Skip to content

Commit 921bb8b

Browse files
authored
feat: implement filter cache rebuilding for mapping, SUPP, and validation (#286)
1 parent 785e528 commit 921bb8b

File tree

8 files changed

+427
-153
lines changed

8 files changed

+427
-153
lines changed

crates/tss-gui/src/app/mod.rs

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use iced::{Element, Size, Subscription, Task, Theme};
2828

2929
use crate::handler::{
3030
DialogHandler, DomainEditorHandler, ExportHandler, HomeHandler, MenuActionHandler,
31-
MessageHandler, SourceAssignmentHandler,
31+
MessageHandler, SourceAssignmentHandler, rebuild_validation_cache,
3232
};
3333
use crate::message::{Message, SettingsCategory};
3434
use crate::state::{AppState, DialogState, DialogType, Settings, ViewState};
@@ -64,9 +64,12 @@ impl App {
6464
};
6565

6666
// Check for post-update status and show toast if update was successful
67-
if let Some(toast) = check_update_status() {
67+
let startup_toast_task = if let Some(toast) = check_update_status() {
6868
app.state.toast = Some(toast);
69-
}
69+
schedule_toast_dismiss()
70+
} else {
71+
Task::none()
72+
};
7073

7174
// Open the main window (daemon mode requires explicit window creation)
7275
// exit_on_close_request: false allows us to handle close events in our subscription
@@ -88,7 +91,7 @@ impl App {
8891
let init_menu = Task::perform(async {}, |_| Message::InitNativeMenu);
8992

9093
// Chain the tasks
91-
let startup = open_window.chain(init_menu);
94+
let startup = open_window.chain(init_menu).chain(startup_toast_task);
9295
(app, startup)
9396
}
9497

@@ -178,7 +181,7 @@ impl App {
178181
);
179182
self.state.toast =
180183
Some(crate::component::feedback::toast::ToastState::warning(msg));
181-
Task::none()
184+
schedule_toast_dismiss()
182185
}
183186

184187
// =================================================================
@@ -309,12 +312,14 @@ impl App {
309312

310313
// Check if there's a pending project restoration
311314
// (when opening a .tss file, we need to apply saved mappings)
312-
if let Some((_, project)) = self.state.pending_project_restore.take() {
315+
let toast_task = if let Some((_, project)) =
316+
self.state.pending_project_restore.take()
317+
{
313318
// Check for changed source files
314319
let changed_files =
315320
crate::handler::project::detect_changed_source_files(&project);
316321

317-
if !changed_files.is_empty() {
322+
let toast_task = if !changed_files.is_empty() {
318323
tracing::warn!(
319324
"Source files changed since last save: {:?}",
320325
changed_files
@@ -327,14 +332,21 @@ impl App {
327332
self.state.toast = Some(
328333
crate::component::feedback::toast::ToastState::warning(msg),
329334
);
330-
}
335+
schedule_toast_dismiss()
336+
} else {
337+
Task::none()
338+
};
331339

332340
// Restore mappings regardless (user can re-map if needed)
333341
crate::handler::project::restore_project_mappings(
334342
&mut self.state,
335343
&project,
336344
);
337-
}
345+
toast_task
346+
} else {
347+
Task::none()
348+
};
349+
return toast_task;
338350
}
339351
Err(err) => {
340352
tracing::error!("Failed to load study: {}", err);
@@ -371,6 +383,12 @@ impl App {
371383
{
372384
domain_state.set_validation_cache(report);
373385
}
386+
// Rebuild validation UI cache since results changed
387+
if let ViewState::DomainEditor(editor) = &self.state.view
388+
&& editor.domain == domain
389+
{
390+
rebuild_validation_cache(&mut self.state, &domain);
391+
}
374392
Task::none()
375393
}
376394

@@ -469,11 +487,53 @@ impl App {
469487
}
470488
ToastMessage::Show(toast_state) => {
471489
self.state.toast = Some(toast_state);
472-
Task::none()
490+
// Schedule auto-dismiss after 5 seconds (#187)
491+
schedule_toast_dismiss()
473492
}
474493
}
475494
}
495+
}
496+
497+
// =============================================================================
498+
// EVENT-DRIVEN TIMERS (#187, #193)
499+
// =============================================================================
500+
501+
/// Duration before a toast auto-dismisses.
502+
const TOAST_DISMISS_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
476503

504+
/// Duration before checking if auto-save should trigger (debounce).
505+
const AUTO_SAVE_DEBOUNCE_DELAY: std::time::Duration = std::time::Duration::from_secs(2);
506+
507+
/// Schedule a one-shot timer to auto-dismiss the current toast.
508+
///
509+
/// This replaces the polling subscription with an event-driven pattern.
510+
/// Returns a Task that sleeps for 5 seconds then sends a Dismiss message.
511+
fn schedule_toast_dismiss() -> Task<Message> {
512+
use crate::component::feedback::toast::ToastMessage;
513+
514+
Task::perform(
515+
async {
516+
tokio::time::sleep(TOAST_DISMISS_DELAY).await;
517+
},
518+
|()| Message::Toast(ToastMessage::Dismiss),
519+
)
520+
}
521+
522+
/// Schedule a one-shot timer to check if auto-save should trigger.
523+
///
524+
/// This replaces the polling subscription with an event-driven pattern.
525+
/// Multiple timers can be in-flight - `should_auto_save()` returns false
526+
/// if already saved or save in progress, making extra triggers harmless.
527+
pub fn schedule_auto_save_check() -> Task<Message> {
528+
Task::perform(
529+
async {
530+
tokio::time::sleep(AUTO_SAVE_DEBOUNCE_DELAY).await;
531+
},
532+
|()| Message::AutoSaveTick,
533+
)
534+
}
535+
536+
impl App {
477537
/// Render the view for a specific window.
478538
///
479539
/// This is a pure function that produces UI based on current state.

crates/tss-gui/src/app/subscription.rs

Lines changed: 19 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,29 @@
55
//!
66
//! # Subscription Overview
77
//!
8-
//! | Subscription | Interval | Condition | Purpose |
9-
//! |--------------|----------|-----------|---------|
10-
//! | Keyboard | Continuous | Always | Global keyboard shortcuts |
11-
//! | System Theme | Continuous | Always | Track OS theme changes |
8+
//! | Subscription | Type | Condition | Purpose |
9+
//! |--------------|------|-----------|---------|
10+
//! | Keyboard | Event-driven | Always | Global keyboard shortcuts |
11+
//! | System Theme | Event-driven | Always | Track OS theme changes |
1212
//! | Menu (macOS) | 100ms poll | Always | Native menu bar events |
13-
//! | Window Close | Continuous | Always | Dialog window cleanup |
14-
//! | Toast Dismiss | 5 seconds | Toast visible | Auto-dismiss notifications |
15-
//! | Auto-Save | 500ms poll | Study loaded + enabled | Debounced project saves |
13+
//! | Window Close | Event-driven | Always | Dialog window cleanup |
14+
//! | Auto-Save | Event-driven | Study loaded + enabled | Debounced project saves |
15+
//!
16+
//! # Event-Driven Patterns (Issue #187, #193)
17+
//!
18+
//! Toast notifications and auto-save use one-shot Task::perform timers instead
19+
//! of polling subscriptions:
20+
//! - Toast: 5-second timer started when toast is shown
21+
//! - Auto-save: 2-second debounce timer started when changes are made
1622
//!
1723
//! # Architecture
1824
//!
1925
//! Subscriptions are batched together in `create_subscription()` and run
20-
//! concurrently. Conditional subscriptions (toast, auto-save) return
21-
//! `Subscription::none()` when their condition is not met, avoiding
22-
//! unnecessary polling.
23-
24-
use std::time::Duration;
26+
//! concurrently.
2527
26-
use iced::Subscription;
2728
use iced::keyboard;
2829
use iced::window;
29-
use iced::{system, time};
30+
use iced::{Subscription, system};
3031

3132
use crate::message::Message;
3233
use crate::state::AppState;
@@ -38,16 +39,15 @@ use crate::state::AppState;
3839
/// - System theme changes for automatic theme switching
3940
/// - Native menu events (macOS only)
4041
/// - Window close events for dialog cleanup
41-
/// - Toast auto-dismiss timer (conditional)
42-
/// - Auto-save timer (conditional)
43-
pub fn create_subscription(state: &AppState) -> Subscription<Message> {
42+
///
43+
/// Note: Toast auto-dismiss and auto-save use Task::perform instead of
44+
/// polling subscriptions (see #187, #193).
45+
pub fn create_subscription(_state: &AppState) -> Subscription<Message> {
4446
Subscription::batch([
4547
keyboard_subscription(),
4648
system_theme_subscription(),
4749
menu_subscription(),
4850
window_close_subscription(),
49-
toast_subscription(state),
50-
auto_save_subscription(state),
5151
])
5252
}
5353

@@ -102,43 +102,6 @@ fn window_close_subscription() -> Subscription<Message> {
102102
window::close_requests().map(Message::DialogWindowClosed)
103103
}
104104

105-
/// Toast auto-dismiss subscription.
106-
///
107-
/// When a toast notification is visible, polls every 5 seconds to
108-
/// trigger auto-dismissal. Returns no subscription when no toast exists.
109-
///
110-
/// # Conditional Behavior
111-
/// - Active: When `state.toast.is_some()`
112-
/// - Inactive: When no toast is displayed
113-
fn toast_subscription(state: &AppState) -> Subscription<Message> {
114-
if state.toast.is_some() {
115-
time::every(Duration::from_secs(5))
116-
.map(|_| Message::Toast(crate::message::ToastMessage::Dismiss))
117-
} else {
118-
Subscription::none()
119-
}
120-
}
121-
122-
/// Auto-save subscription.
123-
///
124-
/// Polls every 500ms to check if an auto-save should trigger. The actual
125-
/// save only occurs if the dirty tracker indicates changes need saving.
126-
///
127-
/// # Conditional Behavior
128-
/// - Active: When auto-save is enabled AND a study is loaded
129-
/// - Inactive: When auto-save is disabled OR no study is loaded
130-
///
131-
/// The 500ms interval provides a balance between:
132-
/// - Responsiveness (saves happen within 500ms of idle threshold)
133-
/// - Efficiency (only 2 checks per second)
134-
fn auto_save_subscription(state: &AppState) -> Subscription<Message> {
135-
if state.auto_save_config.enabled && state.study.is_some() {
136-
time::every(Duration::from_millis(500)).map(|_| Message::AutoSaveTick)
137-
} else {
138-
Subscription::none()
139-
}
140-
}
141-
142105
#[cfg(test)]
143106
mod tests {
144107
// Note: Subscription testing requires an Iced runtime, which is not

0 commit comments

Comments
 (0)