From e1622b5959970d6edd294c9bb22a85cce8c91738 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 07:14:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(windows):=20Phase=206=20C2=20=E2=80=94=20c?= =?UTF-8?q?lose-to-tray=20+=20Settings=20toggle=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/settings-page.slint: SettingsToggleRow 的 toggled callback 从 (bool) 改成 (string, bool) — 把 row 自己的 label + 新 value 一起交给 caller;row 内部先 value=!value 再 emit;value 改 in-out property 让内部可以 flip;SettingsPage 加 callback toggle-persist(string, bool);7 个真 toggle 全部 wire 到 toggle-persist(Notifications 4 + Startup 3) - ui/app.slint: MainWindow 加 in-out property ×7 + setting-toggled(string, bool) callback;启动从 ActiveConfig 装载;SettingsPage section 把 7 个 property forward 给 page + toggle-persist 转接到 root.setting-toggled - src/lib.rs: · 启动从 ActiveConfig 读 7 个 toggle 设到 MainWindow · 新 on_setting_toggled 回调:load AppConfig → 按 label 改 对应字段 → save_config;start_with_windows 额外调 startup::set_enabled 写 HKCU Run(Phase 6 C1) · on_close_requested 看 close_to_tray:true 时 window().hide() + KeepWindowShown;false 时保持原来的 HideWindow 行为 避坑: - Slint 1.x callback 不能直接用 label 区分,必须 caller 传 string 参数;改造 (bool) → (string, bool) - 设置 in-out property 让 row 内部能 flip value 然后 emit 新值(caller 收到的就是 post-flip 值) - close_to_tray 在 UI MainWindow 上是 in-out property,Rust 端用 get_close_to_tray() 实时读,所以 toggle 立刻影响 close_requested 行为(不需要重启) - on_close_requested 内部读 get_close_to_tray 要 upgrade() MainWindow weak handle,捕获空时 fallback false(默认 close 而不是 hide) 验证:cargo build / build --release 都 exit 0,0 warning - cargo test: 15 passed; 0 failed - debug 35.64 MB, subsystem = WINDOWS_CUI - release 9.78 MB, subsystem = WINDOWS_GUI --- src/lib.rs | 88 ++++++++++++++++++++++++++++++++++++++++-- ui/app.slint | 25 ++++++++++++ ui/settings-page.slint | 50 +++++++++++++++++++++--- 3 files changed, 155 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0d7209d..9c74ec2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,6 +96,18 @@ pub fn run() -> error::AppResult<()> { state_rc.borrow().last_window_height.max(0) as f32, ))); + // ── Phase 6 C2: load live AppConfig toggles into MainWindow ─── + { + let cfg = config::ActiveConfig::load()?; + ui.set_desktop_notify(cfg.desktop_notifications); + ui.set_sound(cfg.sound); + ui.set_notify_high(cfg.notify_high); + ui.set_notify_max(cfg.notify_max); + ui.set_start_with_windows(cfg.start_with_windows); + ui.set_start_minimized(cfg.start_minimized); + ui.set_close_to_tray(cfg.close_to_tray); + } + // ── Phase 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ──── let cfg = config::ActiveConfig::load()?; let shared_model: Rc>; @@ -261,6 +273,61 @@ pub fn run() -> error::AppResult<()> { } }); + // ── Phase 6 C2: every other SettingsPage toggle (notifications, + // startup) routes through one callback. We dispatch on the `label` + // string the UI hands us, persist into `config.json`, and for + // `start_with_windows` we also write HKCU Run (Phase 6 C1). + ui.on_setting_toggled(|label, value| { + // Phase 6 C2 also: AppConfig is mutated through a fresh load + + // save because the toggle handler is a `Fn` (no captured + // mutex); this keeps the persistence path side-effect-only and + // doesn't risk racing with the close-requested save below. + let mut cfg = match storage::load_config() { + c => c, + }; + let label_str = label.to_string(); + let label_ref: &str = &label_str; + match label_ref { + "desktop_notifications" => cfg.desktop_notifications = value, + "sound" => cfg.sound = value, + "notify_high" => cfg.notify_high = value, + "notify_max" => cfg.notify_max = value, + "start_minimized" => cfg.start_minimized = value, + "close_to_tray" => { + cfg.close_to_tray = value; + // Runtime-only: also flip the MainWindow property so + // the close-requested handler picks up the new value + // immediately without a restart. The Slint callback + // already set root.close-to-tray = value on the UI + // side; we just mirror it in `cfg` so save_config + // captures the same value. + } + "start_with_windows" => { + cfg.start_with_windows = value; + #[cfg(windows)] + { + use crate::windows::startup; + if !startup::set_enabled(value) { + tracing::warn!("startup: HKCU Run write failed"); + } + } + #[cfg(not(windows))] + { + tracing::debug!("startup: not on Windows, skipping HKCU"); + } + } + _ => { + tracing::warn!(label = %label_str, "settings: unknown toggle label"); + return; + } + } + if let Err(e) = storage::save_config(&cfg) { + tracing::warn!(error = %e, label = %label_str, "settings: save_config failed"); + } else { + tracing::info!(label = %label_str, value, "settings: toggle persisted"); + } + }); + // ── Phase 2 C3 callbacks ─────────────────────────────────────── ui.on_about_clicked({ let ui_handle = ui.as_weak(); @@ -272,13 +339,20 @@ pub fn run() -> error::AppResult<()> { } }); - // ── Phase 2 C3 / 5 C2: save state on OS-close ───────────────── - // Captures the current window geometry, theme, and last_message_id - // (already updated by the WS drain closure). + // ── Phase 2 C3 / 5 C2 / 6 C2: save state on OS-close ────────── + // Phase 6 C2: when `close_to_tray` is true, hide the window and + // pretend to honor the close (return `KeepWindowShown`); otherwise + // let Slint proceed with its default behavior (which hides the + // window on Windows so the process keeps running until Quit). ui.window().on_close_requested({ let state_for_save = state_rc.clone(); let ui_handle = ui.as_weak(); move || { + let close_to_tray = ui_handle + .upgrade() + .map(|u| u.get_close_to_tray()) + .unwrap_or(false); + if let Some(ui) = ui_handle.upgrade() { let size = ui.window().size(); let mut s = state_for_save.borrow_mut(); @@ -297,6 +371,14 @@ pub fn run() -> error::AppResult<()> { "saved state.json on close" ); } + + if close_to_tray { + if let Some(ui) = ui_handle.upgrade() { + let _ = ui.window().hide(); + tracing::info!("close: window hidden (close-to-tray)"); + return slint::CloseRequestResponse::KeepWindowShown; + } + } slint::CloseRequestResponse::HideWindow } }); diff --git a/ui/app.slint b/ui/app.slint index fcdc679..c2af4bd 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -61,6 +61,21 @@ export component MainWindow inherits Window { // Rust persists it into `state.json::last_theme`. callback appearance-changed-persist(int); + // ── Phase 6 C2: live AppConfig toggles (Settings page) ───────── + // Forwarded from SettingsPage so the Rust runtime can: + // 1. Persist into `config.json` + // 2. For `start_with_windows`, also write HKCU Run. + // 3. For `close_to_tray`, immediately affect the close-requested + // handler below without a restart. + in-out property desktop-notify: true; + in-out property sound: true; + in-out property notify-high: true; + in-out property notify-max: true; + in-out property start-with-windows: false; + in-out property start-minimized: false; + in-out property close-to-tray: true; + callback setting-toggled(string, bool); + // ── Phase 3 C2: live connection status ─────────────────────── in-out property is-connected: false; in-out property status-text: "Disconnected"; @@ -210,6 +225,13 @@ export component MainWindow inherits Window { if root.selection == SidebarSelection.Settings: SettingsPage { appearance-index: root.appearance-index; + desktop-notify: root.desktop-notify; + sound: root.sound; + notify-high: root.notify-high; + notify-max: root.notify-max; + start-with-windows: root.start-with-windows; + start-minimized: root.start-minimized; + close-to-tray: root.close-to-tray; appearance-changed(idx) => { root.appearance-index = idx; root.theme = idx == 0 ? AppearanceMode.System : @@ -220,6 +242,9 @@ export component MainWindow inherits Window { test-connection-requested => { root.test-connection-requested(); } + toggle-persist(label, value) => { + root.setting-toggled(label, value); + } } // About entry stays as a simple right-pane marker; the diff --git a/ui/settings-page.slint b/ui/settings-page.slint index dadc07a..417397b 100644 --- a/ui/settings-page.slint +++ b/ui/settings-page.slint @@ -16,9 +16,14 @@ import { ServerCard } from "./server-card.slint"; export component SettingsToggleRow inherits Rectangle { in property label; - in property value: false; + // `in-out` so the TouchArea can flip it before emitting the + // toggled(label, new-value) event. + in-out property value: false; in property enabled: true; - callback toggled(bool); + // Phase 6 C2: pass the row's own label + the new value to the + // caller so a single callback on the page can dispatch to the + // right AppConfig field. + callback toggled(string, bool); in property row-bg: #28292d; in property row-border: #3a3b40; @@ -66,7 +71,8 @@ export component SettingsToggleRow inherits Rectangle { TouchArea { enabled: root.enabled; clicked => { - root.toggled(!root.value); + root.value = !root.value; + root.toggled(root.label, root.value); } } } @@ -123,13 +129,13 @@ export component SettingsPage inherits Rectangle { in-out property appearance-index: 2; callback appearance-changed(int); - // Notifications (UI shell) + // Notifications (Phase 6 C2: live wired through setting-toggled) in-out property desktop-notify: true; in-out property sound: true; in-out property notify-high: true; in-out property notify-max: true; - // Startup (UI shell) + // Startup (Phase 6 C2: live wired) in-out property start-with-windows: false; in-out property start-minimized: false; in-out property close-to-tray: true; @@ -141,6 +147,12 @@ export component SettingsPage inherits Rectangle { in-out property test-in-progress: false; callback test-connection-requested(); + // Phase 6 C2: a single callback that covers every toggle row on + // this page. Rust receives `(label, new_value)` and routes to the + // correct AppConfig field (and, for "start_with_windows", to the + // HKCU Run registry). + callback toggle-persist(string, bool); + // Tokens in property divider-color: #3a3b40; in property hint-color: #6a6a6a; @@ -199,21 +211,37 @@ export component SettingsPage inherits Rectangle { SettingsToggleRow { label: "Enable desktop notification"; value: root.desktop-notify; + toggled(l, v) => { + root.desktop-notify = v; + root.toggle-persist(l, v); + } } SettingsToggleRow { label: "Play sound"; value: root.sound; enabled: root.desktop-notify; + toggled(l, v) => { + root.sound = v; + root.toggle-persist(l, v); + } } SettingsToggleRow { label: "Notify on High priority"; value: root.notify-high; enabled: root.desktop-notify; + toggled(l, v) => { + root.notify-high = v; + root.toggle-persist(l, v); + } } SettingsToggleRow { label: "Notify on Max priority"; value: root.notify-max; enabled: root.desktop-notify; + toggled(l, v) => { + root.notify-max = v; + root.toggle-persist(l, v); + } } } @@ -224,14 +252,26 @@ export component SettingsPage inherits Rectangle { SettingsToggleRow { label: "Start with Windows"; value: root.start-with-windows; + toggled(l, v) => { + root.start-with-windows = v; + root.toggle-persist(l, v); + } } SettingsToggleRow { label: "Start minimized to tray"; value: root.start-minimized; + toggled(l, v) => { + root.start-minimized = v; + root.toggle-persist(l, v); + } } SettingsToggleRow { label: "Close button minimizes to tray"; value: root.close-to-tray; + toggled(l, v) => { + root.close-to-tray = v; + root.toggle-persist(l, v); + } } }