feat(windows): Phase 6 C2 — close-to-tray + Settings toggle 持久化

- 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
This commit is contained in:
2026-09-19 07:14:35 +08:00
parent ac5d0cb1d0
commit e1622b5959
3 changed files with 155 additions and 8 deletions
+85 -3
View File
@@ -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<VecModel<MessageItem>>;
@@ -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
}
});