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:
+174
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user