diff --git a/crates/commands/src/caps.rs b/crates/commands/src/caps.rs index a48286f..bcfd700 100644 --- a/crates/commands/src/caps.rs +++ b/crates/commands/src/caps.rs @@ -6,6 +6,7 @@ use adhd_ranch_domain::{ use adhd_ranch_storage::FocusStore; use crate::error::CommandError; +use crate::SettingsProvider; pub trait CapNotifier: Send + Sync { fn focuses_over_cap(&self, max: usize); @@ -18,7 +19,7 @@ pub struct CapEvaluator { store: Arc, monitor: Arc, notifier: Arc, - settings: Settings, + settings: SettingsProvider, } impl CapEvaluator { @@ -27,6 +28,20 @@ impl CapEvaluator { monitor: Arc, notifier: Arc, settings: Settings, + ) -> Self { + Self::new_with_settings_provider( + store, + monitor, + notifier, + Arc::new(move || settings.clone()), + ) + } + + pub fn new_with_settings_provider( + store: Arc, + monitor: Arc, + notifier: Arc, + settings: SettingsProvider, ) -> Self { Self { store, @@ -38,18 +53,15 @@ impl CapEvaluator { pub fn evaluate(&self) -> Result<(), CommandError> { let focuses = self.store.list()?; - let state = cap_state(&focuses, self.settings.caps); + let settings = self.settings.get(); + let state = cap_state(&focuses, settings.caps); let transition = self.monitor.evaluate(&state); - let focuses_enabled = self - .settings - .notifications - .is_enabled(&FocusesOverCapSource); - let tasks_enabled = self.settings.notifications.is_enabled(&TasksOverCapSource); + let focuses_enabled = settings.notifications.is_enabled(&FocusesOverCapSource); + let tasks_enabled = settings.notifications.is_enabled(&TasksOverCapSource); if focuses_enabled && transition.focuses_to_over { - self.notifier - .focuses_over_cap(self.settings.caps.max_focuses); + self.notifier.focuses_over_cap(settings.caps.max_focuses); } if focuses_enabled && transition.focuses_to_under { self.notifier.focuses_under_cap(); @@ -57,7 +69,7 @@ impl CapEvaluator { if tasks_enabled { for id in &transition.task_to_over_focus_ids { self.notifier - .task_over_cap(id, self.settings.caps.max_tasks_per_focus); + .task_over_cap(id, settings.caps.max_tasks_per_focus); } for id in &transition.task_to_under_focus_ids { self.notifier.task_under_cap(id); @@ -342,4 +354,31 @@ mod tests { assert!(calls.contains(&Call::FocusesOver(5))); assert!(!calls.contains(&Call::TaskOver("f0".into(), 7))); } + + #[test] + fn notification_source_toggle_is_read_from_latest_settings() { + let store = Arc::new(StubStore::new()); + let notifier = Arc::new(RecordingNotifier::new()); + let settings = Arc::new(Mutex::new(settings(all_enabled()))); + let evaluator = CapEvaluator::new_with_settings_provider( + store.clone(), + Arc::new(OverCapMonitor::new()), + notifier.clone(), + { + let settings = settings.clone(); + Arc::new(move || settings.lock().unwrap().clone()) + }, + ); + + store.set(vec![focus_with_tasks("a", 9)]); + settings + .lock() + .unwrap() + .notifications + .set(&adhd_ranch_domain::TasksOverCapSource, false); + + evaluator.evaluate().unwrap(); + + assert!(notifier.calls().is_empty()); + } } diff --git a/crates/commands/src/focus.rs b/crates/commands/src/focus.rs index 3991382..1076e05 100644 --- a/crates/commands/src/focus.rs +++ b/crates/commands/src/focus.rs @@ -155,7 +155,7 @@ impl Commands { } pub fn caps(&self) -> Caps { - self.settings.caps + self.settings().caps } } diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs index 2cdd576..5885e39 100644 --- a/crates/commands/src/lib.rs +++ b/crates/commands/src/lib.rs @@ -23,6 +23,21 @@ pub type Clock = Arc String + Send + Sync>; pub type ClockSecs = Arc i64 + Send + Sync>; pub type IdGen = Arc String + Send + Sync>; +pub trait SettingsReader: Send + Sync { + fn get(&self) -> Settings; +} + +impl SettingsReader for F +where + F: Fn() -> Settings + Send + Sync, +{ + fn get(&self) -> Settings { + self() + } +} + +pub type SettingsProvider = Arc; + pub struct Commands { pub(crate) store: Arc, pub(crate) queue: Arc, @@ -30,7 +45,7 @@ pub struct Commands { pub(crate) clock: Clock, pub(crate) clock_secs: ClockSecs, pub(crate) id_gen: IdGen, - pub(crate) settings: Settings, + pub(crate) settings: SettingsProvider, } impl Commands { @@ -42,6 +57,26 @@ impl Commands { clock_secs: ClockSecs, id_gen: IdGen, settings: Settings, + ) -> Self { + Self::new_with_settings_provider( + store, + queue, + decisions, + clock, + clock_secs, + id_gen, + Arc::new(move || settings.clone()), + ) + } + + pub fn new_with_settings_provider( + store: Arc, + queue: Arc, + decisions: Arc, + clock: Clock, + clock_secs: ClockSecs, + id_gen: IdGen, + settings: SettingsProvider, ) -> Self { let lifecycle = Arc::new(ProposalLifecycle::new( store.clone(), @@ -63,6 +98,45 @@ impl Commands { } pub fn settings(&self) -> Settings { - self.settings.clone() + self.settings.get() + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use adhd_ranch_domain::{Caps, Settings}; + use adhd_ranch_storage::{JsonlDecisionLog, JsonlProposalQueue, MarkdownFocusStore}; + use tempfile::TempDir; + + use super::*; + + #[test] + fn caps_reads_latest_settings_provider_value() { + let dir = TempDir::new().unwrap(); + let settings = Arc::new(Mutex::new(Settings::default())); + let commands = Commands::new_with_settings_provider( + Arc::new(MarkdownFocusStore::new(dir.path().join("focuses"))), + Arc::new(JsonlProposalQueue::new(dir.path().join("proposals.jsonl"))), + Arc::new(JsonlDecisionLog::new(dir.path().join("decisions.jsonl"))), + Arc::new(|| "2026-01-01T00:00:00Z".to_string()), + Arc::new(|| 1_700_000_000), + Arc::new(|| "id-fixed".to_string()), + { + let settings = settings.clone(); + Arc::new(move || settings.lock().unwrap().clone()) + }, + ); + + assert_eq!(commands.caps().max_focuses, 5); + + settings.lock().unwrap().caps = Caps { + max_focuses: 9, + max_tasks_per_focus: 11, + }; + + assert_eq!(commands.caps().max_focuses, 9); + assert_eq!(commands.caps().max_tasks_per_focus, 11); } } diff --git a/issues/031-notification-source-settings.md b/issues/031-notification-source-settings.md index b9ae301..033fa69 100644 --- a/issues/031-notification-source-settings.md +++ b/issues/031-notification-source-settings.md @@ -51,4 +51,4 @@ Notification toggles in Preferences are driven by the Rust `NotificationSource` ## Blocked by -None. 029 and 032 are done. +050 — settings update workflow and runtime freshness. 029 and 032 are done. diff --git a/issues/050-settings-update-workflow.md b/issues/050-settings-update-workflow.md index 5332b66..7d242c5 100644 --- a/issues/050-settings-update-workflow.md +++ b/issues/050-settings-update-workflow.md @@ -10,6 +10,8 @@ Move settings update coordination out of the Tauri command handler and into a de Today `ui_bridge::update_settings` mutates in-memory Settings, persists `settings.yaml`, applies always-on-top behavior, updates display state, reapplies overlays, and rebuilds the tray menu inline. That makes the command handler the place where ordering and side effects live. +There is also a runtime freshness bug hiding behind that shallow module: settings are copied into several long-lived adapters at startup. `SettingsState` updates when Preferences changes, but `Commands::settings`, `CapEvaluator::settings`, and tray rebuild handlers keep their original `Settings` value. That means caps, notification source toggles, and tray badge state can disagree with the newly persisted `settings.yaml` until restart. + ### Target shape Add an app-side workflow module, for example `src-tauri/src/app/settings_workflow.rs`. @@ -30,22 +32,29 @@ Adapters should cover: - settings file persistence - overlay always-on-top application - display config reapply +- cap evaluator / command settings freshness - tray menu rebuild The workflow owns ordering. A reasonable initial invariant: persist first, then update memory and apply runtime effects. If implementation chooses a different invariant, document it in tests. +The workflow should make one committed `Settings` value authoritative for runtime consumers. It is acceptable to replace startup-copied `Settings` fields with a shared settings provider/adapter, or to update those adapters as part of the workflow, as long as the result is that Preferences changes take effect immediately without requiring app restart. + ## Completion promise -`ui_bridge::update_settings` delegates to a settings workflow module; settings persistence, in-memory update, display effects, and tray rebuild ordering are tested through one interface. +`ui_bridge::update_settings` delegates to a settings workflow module; settings persistence, runtime settings freshness, display effects, cap/notification behavior, and tray rebuild ordering are tested through one interface. ## Acceptance criteria -- [ ] `src-tauri/src/app/settings_workflow.rs` exists -- [ ] `ui_bridge::update_settings` delegates to the workflow and contains no inline display/tray/window coordination -- [ ] The workflow uses injected adapters or small helper traits for side effects -- [ ] Tests cover persistence failure, display-change behavior, non-display widget setting behavior, and tray rebuild invocation -- [ ] The chosen ordering invariant is documented in test names or code comments -- [ ] `task check` green +- [x] `src-tauri/src/app/settings_workflow.rs` exists +- [x] `ui_bridge::update_settings` delegates to the workflow and contains no inline display/tray/window coordination +- [x] The workflow uses injected adapters or small helper traits for side effects +- [x] Runtime consumers that currently receive startup-copied `Settings` observe the updated Settings without restart +- [x] `get_caps` reflects updated cap settings after `update_settings` +- [x] Cap and timer notification source toggles take effect on the next evaluation/tick without restart +- [x] Tray menu rebuild and tray badge calculations use the updated caps +- [x] Tests cover persistence failure, display-change behavior, non-display widget setting behavior, runtime settings freshness, and tray rebuild invocation +- [x] The chosen ordering invariant is documented in test names or code comments +- [x] `task check` green ## Blocked by @@ -54,3 +63,4 @@ None ## User stories addressed - "When settings change, the app applies one coherent workflow instead of scattering persistence and side effects through the command handler." +- "When I change caps or notification toggles in Preferences, the app behavior changes immediately without restarting." diff --git a/issues/README.md b/issues/README.md index eb8ad23..22bd906 100644 --- a/issues/README.md +++ b/issues/README.md @@ -6,6 +6,7 @@ Each issue is a self-contained vertical slice an AFK coding agent ("ralph") can Complete this product slice before picking up architecture-only work. +- [050](050-settings-update-workflow.md) — Settings update workflow and runtime freshness - [031](031-notification-source-settings.md) — Notification source settings registry (GH #31) ### Architecture queue (deepening, AFK except where noted) @@ -14,7 +15,6 @@ These are not required before 031. Pick one when we choose to spend a slice on i - [046](046-timer-expiry-service-extraction.md) — Timer expiry workflow module - [047](047-storage-transaction-seam.md) — Storage transaction seam for Proposal lifecycle -- [050](050-settings-update-workflow.md) — Settings update workflow module - [051](051-ranch-animal-vocabulary-seam.md) — RanchAnimal vocabulary seam for future animal types Completed issue files live in `issues/done/`. Do not pick up files from `issues/done/` or `issues/icebox/`. diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 08e483b..ad5a707 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -2,6 +2,7 @@ pub mod cap_notifier; pub mod menu; pub mod paths; pub mod seed; +pub mod settings_workflow; pub mod timer_expiry; pub mod tray; pub mod window_always_on_top; @@ -90,24 +91,36 @@ pub fn run() { let decision_log: Arc = Arc::new(JsonlDecisionLog::new(decisions_path.clone())); - let commands = Arc::new(Commands::new( + let settings_state = Arc::new(Mutex::new(settings.clone())); + let settings_provider: adhd_ranch_commands::SettingsProvider = { + let settings_state = Arc::clone(&settings_state); + Arc::new(move || match settings_state.lock() { + Ok(settings) => settings.clone(), + Err(poisoned) => { + log::warn!("settings provider: lock poisoned; using recovered settings"); + poisoned.into_inner().clone() + } + }) + }; + + let commands = Arc::new(Commands::new_with_settings_provider( store.clone(), queue.clone(), decision_log.clone(), Arc::new(now_rfc3339), Arc::new(now_unix_secs), Arc::new(|| uuid::Uuid::now_v7().to_string()), - settings.clone(), + Arc::clone(&settings_provider), )); seed::ensure_example_focus(&commands, &focuses_root)?; let cap_monitor = Arc::new(OverCapMonitor::new()); let notifier = Arc::new(TauriCapNotifier::new(app.handle().clone())); - let evaluator = Arc::new(CapEvaluator::new( + let evaluator = Arc::new(CapEvaluator::new_with_settings_provider( store.clone(), cap_monitor, notifier, - settings.clone(), + Arc::clone(&settings_provider), )); app.manage(ui_bridge::CommandsState(commands)); @@ -131,7 +144,7 @@ pub fn run() { app.manage(DisplayConfigState(Arc::new(Mutex::new( display_config.clone(), )))); - app.manage(SettingsState(Arc::new(Mutex::new(settings.clone())))); + app.manage(SettingsState(Arc::clone(&settings_state))); app.manage(SettingsPathState(settings_path.clone())); app.manage(DebugOverlayState(Arc::new(Mutex::new(false)))); @@ -156,7 +169,7 @@ pub fn run() { tray_icon.clone(), app.handle().clone(), store.clone(), - settings, + Arc::clone(&settings_provider), ), ], )?; diff --git a/src-tauri/src/app/settings_workflow.rs b/src-tauri/src/app/settings_workflow.rs new file mode 100644 index 0000000..e39c4ec --- /dev/null +++ b/src-tauri/src/app/settings_workflow.rs @@ -0,0 +1,426 @@ +use adhd_ranch_domain::Settings; +use adhd_ranch_storage::write_settings; +use tauri::{AppHandle, Manager, Wry}; + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use super::{DisplayConfigState, MonitorsState}; + +pub trait SettingsPersistence: Send + Sync { + fn persist(&self, settings: &Settings) -> Result<(), SettingsWorkflowError>; +} + +impl SettingsPersistence for std::sync::Arc +where + T: SettingsPersistence + ?Sized, +{ + fn persist(&self, settings: &Settings) -> Result<(), SettingsWorkflowError> { + (**self).persist(settings) + } +} + +pub trait SettingsRuntime: Send + Sync { + fn current(&self) -> Result; + fn commit(&self, settings: Settings) -> Result<(), SettingsWorkflowError>; +} + +impl SettingsRuntime for std::sync::Arc +where + T: SettingsRuntime + ?Sized, +{ + fn current(&self) -> Result { + (**self).current() + } + + fn commit(&self, settings: Settings) -> Result<(), SettingsWorkflowError> { + (**self).commit(settings) + } +} + +pub trait SettingsEffects: Send + Sync { + fn apply_widget(&self, settings: &Settings) -> Result<(), SettingsWorkflowError>; + fn apply_displays( + &self, + previous: &Settings, + next: &Settings, + ) -> Result<(), SettingsWorkflowError>; + fn refresh_runtime_consumers(&self, settings: &Settings) -> Result<(), SettingsWorkflowError>; + fn rebuild_tray(&self) -> Result<(), SettingsWorkflowError>; +} + +impl SettingsEffects for std::sync::Arc +where + T: SettingsEffects + ?Sized, +{ + fn apply_widget(&self, settings: &Settings) -> Result<(), SettingsWorkflowError> { + (**self).apply_widget(settings) + } + + fn apply_displays( + &self, + previous: &Settings, + next: &Settings, + ) -> Result<(), SettingsWorkflowError> { + (**self).apply_displays(previous, next) + } + + fn refresh_runtime_consumers(&self, settings: &Settings) -> Result<(), SettingsWorkflowError> { + (**self).refresh_runtime_consumers(settings) + } + + fn rebuild_tray(&self) -> Result<(), SettingsWorkflowError> { + (**self).rebuild_tray() + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SettingsWorkflowError { + Persist(String), + Runtime(String), + Effect(String), +} + +pub struct SettingsWorkflow { + persistence: P, + runtime: R, + effects: E, +} + +impl SettingsWorkflow +where + P: SettingsPersistence, + R: SettingsRuntime, + E: SettingsEffects, +{ + pub fn new(persistence: P, runtime: R, effects: E) -> Self { + Self { + persistence, + runtime, + effects, + } + } + + pub fn update(&self, next: Settings) -> Result<(), SettingsWorkflowError> { + let previous = self.runtime.current()?; + self.persistence.persist(&next)?; + self.runtime.commit(next.clone())?; + self.effects.apply_widget(&next)?; + if previous.displays != next.displays { + self.effects.apply_displays(&previous, &next)?; + } + self.effects.refresh_runtime_consumers(&next)?; + self.effects.rebuild_tray()?; + Ok(()) + } +} + +pub struct FileSettingsPersistence { + path: PathBuf, +} + +impl FileSettingsPersistence { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl SettingsPersistence for FileSettingsPersistence { + fn persist(&self, settings: &Settings) -> Result<(), SettingsWorkflowError> { + write_settings(&self.path, settings) + .map_err(|e| SettingsWorkflowError::Persist(format!("persist settings: {e}"))) + } +} + +pub struct SharedSettingsRuntime { + state: Arc>, +} + +impl SharedSettingsRuntime { + pub fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl SettingsRuntime for SharedSettingsRuntime { + fn current(&self) -> Result { + self.state + .lock() + .map(|settings| settings.clone()) + .map_err(|_| SettingsWorkflowError::Runtime("settings lock poisoned".to_string())) + } + + fn commit(&self, settings: Settings) -> Result<(), SettingsWorkflowError> { + *self + .state + .lock() + .map_err(|_| SettingsWorkflowError::Runtime("settings lock poisoned".to_string()))? = + settings; + Ok(()) + } +} + +pub struct TauriSettingsEffects { + app: AppHandle, +} + +impl TauriSettingsEffects { + pub fn new(app: AppHandle) -> Self { + Self { app } + } +} + +impl SettingsEffects for TauriSettingsEffects { + fn apply_widget(&self, settings: &Settings) -> Result<(), SettingsWorkflowError> { + let monitors_count = self + .app + .try_state::() + .map(|s| s.0.len()) + .unwrap_or(1); + for i in 0..monitors_count { + if let Some(window) = self.app.get_webview_window(&format!("overlay-{i}")) { + crate::app::window_always_on_top::apply(&window, settings.widget.always_on_top); + } + } + Ok(()) + } + + fn apply_displays( + &self, + _previous: &Settings, + next: &Settings, + ) -> Result<(), SettingsWorkflowError> { + if let Some(display_state) = self.app.try_state::() { + match display_state.0.lock() { + Ok(mut config) => *config = next.displays.clone(), + Err(e) => { + return Err(SettingsWorkflowError::Effect(format!( + "display config lock poisoned: {e}" + ))); + } + } + } + if let Some(display_state) = self.app.try_state::() { + if let Some(monitors_state) = self.app.try_state::() { + let display_manager = Arc::clone(&display_state.0); + let monitors = monitors_state.0.clone(); + let config = next.displays.clone(); + let app = self.app.clone(); + self.app + .run_on_main_thread(move || { + display_manager.apply(&app, &monitors, &config); + }) + .map_err(|e| { + SettingsWorkflowError::Effect(format!("run_on_main_thread failed: {e}")) + })?; + } + } + Ok(()) + } + + fn refresh_runtime_consumers(&self, _settings: &Settings) -> Result<(), SettingsWorkflowError> { + Ok(()) + } + + fn rebuild_tray(&self) -> Result<(), SettingsWorkflowError> { + crate::app::tray::rebuild_tray_menu(&self.app); + Ok(()) + } +} + +pub fn workflow_for_app( + app: AppHandle, + settings_state: Arc>, + settings_path: PathBuf, +) -> SettingsWorkflow { + SettingsWorkflow::new( + FileSettingsPersistence::new(settings_path), + SharedSettingsRuntime::new(settings_state), + TauriSettingsEffects::new(app), + ) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use adhd_ranch_domain::{Caps, Settings}; + + use super::{ + SettingsEffects, SettingsPersistence, SettingsRuntime, SettingsWorkflow, + SettingsWorkflowError, + }; + + struct RecordingPersistence { + should_fail: bool, + persisted: Mutex>, + } + + impl RecordingPersistence { + fn new() -> Self { + Self { + should_fail: false, + persisted: Mutex::new(Vec::new()), + } + } + + fn failing() -> Self { + Self { + should_fail: true, + persisted: Mutex::new(Vec::new()), + } + } + + fn persisted(&self) -> Vec { + self.persisted.lock().unwrap().clone() + } + } + + impl SettingsPersistence for RecordingPersistence { + fn persist(&self, settings: &Settings) -> Result<(), SettingsWorkflowError> { + if self.should_fail { + return Err(SettingsWorkflowError::Persist("disk full".to_string())); + } + self.persisted.lock().unwrap().push(settings.clone()); + Ok(()) + } + } + + struct RecordingRuntime { + settings: Mutex, + } + + impl RecordingRuntime { + fn new(settings: Settings) -> Self { + Self { + settings: Mutex::new(settings), + } + } + + fn settings(&self) -> Settings { + self.settings.lock().unwrap().clone() + } + } + + impl SettingsRuntime for RecordingRuntime { + fn current(&self) -> Result { + Ok(self.settings()) + } + + fn commit(&self, settings: Settings) -> Result<(), SettingsWorkflowError> { + *self.settings.lock().unwrap() = settings; + Ok(()) + } + } + + #[derive(Default)] + struct RecordingEffects { + calls: Mutex>, + } + + impl RecordingEffects { + fn calls(&self) -> Vec<&'static str> { + self.calls.lock().unwrap().clone() + } + } + + impl SettingsEffects for RecordingEffects { + fn apply_widget(&self, _settings: &Settings) -> Result<(), SettingsWorkflowError> { + self.calls.lock().unwrap().push("widget"); + Ok(()) + } + + fn apply_displays( + &self, + _previous: &Settings, + _next: &Settings, + ) -> Result<(), SettingsWorkflowError> { + self.calls.lock().unwrap().push("displays"); + Ok(()) + } + + fn refresh_runtime_consumers( + &self, + _settings: &Settings, + ) -> Result<(), SettingsWorkflowError> { + self.calls.lock().unwrap().push("runtime"); + Ok(()) + } + + fn rebuild_tray(&self) -> Result<(), SettingsWorkflowError> { + self.calls.lock().unwrap().push("tray"); + Ok(()) + } + } + + fn settings_with_max_focuses(max_focuses: usize) -> Settings { + Settings { + caps: Caps { + max_focuses, + ..Caps::default() + }, + ..Settings::default() + } + } + + fn settings_with_displays(enabled_indices: Vec) -> Settings { + Settings { + displays: adhd_ranch_domain::DisplayConfig { enabled_indices }, + ..Settings::default() + } + } + + #[test] + fn persist_failure_does_not_commit_runtime_settings_or_apply_effects() { + let original = settings_with_max_focuses(5); + let next = settings_with_max_focuses(9); + let runtime = Arc::new(RecordingRuntime::new(original.clone())); + let effects = Arc::new(RecordingEffects::default()); + let workflow = SettingsWorkflow::new( + Arc::new(RecordingPersistence::failing()), + runtime.clone(), + effects.clone(), + ); + + let err = workflow.update(next).unwrap_err(); + + assert!(matches!(err, SettingsWorkflowError::Persist(_))); + assert_eq!(runtime.settings(), original); + assert!(effects.calls().is_empty()); + } + + #[test] + fn non_display_update_persists_commits_runtime_and_skips_display_reapply() { + let original = settings_with_max_focuses(5); + let next = settings_with_max_focuses(9); + let persistence = Arc::new(RecordingPersistence::new()); + let runtime = Arc::new(RecordingRuntime::new(original)); + let effects = Arc::new(RecordingEffects::default()); + let workflow = SettingsWorkflow::new(persistence.clone(), runtime.clone(), effects.clone()); + + workflow.update(next.clone()).unwrap(); + + assert_eq!(persistence.persisted(), vec![next.clone()]); + assert_eq!(runtime.settings(), next); + assert_eq!(effects.calls(), vec!["widget", "runtime", "tray"]); + } + + #[test] + fn display_update_reapplies_displays_between_widget_and_runtime_refresh() { + let original = settings_with_displays(vec![0]); + let next = settings_with_displays(vec![0, 1]); + let persistence = Arc::new(RecordingPersistence::new()); + let runtime = Arc::new(RecordingRuntime::new(original)); + let effects = Arc::new(RecordingEffects::default()); + let workflow = SettingsWorkflow::new(persistence.clone(), runtime.clone(), effects.clone()); + + workflow.update(next.clone()).unwrap(); + + assert_eq!(persistence.persisted(), vec![next.clone()]); + assert_eq!(runtime.settings(), next); + assert_eq!( + effects.calls(), + vec!["widget", "displays", "runtime", "tray"] + ); + } +} diff --git a/src-tauri/src/app/tray.rs b/src-tauri/src/app/tray.rs index 2cfc944..70c9acb 100644 --- a/src-tauri/src/app/tray.rs +++ b/src-tauri/src/app/tray.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use adhd_ranch_commands::SettingsProvider; use adhd_ranch_domain::{cap_state, Focus, Settings, TimerStatus}; use adhd_ranch_storage::FocusStore; use tauri::image::Image; @@ -94,7 +95,7 @@ pub fn rebuild_handler( tray: TrayIcon, handle: AppHandle, store: Arc, - settings: Settings, + settings: SettingsProvider, ) -> Box { Box::new(move || { let focuses = match store.list() { @@ -107,7 +108,7 @@ pub fn rebuild_handler( if let Ok(menu) = build_menu(&handle, &focuses) { let _ = tray.set_menu(Some(menu)); } - let over_cap = cap_state(&focuses, settings.caps).any_over(); + let over_cap = cap_state(&focuses, settings.get().caps).any_over(); if over_cap { let _ = tray.set_icon(Some(red_icon())); } else if let Some(icon) = handle.default_window_icon() { @@ -257,6 +258,17 @@ pub fn rebuild_tray_menu(app: &AppHandle) { if let Ok(menu) = build_menu(app, &focuses) { let _ = tray.set_menu(Some(menu)); } + let over_cap = app + .try_state::() + .map(|s| cap_state(&focuses, s.0.caps()).any_over()) + .unwrap_or(false); + if over_cap { + let _ = tray.set_icon(Some(red_icon())); + } else if let Some(icon) = app.default_window_icon() { + let _ = tray.set_icon(Some(icon.clone())); + } else { + let _ = tray.set_icon(None); + } } } diff --git a/src-tauri/src/ui_bridge/mod.rs b/src-tauri/src/ui_bridge/mod.rs index 6380ee9..331e01a 100644 --- a/src-tauri/src/ui_bridge/mod.rs +++ b/src-tauri/src/ui_bridge/mod.rs @@ -6,7 +6,6 @@ use adhd_ranch_commands::{ ProposalEdit, }; use adhd_ranch_domain::{Caps, Focus, Proposal, Settings, TimerPreset}; -use adhd_ranch_storage::write_settings; use tauri::{AppHandle, Emitter, Manager, State, Wry}; @@ -263,52 +262,9 @@ pub fn update_settings( state: State<'_, SettingsState>, path_state: State<'_, SettingsPathState>, ) -> Result<(), String> { - let old_displays = { - let Ok(mut s) = state.0.lock() else { - return Err("settings lock poisoned".to_string()); - }; - let old = s.displays.clone(); - *s = settings.clone(); - old - }; - - write_settings(&path_state.0, &settings).map_err(|e| format!("persist settings: {e}"))?; - - let monitors_count = app - .try_state::() - .map(|s| s.0.len()) - .unwrap_or(1); - for i in 0..monitors_count { - if let Some(w) = app.get_webview_window(&format!("overlay-{i}")) { - crate::app::window_always_on_top::apply(&w, settings.widget.always_on_top); - } - } - - if settings.displays != old_displays { - if let Some(display_state) = app.try_state::() { - match display_state.0.lock() { - Ok(mut config) => *config = settings.displays.clone(), - Err(e) => log::error!("update_settings: display config lock poisoned: {e}"), - } - } - if let Some(overlay_state) = app.try_state::() { - if let Some(monitors_state) = app.try_state::() { - let display_mgr = Arc::clone(&overlay_state.0); - let monitors = monitors_state.0.clone(); - let config = settings.displays.clone(); - let app_clone = app.clone(); - if let Err(e) = app.run_on_main_thread(move || { - display_mgr.apply(&app_clone, &monitors, &config); - }) { - log::error!("update_settings: run_on_main_thread failed: {e}"); - } - } - } - } - - crate::app::tray::rebuild_tray_menu(&app); - - Ok(()) + crate::app::settings_workflow::workflow_for_app(app, Arc::clone(&state.0), path_state.0.clone()) + .update(settings) + .map_err(|e| format!("{e:?}")) } use adhd_ranch_domain::MonitorInfo;