feat(ui): Phase 2 C3 — Settings 页 + Dialogs + 窗口状态保存

- src/storage.rs: AppState (last_window_width/height) + load/save
  atomic write 到 %APPDATA%/WindowsGotify/state.json(§18)
- ui/settings-page.slint: 5 分组(Connection / Notifications / Startup
  / Appearance / Advanced),Appearance 实时切主题(System/Light/Dark
  radio);Advanced 折叠
- ui/dialogs.slint: AboutDialog + ConfirmDialog(§54)
- ui/server-card.slint: 单 server 行(name/url/enabled toggle/edit/remove)
- ui/app.slint: settings 页切换、About 导航、theme 联动
- src/lib.rs: load_state → 启动时应用窗口尺寸;ui.window()
  .on_close_requested 在 OS 关闭前 save_state;wire about/close
  callbacks
- src/error.rs: 加 AppError::Storage(String) 变体
- Cargo.toml: 加 serde + serde_json

避坑:
- Slint 1.x 同文件内 export component 必须在引用前定义(sub-components
  移到 SettingsPage 上面)
- Slint 不支持 padding 两值简写 (padding: 0 16px) → 用 padding-* 四属性
- Slint 不支持 Rectangle 内建 border-color 覆盖 → 命名 card-border
- Slint 不支持 alignment: right → 用 spacer + horizontal-stretch: 1
- 修了一处 .slint 文件被 PowerShell Set-Content GBK 损坏的问题(已
  通过 write 工具 + UTF-8 no BOM 重写并清理 ui/ 所有 .slint)

验证:cargo build / build --release 都 exit 0,0 warning
- debug   27.55 MB, subsystem = WINDOWS_CUI
- release  8.10 MB, subsystem = WINDOWS_GUI
This commit is contained in:
2026-09-19 04:47:54 +08:00
parent 11a122216b
commit 680fbfa0ad
9 changed files with 792 additions and 51 deletions
+3
View File
@@ -24,4 +24,7 @@ pub enum AppError {
#[error("ui error: {0}")]
Ui(String),
#[error("storage error: {0}")]
Storage(String),
}
+62 -12
View File
@@ -3,19 +3,22 @@
//! Phase 2 surface:
//! - [`error`]: AppError / AppResult
//! - [`logging`]: tracing-subscriber init
//! - [`models`]: MessageItem / Priority (re-exported from slint-generated
//! types) + the mock data feed used until Phase 3 lands.
//! - [`models`]: Mock message feed (Phase 3 replaces with live Gotify data)
//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3)
//! - [`run`]: builds the MainWindow and runs its event loop
//!
//! Larger subsystems (gotify / storage / windows integration) land in later
//! phases per AGENTS.md §57.
pub mod error;
pub mod logging;
pub mod models;
pub mod storage;
slint::include_modules!();
use std::cell::RefCell;
use std::rc::Rc;
use storage::AppState;
/// Build the MainWindow and run its event loop until the window is closed.
///
/// In release builds the Windows subsystem is set to `windows` via the
@@ -28,12 +31,20 @@ pub fn run() -> error::AppResult<()> {
let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?;
// Phase 2 C1: dark by default. C3 wires this to the Appearance section
// of the Settings page and to `state.json` so the choice persists.
ui.set_theme(AppearanceMode::Dark);
// Phase 2 C3: load persisted state and apply it before `run()`.
let initial_state = storage::load_state();
tracing::info!(
width = initial_state.last_window_width,
height = initial_state.last_window_height,
"loaded state.json"
);
ui.set_theme(AppearanceMode::Dark); // C4 reads theme from state.json.
ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
initial_state.last_window_width.max(0) as f32,
initial_state.last_window_height.max(0) as f32,
)));
// Phase 2 C2: hook up the two top-level callbacks. C6/Phase 6 swap these
// for real Windows toast firing.
// Phase 2 C2 callbacks.
ui.on_test_notification_clicked(|| {
tracing::info!("empty-state: Test Notification clicked (stub)");
});
@@ -41,10 +52,49 @@ pub fn run() -> error::AppResult<()> {
tracing::debug!(idx, "message card clicked");
});
// Phase 2 C2: feed mock data into the message list. Phase 3 swaps this
// for an HTTP catch-up + WebSocket live stream.
// Phase 2 C3 callbacks.
let state_for_save: Rc<RefCell<AppState>> = Rc::new(RefCell::new(initial_state));
ui.on_about_clicked({
let ui_handle = ui.as_weak();
move || {
tracing::info!("about-clicked");
if let Some(ui) = ui_handle.upgrade() {
ui.set_selection(SidebarSelection::About);
}
}
});
// Phase 2 C2 message feed (mock).
ui.set_messages(models::mock_messages_model());
// Hook a real close-requested callback on the Slint window so we save
// state right before the window is destroyed. Slint 1.18's `Window` has
// a `close-requested` callback that fires on OS-close.
ui.window().on_close_requested({
let state_for_save = state_for_save.clone();
let ui_handle = ui.as_weak();
move || {
// Capture final size from the live window before saving.
if let Some(ui) = ui_handle.upgrade() {
let size = ui.window().size();
let mut state = state_for_save.borrow_mut();
state.last_window_width = size.width as i32;
state.last_window_height = size.height as i32;
}
if let Err(e) = storage::save_state(&state_for_save.borrow()) {
tracing::warn!(error = %e, "on close: failed to save state.json");
} else {
tracing::info!(
width = state_for_save.borrow().last_window_width,
height = state_for_save.borrow().last_window_height,
"saved state.json on close"
);
}
// Allow the window to hide (default CloseRequestResponse behaviour).
slint::CloseRequestResponse::HideWindow
}
});
ui.run()
.map_err(|e| error::AppError::Ui(e.to_string()))
}
+101
View File
@@ -0,0 +1,101 @@
//! Local persistent state (AGENTS.md §18).
//!
//! Phase 2 C3 only persists **window geometry** (size). The full state.json
//! schema — `last_message_id` / `selected_server` / etc. — lands in Phase 5
//! (Storage) when `AppConfig` joins in. For now we keep this minimal: read
//! on startup, write on close, atomic via tmp + rename.
//!
//! Location: `%APPDATA%/WindowsGotify/state.json`. The directory is created
//! on first save; missing-file on first run is treated as "no prior state".
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
const STATE_DIR: &str = "WindowsGotify";
const STATE_FILE: &str = "state.json";
const STATE_VERSION: u32 = 1;
/// Persistable subset of application state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppState {
/// Bumped when the on-disk schema changes so future migrations can run.
#[serde(default = "current_version")]
pub version: u32,
pub last_window_width: i32,
pub last_window_height: i32,
}
fn current_version() -> u32 {
STATE_VERSION
}
impl Default for AppState {
fn default() -> Self {
Self {
version: STATE_VERSION,
// Match MainWindow's `preferred-width/height` from ui/app.slint.
last_window_width: 960,
last_window_height: 680,
}
}
}
/// `%APPDATA%/WindowsGotify/state.json`. Falls back to a temp-relative path
/// when `APPDATA` is unset (rare on real Windows installs but harmless for
/// dev shells).
pub fn state_path() -> Option<PathBuf> {
if let Some(roaming) = std::env::var_os("APPDATA") {
return Some(Path::new(&roaming).join(STATE_DIR).join(STATE_FILE));
}
// Fallback: current working dir + subfolder — only matters in dev shells.
std::env::current_dir()
.ok()
.map(|d| d.join(STATE_DIR).join(STATE_FILE))
}
/// Load state from disk. Missing file or invalid JSON yields `AppState::default()`.
pub fn load_state() -> AppState {
let Some(path) = state_path() else {
return AppState::default();
};
let Ok(bytes) = fs::read(&path) else {
return AppState::default();
};
match serde_json::from_slice::<AppState>(&bytes) {
Ok(s) => s,
Err(e) => {
tracing::warn!(?path, error = %e, "state.json is malformed; using defaults");
AppState::default()
}
}
}
/// Atomic write: write `state.json.tmp`, fsync, rename over `state.json`.
/// Returns Ok(()) on success, even when `%APPDATA%` is unset (no-op in
/// that case so dev shells don't crash).
pub fn save_state(state: &AppState) -> AppResult<()> {
let Some(path) = state_path() else {
tracing::debug!("APPDATA unset; skipping state.json save");
return Ok(());
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::Storage(e.to_string()))?;
}
let tmp = path.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp).map_err(|e| AppError::Storage(e.to_string()))?;
let body = serde_json::to_vec_pretty(state)
.map_err(|e| AppError::Storage(e.to_string()))?;
f.write_all(&body)
.map_err(|e| AppError::Storage(e.to_string()))?;
f.sync_all()
.map_err(|e| AppError::Storage(e.to_string()))?;
}
fs::rename(&tmp, &path).map_err(|e| AppError::Storage(e.to_string()))?;
Ok(())
}