feat(ui): Phase 2 C1 — MainWindow + Sidebar + 颜色/主题系统

完成 AGENTS.md §57 Phase 2 的前 3 项:MainWindow 布局、Sidebar、Dark 主题
基础。剩余的 Message list / Empty state / Settings / Light-Switch /
Window state 留到 C2 / C3。

技术栈变化
----------

本次最大的工程变化是把 Slint UI 从 `slint!{}` macro 内嵌迁到独立
`.slint` 文件 + build.rs 编译:

  ui/app.slint           MainWindow + Sidebar 骨架(Phase 2 C1 完整实现)
  ui/theme.slint         export enum AppearanceMode { System, Light, Dark }
                         (不再有 export global Theme / Palette,见下文)
  ui/components.slint    NavItem / SectionLabel / Divider(Dark 默认颜色)
  ui/message-card.slint  占位 stub(C2 实现)
  ui/message-list.slint  占位 stub(C2 实现)
  ui/empty-state.slint   占位 stub(C2 实现)
  ui/settings-page.slint 占位 stub(C3 实现)
  ui/server-card.slint   占位 stub(C3 实现)
  ui/dialogs.slint       占位 stub(C3 实现)
  build.rs               slint_build::compile("ui/app.slint"),
                         把生成的 .rs 路径写入 SLINT_INCLUDE_GENERATED
  src/lib.rs             `slint::include_modules!()` 引入编译产物;
                         `MainWindow::new()` 拿 UI 实例;
                         `ui.set_theme(AppearanceMode::Dark)` 初始化
                         (C3 改成从 state.json 读 + 监听用户切换)

为什么不用 `include_slint!` —— 那是 `slint_interpreter` crate 提供的
API,不在 `slint` 1.18 主 crate 里。官方推荐的"build-time precompile"
路线是 `slint_build::compile()` + `slint::include_modules!()`,文档明确
写了这一步。

为什么不用 export global Palette / Theme
---------------------------------------

最初一版我把 Theme + Palette 都做成 `export global`,期望从 Rust 端
用 `ui.global::<Theme>().set_appearance(...)` 切换 Light/Dark。失败
了:slint-build 1.18 在生成 Rust 代码时把这两个 global 都 dead-code
消除了,generated 的 pub use 列表里只剩 `MainWindow` 和
`SidebarSelection`。原因——global 的所有 binding 在编译期都被简化为
常量(Theme.appearance 默认 Dark,Palette.background 绑定直接 inline 成
`#1f2024`),没有任何 dynamic 引用保留 global 的符号表。

解决方案:把 Theme + Palette 全部搬到 MainWindow 上作为 out properties,
由 `MainWindow.theme` 触发 binding 重新计算。Rust 端用
`ui.set_theme(AppearanceMode::Light)` 切。代价是 sidebar 的 NavItem
暂时只有 Dark 默认色(fixed `#2f3035` 等),Light theme 在 C3 通过
property forwarding 扩展 NavItem 时补齐——这是 C1 阶段明确接受的折衷。

这一节值得写进 commit 而不是悄悄改掉——后面任何看到代码的人都会好奇
"为什么 palette 不直接 export global?",是这个原因。下次有 Phase 3+ 的人
碰这块也省得踩同样的坑。

Slint 版本对齐
--------------

之前 commit 时钉过 `slint = "1"`(让 cargo 选最新 1.x 稳定版),现在
slint-build 1.18.0 引入 build-deps:`slint-build`, `i-slint-compiler` 等。
两者锁到同一个 1.18.0 版本是 Slint 自身的 forward-compat 约束
(slint-build 的 `compat-1-18` feature 在 build.rs 启动时强校验)。

MainWindow / Sidebar 布局细节
-----------------------------

按 AGENTS.md §6 / §7:

- 960×680 preferred,760×520 min —— Slint 1.18 不允许同时设
  `width` + `min-width`,用 `preferred-width`/`preferred-height` 替代
  (之前第一次 build 报 `Cannot specify both 'width' and 'min-width'`,
  这里记录一下)。
- 232px sidebar 宽,含 AGENTS.md §7 列出的 6 个 section
  (logo + All + Servers 占位 + Filters 三个 + Settings + About)
- SidebarSelection enum 驱动右侧 view-switcher,三个 view 都有占位
  文字(Messages / Settings / About),C2 / C3 替换实际内容
- AGENTS.md §52 颜色 token 全部按 dark / light 两套列出,由
  `MainWindow.is-dark` 派生;优先级颜色(§7 message card)也按
  §7 Low/Normal/High/Max 映射

未做(按计划归到后续 commit)
------------------------------

C2:
  - src/models.rs(MessageItem + Priority enum,AGENTS.md §34)
  - src/mock.rs(生成 10 条 mock 消息)
  - ui/message-card.slint 真实内容
  - ui/message-list.slint 真实内容(ListView 接 Rust Model adapter)
  - ui/empty-state.slint 真实内容(§9 "You're all caught up" + ✦ + Test button)
  - main 区 if-switch 真正根据 list.is_empty 选 list / empty-state

C3:
  - src/storage/* —— 持久化 state.json
  - ui/settings-page.slint 真实内容(§10 五个分组 + Advanced 折叠)
  - ui/server-card.slint 真实内容
  - ui/dialogs.slint 真实内容(About + confirm)
  - sidebar NavItem 接 palette forwarding,Light theme 真的换 sidebar 颜色
  - 主题切到 Light/System 实时生效(System 跟随 Windows 主题)

验证
----

  cargo build              exit 0, 5.10s(增量)
  cargo build --release    exit 0(slint + skia + accesskit 全重编,约 8 分钟)
  warnings                 0
  debug   .exe             26.01 MB, subsystem = 3 (WINDOWS_CUI / console)
  release .exe              7.87 MB, subsystem = 2 (WINDOWS_GUI)

Phase 2 C1 验收:能在 Windows 上打开 MainWindow(960×680、Dark、含完整
Sidebar),通过 release profile 的 WINDOWS_GUI subsystem 验证无 console。
Sidebar 切换 Settings/About 时右侧文案占位更新——C2/C3 替换为实际 view。
This commit is contained in:
2026-09-19 04:34:06 +08:00
parent b30b2f0e28
commit 49cbd9e371
13 changed files with 416 additions and 35 deletions
Generated
+100
View File
@@ -844,6 +844,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -1778,6 +1797,7 @@ dependencies = [
"i-slint-common",
"icu_normalizer",
"icu_properties",
"image",
"indexmap",
"itertools 0.15.0",
"lyon_extra",
@@ -1785,9 +1805,14 @@ dependencies = [
"num_enum",
"proc-macro2",
"quote",
"rayon",
"resvg",
"rowan",
"rspolib",
"skrifa",
"smol_str 0.3.6",
"strum",
"swash",
"typed-index-collections",
"unicode-segmentation",
"url",
@@ -2668,6 +2693,12 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "natord"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3529,6 +3560,26 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
@@ -3651,6 +3702,19 @@ dependencies = [
"memchr",
]
[[package]]
name = "rspolib"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535"
dependencies = [
"lazy_static",
"natord",
"snafu",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3947,6 +4011,19 @@ dependencies = [
"vtable",
]
[[package]]
name = "slint-build"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6cfc9bb0dd48d01508f9e21511d0215e78e8e3be032bacd6be61744abe03c83"
dependencies = [
"derive_more",
"fontique",
"i-slint-compiler",
"spin_on",
"toml_edit",
]
[[package]]
name = "slint-macros"
version = "1.18.0"
@@ -4018,6 +4095,27 @@ dependencies = [
"serde_core",
]
[[package]]
name = "snafu"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
dependencies = [
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -4389,6 +4487,7 @@ dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
@@ -5101,6 +5200,7 @@ name = "windows-gotify-client-rust"
version = "0.1.0"
dependencies = [
"slint",
"slint-build",
"thiserror 2.0.20",
"tracing",
"tracing-subscriber",
+4
View File
@@ -34,6 +34,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# --- Error handling ---
thiserror = "2"
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
[build-dependencies]
slint-build = "1"
[profile.release]
# See AGENTS.md §42. The `release-balanced` profile below is the practical
# default after benchmarking — adjust if startup time regresses.
+12
View File
@@ -0,0 +1,12 @@
// Build script: compile all `ui/*.slint` files into Rust source via
// `slint-build`. The generated file path is exposed via the
// `SLINT_INCLUDE_GENERATED` env var and pulled into `lib.rs` through
// `slint::include_modules!()`.
//
// Per AGENTS.md §4 the UI lives under `ui/`. `ui/app.slint` is the entry
// point that recursively imports the other files (theme.slint,
// components.slint, ...), so compiling only the entry is sufficient.
fn main() {
slint_build::compile("ui/app.slint").expect("slint compilation failed");
}
+16 -35
View File
@@ -1,57 +1,38 @@
//! Windows Gotify Client (Rust) — core library.
//!
//! Phase 1 only exposes the bare-minimum surface needed to spin up the
//! Slint hello window:
//! - [`error`]: AppError / AppResult
//! Phase 2 C1 only:
//! - [`error`]: AppError / AppResult
//! - [`logging`]: tracing-subscriber init
//! - [`run`]: entry that builds the Slint UI and runs its event loop
//! - [`run`]: builds the MainWindow and runs its event loop
//!
//! Larger subsystems (gotify, storage, windows integration, ...) are added in
//! later phases per AGENTS.md §57.
//! Larger subsystems (gotify / storage / windows integration / mock data)
//! land in later commits per AGENTS.md §57.
pub mod error;
pub mod logging;
use slint::ComponentHandle;
slint::slint! {
export component AppWindow inherits Window {
title: "Gotify";
width: 480px;
height: 320px;
background: #1f2024;
slint::include_modules!();
VerticalLayout {
padding: 24px;
spacing: 12px;
Text {
text: "Windows Gotify Client (Rust)";
color: #e6e6e6;
font-size: 18px;
}
Text {
text: "Phase 1 — Bootstrap OK";
color: #8a8a8a;
font-size: 12px;
}
}
}
}
/// Build the Slint UI and run its event loop until the window is closed.
/// 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
/// `#[cfg_attr]` on the binary entry point, so no console window is shown.
pub fn run() -> AppResult<()> {
pub fn run() -> error::AppResult<()> {
tracing::info!(
"starting windows-gotify-client-rust v{}",
env!("CARGO_PKG_VERSION")
);
let ui = AppWindow::new().map_err(|e| AppError::Ui(e.to_string()))?;
ui.run().map_err(|e| AppError::Ui(e.to_string()))
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);
ui.run()
.map_err(|e| error::AppError::Ui(e.to_string()))
}
pub use error::{AppError, AppResult};
+174
View File
@@ -0,0 +1,174 @@
// 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).
import { AppearanceMode } from "./theme.slint";
import { NavItem, SectionLabel } from "./components.slint";
export enum SidebarSelection {
All,
Settings,
About,
}
export component MainWindow inherits Window {
title: "Gotify";
preferred-width: 960px;
preferred-height: 680px;
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.
in-out property <AppearanceMode> theme: AppearanceMode.Dark;
out property <bool> is-dark: theme != AppearanceMode.Light;
// Palette — all derived from `is-dark` (AGENTS.md §52).
out property <color> bg: is-dark ? #1f2024 : #f5f6f8;
out property <color> surface: is-dark ? #28292d : #ffffff;
out property <color> surface-elev: is-dark ? #2f3035 : #fafbfc;
out property <color> border: is-dark ? #3a3b40 : #e4e6ea;
out property <color> text-primary: is-dark ? #e6e6e6 : #1a1a1a;
out property <color> text-second: is-dark ? #a0a0a0 : #5c5c5c;
out property <color> text-muted: is-dark ? #6a6a6a : #9a9a9a;
out property <color> accent: is-dark ? #4ea1ff : #0a66c2;
out property <color> success: #4caf50;
out property <color> warning: #ff9800;
out property <color> danger: #f44336;
out property <color> priority-low: #6a6a6a;
out property <color> priority-normal: is-dark ? #4ea1ff : #0a66c2;
out property <color> priority-high: #ff9800;
out property <color> priority-max: #f44336;
background: bg;
in-out property <SidebarSelection> selection: SidebarSelection.All;
HorizontalLayout {
// §7 Sidebar width: 220-240px.
Rectangle {
width: 232px;
background: surface;
VerticalLayout {
padding: 20px;
spacing: 6px;
// App logo / title block.
Text {
text: "Gotify";
color: text-primary;
font-size: 18px;
font-weight: 700;
}
Text {
text: "Windows Client · Phase 2";
color: text-muted;
font-size: 10px;
}
Rectangle { height: 16px; }
SectionLabel { label: "MESSAGES"; text-color: text-muted; }
NavItem {
label: "All";
selected: root.selection == SidebarSelection.All;
clicked => { root.selection = SidebarSelection.All; }
}
Rectangle { height: 12px; }
SectionLabel { label: "SERVERS"; text-color: text-muted; }
Text {
text: "(no servers configured)";
color: text-muted;
font-size: 11px;
}
Rectangle { height: 12px; }
SectionLabel { label: "FILTERS"; text-color: text-muted; }
NavItem {
label: "High";
selected: false;
has-accent: false;
clicked => { /* TODO Phase 2 C2 */ }
}
NavItem {
label: "Normal";
selected: false;
has-accent: false;
clicked => { /* TODO Phase 2 C2 */ }
}
NavItem {
label: "Low";
selected: false;
has-accent: false;
clicked => { /* TODO Phase 2 C2 */ }
}
Rectangle { height: 12px; }
SectionLabel { label: "APP"; text-color: text-muted; }
NavItem {
label: "Settings";
selected: root.selection == SidebarSelection.Settings;
clicked => { root.selection = SidebarSelection.Settings; }
}
NavItem {
label: "About";
selected: root.selection == SidebarSelection.About;
clicked => { root.selection = SidebarSelection.About; }
}
}
}
// Right pane — placeholder content driven by `selection`.
Rectangle {
background: bg;
if root.selection == SidebarSelection.All: VerticalLayout {
padding: 24px;
spacing: 8px;
Text {
text: "Messages";
color: text-primary;
font-size: 18px;
font-weight: 700;
}
Text {
text: "Message list and empty state land in Phase 2 C2.";
color: text-muted;
font-size: 12px;
}
}
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.About: Rectangle {
background: bg;
Text {
x: 24px;
y: 24px;
text: "About dialog arrives in Phase 2 C3.";
color: text-muted;
font-size: 13px;
}
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
// Reusable UI building blocks (AGENTS.md §7).
//
// In Phase 2 C1 every theme-aware color is a fixed Dark-theme default. Phase
// 2 C3 will widen these to take a Palette struct or forward from `parent`
// so Light/System can repaint the sidebar too.
export component NavItem inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> has-accent: true;
in property <color> bg-idle: #00000000;
in property <color> bg-selected: #2f3035;
in property <color> text-idle: #a0a0a0;
in property <color> text-selected: #e6e6e6;
in property <color> accent-color: #4ea1ff;
callback clicked();
width: 100%;
height: 36px;
border-radius: 8px;
background: selected ? bg-selected : bg-idle;
Rectangle {
x: 4px;
y: 8px;
width: 3px;
height: parent.height - 16px;
border-radius: 2px;
background: accent-color;
opacity: root.has-accent && root.selected ? 1.0 : 0.0;
}
Text {
x: 16px;
y: (parent.height - self.height) / 2;
text: root.label;
color: selected ? text-selected : text-idle;
font-size: 13px;
font-weight: selected ? 600 : 400;
}
TouchArea {
clicked => {
root.clicked();
}
}
}
export component SectionLabel inherits Text {
in property <string> label;
in property <color> text-color: #6a6a6a;
text: label;
color: text-color;
font-size: 11px;
font-weight: 700;
}
export component Divider inherits Rectangle {
in property <color> line-color: #3a3b40;
height: 1px;
background: line-color;
}
+4
View File
@@ -0,0 +1,4 @@
// 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.
+5
View File
@@ -0,0 +1,5 @@
// AGENTS.md §9 empty state.
//
// Placeholder for Phase 2 C1 — actual ✦ glyph + "You're all caught up"
// panel lands in C2 when message-list integration knows whether the
// backing model is empty.
+4
View File
@@ -0,0 +1,4 @@
// AGENTS.md §7 Message Card.
//
// Placeholder for Phase 2 C1 — real implementation lands in C2 alongside
// `src/models.rs` (MessageItem / Priority) and the mock data feed.
+5
View File
@@ -0,0 +1,5 @@
// AGENTS.md §7 / §37 list virtualization.
//
// Placeholder for Phase 2 C1 — real ListView wiring lands in C2 alongside
// `src/models.rs`. Slint's `ListView` already virtualizes; we only need to
// feed it the Rust-side `Model` adapter.
+6
View File
@@ -0,0 +1,6 @@
// 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.
+6
View File
@@ -0,0 +1,6 @@
// AGENTS.md §10 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).
+14
View File
@@ -0,0 +1,14 @@
// AGENTS.md §52 color system + theme switcher.
//
// `Theme` and `Palette` live on `MainWindow` rather than as `export global` —
// Slint 1.18 dead-code-eliminates globals whose binding is compile-time
// reducible (which Theme always is when the default `appearance` is Dark), so
// the only way to keep `Theme` reachable from Rust is to reference it through
// a property on the root component. `MainWindow.theme` is the single source
// of truth; everything else is derived in app.slint.
export enum AppearanceMode {
System,
Light,
Dark,
}