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
+39 -26
View File
@@ -1,15 +1,17 @@
// Main window + sidebar (AGENTS.md §6, §7).
//
// Phase 2 C1 only lays out the chrome: 960×680 default, 760×520 minimum,
// 232px sidebar with the seven required sections. The message area /
// settings page / about dialog are still placeholders — content lands in
// C2 (messages) and C3 (settings + dialogs + sidebar Light/System).
// Phase 2 C3 wires the Settings page, About dialog, and window-state
// persistence. The Appearance section in `settings-page.slint` is the only
// settings UI that drives real state today — every other control is a
// shell that Phase 5 hooks to `AppConfig`.
import { AppearanceMode } from "./theme.slint";
import { NavItem, SectionLabel } from "./components.slint";
import { MessageItem } from "./message-card.slint";
import { MessageList } from "./message-list.slint";
import { EmptyState } from "./empty-state.slint";
import { SettingsPage } from "./settings-page.slint";
import { AboutDialog } from "./dialogs.slint";
export enum SidebarSelection {
All,
@@ -24,9 +26,7 @@ export component MainWindow inherits Window {
min-width: 760px;
min-height: 520px;
// Theme switch — Rust-controllable via `ui.set_theme(...)`. Light/System
// is wired through this single property; the sidebar children will start
// following it once C3 widens NavItem to accept palette forwarding.
// Theme switch — Rust-controllable via `ui.set_theme(...)`.
in-out property <AppearanceMode> theme: AppearanceMode.Dark;
out property <bool> is-dark: theme != AppearanceMode.Light;
@@ -51,18 +51,21 @@ export component MainWindow inherits Window {
in-out property <SidebarSelection> selection: SidebarSelection.All;
// Phase 2 C2: mock-driven message list + selection state. Set from Rust
// via `ui.set_messages(model)` in `run()`. The list / empty-state switch
// is purely driven by `messages.length` (Slint's `for` over an empty
// model is a no-op).
// ── Phase 2 C2: mock messages + selection ────────────────────
in-out property <[MessageItem]> messages;
in-out property <int> selected-message-index: -1;
// Hooks that Phase 6 (Windows Integration) wires to a real Windows
// toast. C2 logs them so the click path is observable end-to-end.
callback test-notification-clicked();
callback message-card-clicked(int);
// ── Phase 2 C3: Settings / About / window state ──────────────
// Initial appearance radio (0=System/1=Light/2=Dark). Maps 1:1 to
// AppearanceMode: System=0, Light=1, Dark=2.
in-out property <int> appearance-index: 2;
// Show About dialog (one-shot — Rust closes it after handling).
callback about-clicked();
callback close-requested-save-state();
HorizontalLayout {
// §7 Sidebar width: 220-240px.
Rectangle {
@@ -73,7 +76,6 @@ export component MainWindow inherits Window {
padding: 20px;
spacing: 6px;
// App logo / title block.
Text {
text: "Gotify";
color: text-primary;
@@ -137,12 +139,15 @@ export component MainWindow inherits Window {
NavItem {
label: "About";
selected: root.selection == SidebarSelection.About;
clicked => { root.selection = SidebarSelection.About; }
clicked => {
root.selection = SidebarSelection.About;
root.about-clicked();
}
}
}
}
// Right pane — placeholder content driven by `selection`.
// Right pane — driven by `selection`.
Rectangle {
background: bg;
@@ -174,27 +179,35 @@ export component MainWindow inherits Window {
}
}
if root.selection == SidebarSelection.Settings: Rectangle {
background: bg;
Text {
x: 24px;
y: 24px;
text: "Settings page arrives in Phase 2 C3.";
color: text-muted;
font-size: 13px;
if root.selection == SidebarSelection.Settings: SettingsPage {
appearance-index: root.appearance-index;
appearance-changed(idx) => {
root.appearance-index = idx;
root.theme = idx == 0 ? AppearanceMode.System :
idx == 1 ? AppearanceMode.Light :
AppearanceMode.Dark;
}
}
// About is rendered via a Dialog overlay — not in the right pane.
// A separate About row keeps the layout stable; the dialog is
// shown via `about-clicked` callback to Rust.
if root.selection == SidebarSelection.About: Rectangle {
background: bg;
Text {
x: 24px;
y: 24px;
text: "About dialog arrives in Phase 2 C3.";
text: "About — see the dialog.";
color: text-muted;
font-size: 13px;
}
}
}
}
// Modal About dialog — opened when the user clicks About in the sidebar.
AboutDialog {
// visible-by-default is false. Toggled via Rust by listening to
// `about-clicked`. C3 wires the bare open/close plumbing.
}
}
+155 -4
View File
@@ -1,4 +1,155 @@
// AGENTS.md §54 dialogs (About + confirm).
//
// Placeholder for Phase 2 C1. C3 adds the modal About dialog (called from
// sidebar) and the confirm dialog used by destructive settings actions.
// AGENTS.md §54 modals. C3 only ships the About dialog (called from the
// sidebar) and a generic confirm dialog template; full delete-confirm flow
// lands in Phase 5 once `AppConfig` persistence is wired.
export component AboutDialog inherits Dialog {
in property <string> version-string: "v0.1.0";
in property <string> build-date: "2026-09-19";
// Theme-aware color tokens (Dark defaults; C4 will widen).
in property <color> bg-color: #28292d;
in property <color> text-primary-color: #e6e6e6;
in property <color> text-secondary-color: #a0a0a0;
in property <color> accent-color: #4ea1ff;
in property <color> button-bg: #2f3035;
in property <color> button-border: #3a3b40;
title: "About Gotify Client";
background: bg-color;
VerticalLayout {
padding: 24px;
spacing: 12px;
width: 360px;
Text {
text: "Windows Gotify Client (Rust)";
color: text-primary-color;
font-size: 16px;
font-weight: 700;
}
Text {
text: version-string;
color: accent-color;
font-size: 13px;
}
Text {
text: "Built " + build-date;
color: text-secondary-color;
font-size: 11px;
}
Rectangle { height: 8px; }
Text {
text: "Lightweight, modern, Windows-native Gotify\ndesktop client. Phase 2 of 8.";
color: text-secondary-color;
font-size: 12px;
wrap: word-wrap;
}
Rectangle { height: 12px; }
// OK button.
Rectangle {
width: 100px;
height: 32px;
background: button-bg;
border-color: button-border;
border-width: 1px;
border-radius: 6px;
x: parent.width - self.width;
Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
text: "Close";
color: text-primary-color;
font-size: 12px;
}
TouchArea {
clicked => {
root.close();
}
}
}
}
}
export component ConfirmDialog inherits Dialog {
in property <string> title-text: "Confirm";
in property <string> message: "Are you sure?";
in property <string> confirm-label: "Confirm";
in property <string> cancel-label: "Cancel";
callback confirmed();
callback cancelled();
in property <color> bg-color: #28292d;
in property <color> text-primary-color: #e6e6e6;
in property <color> text-secondary-color: #a0a0a0;
in property <color> danger-color: #f44336;
in property <color> button-bg: #2f3035;
in property <color> button-border: #3a3b40;
title: title-text;
background: bg-color;
VerticalLayout {
padding: 24px;
spacing: 16px;
width: 360px;
Text {
text: message;
color: text-primary-color;
font-size: 13px;
wrap: word-wrap;
}
HorizontalLayout {
spacing: 12px;
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 90px;
height: 32px;
background: button-bg;
border-color: button-border;
border-width: 1px;
border-radius: 6px;
Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
text: cancel-label;
color: text-primary-color;
font-size: 12px;
}
TouchArea {
clicked => {
root.cancelled();
root.close();
}
}
}
Rectangle {
width: 90px;
height: 32px;
background: danger-color;
border-radius: 6px;
Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
text: confirm-label;
color: #ffffff;
font-size: 12px;
font-weight: 600;
}
TouchArea {
clicked => {
root.confirmed();
root.close();
}
}
}
}
}
}
+127 -4
View File
@@ -1,6 +1,129 @@
// AGENTS.md §21 multi-server card.
//
// Placeholder for Phase 2 C1. The Settings → Connection section uses these
// to list/edit each configured server (name / URL / token / enabled).
// Real data wiring lands in Phase 5 (Storage) when `AppConfig.servers` is
// persisted.
// Phase 2 C3 is a visual + structural shell: one placeholder row so the
// Settings page has the right rhythm. The Settings `Connection` section
// hosts a `Repeater` over these once `AppConfig.servers` lands in Phase 5.
export component ServerCard inherits Rectangle {
in property <string> server-name: "(unnamed server)";
in property <string> server-url: "";
in property <bool> enabled: true;
in property <bool> is-current: false;
callback toggled();
callback edit-clicked();
callback remove-clicked();
in property <color> bg-color: #28292d;
in property <color> card-border: #3a3b40;
in property <color> text-primary-color: #e6e6e6;
in property <color> text-secondary-color: #a0a0a0;
in property <color> accent-color: #4ea1ff;
in property <color> success-color: #4caf50;
in property <color> toggle-on: #4ea1ff;
in property <color> toggle-off: #6a6a6a;
in property <color> button-bg: #2f3035;
width: 100%;
height: 64px;
border-radius: 8px;
background: bg-color;
border-color: card-border;
border-width: 1px;
HorizontalLayout {
padding-top: 12px;
padding-bottom: 12px;
padding-left: 16px;
padding-right: 16px;
spacing: 12px;
alignment: center;
// On/off pill toggle (visual only in C3 — Phase 5 makes it mutate state).
Rectangle {
width: 36px;
height: 20px;
border-radius: 10px;
background: enabled ? toggle-on : toggle-off;
y: parent.height / 2 - self.height / 2;
Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
background: #ffffff;
x: enabled ? parent.width - self.width - 2px : 2px;
y: (parent.height - self.height) / 2;
}
TouchArea {
clicked => {
root.toggled();
}
}
}
VerticalLayout {
horizontal-stretch: 1;
spacing: 2px;
HorizontalLayout {
spacing: 8px;
Text {
text: server-name;
color: text-primary-color;
font-size: 13px;
font-weight: 600;
}
if is-current: Text {
text: "• current";
color: accent-color;
font-size: 11px;
}
}
Text {
text: server-url;
color: text-secondary-color;
font-size: 11px;
overflow: elide;
}
}
// Action buttons.
Rectangle {
width: 56px;
height: 28px;
background: button-bg;
border-radius: 6px;
Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
text: "Edit";
color: text-primary-color;
font-size: 11px;
}
TouchArea {
clicked => {
root.edit-clicked();
}
}
}
Rectangle {
width: 56px;
height: 28px;
background: button-bg;
border-radius: 6px;
Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
text: "Remove";
color: text-secondary-color;
font-size: 11px;
}
TouchArea {
clicked => {
root.remove-clicked();
}
}
}
}
}
+299 -5
View File
@@ -1,6 +1,300 @@
// AGENTS.md §10 Settings page.
// AGENTS.md §10 / §11 settings page.
//
// Placeholder for Phase 2 C1. C3 will lay out the five required sections
// (Connection / Notifications / Startup / Appearance / Advanced), use the
// Appearance section to drive the live `Theme.appearance` switch, and wire
// up the actual save pipeline (Phase 5).
// C3 lays out the five sections (Connection / Notifications / Startup /
// Appearance / Advanced) with controls. The Appearance section is the only
// one wired back to the model: the radio selection drives
// `MainWindow.theme` via the `appearance-changed` callback. Everything else
// is a UI shell — Phase 5 wires it to `AppConfig` + Settings → state.json.
//
// Sub-components MUST be defined before the components that reference them
// in Slint 1.x, so `SettingsToggleRow` / `AppearanceRadio` come first.
import { SectionLabel } from "./components.slint";
import { ServerCard } from "./server-card.slint";
// ─── Sub-components ────────────────────────────────────────────────
export component SettingsToggleRow inherits Rectangle {
in property <string> label;
in property <bool> value: false;
in property <bool> enabled: true;
callback toggled(bool);
in property <color> row-bg: #28292d;
in property <color> row-border: #3a3b40;
in property <color> label-color: #e6e6e6;
in property <color> toggle-on: #4ea1ff;
in property <color> toggle-off: #6a6a6a;
width: 100%;
height: 44px;
border-radius: 8px;
background: row-bg;
border-color: row-border;
border-width: 1px;
opacity: enabled ? 1.0 : 0.5;
HorizontalLayout {
padding-top: 0;
padding-bottom: 0;
padding-left: 16px;
padding-right: 16px;
alignment: center;
Text {
text: label;
color: label-color;
font-size: 13px;
horizontal-stretch: 1;
}
Rectangle {
width: 36px;
height: 20px;
border-radius: 10px;
background: value ? toggle-on : toggle-off;
y: parent.height / 2 - self.height / 2;
Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
background: #ffffff;
x: value ? parent.width - self.width - 2px : 2px;
y: (parent.height - self.height) / 2;
}
TouchArea {
enabled: root.enabled;
clicked => {
root.toggled(!root.value);
}
}
}
}
}
export component AppearanceRadio inherits Rectangle {
in property <string> label;
in property <string> description;
in property <bool> selected: false;
callback chosen();
in property <color> row-bg: #28292d;
in property <color> row-border: #3a3b40;
in property <color> row-border-selected: #4ea1ff;
in property <color> label-color: #e6e6e6;
in property <color> hint-color: #6a6a6a;
width: 160px;
height: 64px;
border-radius: 8px;
background: row-bg;
border-color: selected ? row-border-selected : row-border;
border-width: selected ? 2px : 1px;
VerticalLayout {
padding: 12px;
spacing: 2px;
Text {
text: label;
color: label-color;
font-size: 13px;
font-weight: 600;
}
Text {
text: description;
color: hint-color;
font-size: 11px;
}
}
TouchArea {
clicked => {
root.chosen();
}
}
}
// ─── Main page ─────────────────────────────────────────────────────
export component SettingsPage inherits Rectangle {
// Appearance (live wired)
in-out property <int> appearance-index: 2;
callback appearance-changed(int);
// Notifications (UI shell)
in-out property <bool> desktop-notify: true;
in-out property <bool> sound: true;
in-out property <bool> notify-high: true;
in-out property <bool> notify-max: true;
// Startup (UI shell)
in-out property <bool> start-with-windows: false;
in-out property <bool> start-minimized: false;
in-out property <bool> close-to-tray: true;
// Advanced (collapsed by default per §10)
in-out property <bool> advanced-expanded: false;
// Tokens
in property <color> divider-color: #3a3b40;
in property <color> hint-color: #6a6a6a;
background: transparent;
VerticalLayout {
padding: 24px;
spacing: 24px;
// CONNECTION
VerticalLayout {
spacing: 8px;
SectionLabel { label: "CONNECTION"; text-color: hint-color; }
ServerCard {
server-name: "Home";
server-url: "https://gotify.example.com";
enabled: true;
is-current: true;
}
ServerCard {
server-name: "NAS";
server-url: "http://192.168.50.103:50334";
enabled: true;
is-current: false;
}
}
// NOTIFICATIONS
VerticalLayout {
spacing: 10px;
SectionLabel { label: "NOTIFICATIONS"; text-color: hint-color; }
SettingsToggleRow {
label: "Enable desktop notification";
value: root.desktop-notify;
}
SettingsToggleRow {
label: "Play sound";
value: root.sound;
enabled: root.desktop-notify;
}
SettingsToggleRow {
label: "Notify on High priority";
value: root.notify-high;
enabled: root.desktop-notify;
}
SettingsToggleRow {
label: "Notify on Max priority";
value: root.notify-max;
enabled: root.desktop-notify;
}
}
// STARTUP
VerticalLayout {
spacing: 10px;
SectionLabel { label: "STARTUP"; text-color: hint-color; }
SettingsToggleRow {
label: "Start with Windows";
value: root.start-with-windows;
}
SettingsToggleRow {
label: "Start minimized to tray";
value: root.start-minimized;
}
SettingsToggleRow {
label: "Close button minimizes to tray";
value: root.close-to-tray;
}
}
// APPEARANCE (live wired)
VerticalLayout {
spacing: 10px;
SectionLabel { label: "APPEARANCE"; text-color: hint-color; }
HorizontalLayout {
spacing: 12px;
AppearanceRadio {
label: "System";
description: "Follow Windows theme";
selected: root.appearance-index == 0;
chosen => {
root.appearance-index = 0;
root.appearance-changed(0);
}
}
AppearanceRadio {
label: "Light";
description: "Bright neutrals";
selected: root.appearance-index == 1;
chosen => {
root.appearance-index = 1;
root.appearance-changed(1);
}
}
AppearanceRadio {
label: "Dark";
description: "Graphite";
selected: root.appearance-index == 2;
chosen => {
root.appearance-index = 2;
root.appearance-changed(2);
}
}
}
SettingsToggleRow {
label: "Compact message density";
value: false;
enabled: false;
}
}
// ADVANCED (collapsed by default per §10)
VerticalLayout {
spacing: 10px;
HorizontalLayout {
spacing: 8px;
alignment: center;
Text {
text: advanced-expanded ? "v" : ">";
color: hint-color;
font-size: 11px;
}
Text {
text: "ADVANCED";
color: hint-color;
font-size: 11px;
font-weight: 700;
}
TouchArea {
clicked => {
root.advanced-expanded = !root.advanced-expanded;
}
}
}
if advanced-expanded: VerticalLayout {
spacing: 10px;
SettingsToggleRow {
label: "Log level: Info (release default)";
value: false;
enabled: false;
}
SettingsToggleRow {
label: "Clear local state (messages + window size)";
value: false;
enabled: false;
}
SettingsToggleRow {
label: "Reset settings to defaults";
value: false;
enabled: false;
}
}
}
}
}