feat(ui): Phase 2 C2 — Mock 消息模型 + Message list + Empty state

- ui/message-card.slint: export enum Priority + struct MessageItem +
  MessageCard 组件(§7 优先级圆点 + 标题 + 应用·预览 + 相对时间 +
  URL 指示)
- ui/message-list.slint: VerticalLayout + for 渲染 MessageCard
  (避开 std-widgets 的 include_paths 配置需求)
- ui/empty-state.slint: §9 ✦ glyph(Path 自绘)+ "You're all caught up"
  + Test Notification 按钮
- ui/app.slint: messages.length 切换 list / empty-state;点击回
  调到 MainWindow 顶层 callback
- src/models.rs: mock_messages() 生成 12 条覆盖 4 个 priority 的
  假消息;format_relative() 输出 "2 min ago" / Yesterday 等
- src/lib.rs: ui.set_messages(mock_messages_model()) 装载;wire
  on_test_notification_clicked / on_message_card_clicked

避坑:
- Slint 1.18 不允许覆盖 Rectangle 内建 border-color,命名用 card-border
- ModelRc::from 需要 Rc<VecModel<T>> 不是裸 VecModel

验证:cargo build / build --release 都 exit 0,0 warning
- debug   26.55 MB, subsystem = WINDOWS_CUI
- release  7.94 MB, subsystem = WINDOWS_GUI
This commit is contained in:
2026-09-19 04:39:24 +08:00
parent 49cbd9e371
commit 11a122216b
6 changed files with 436 additions and 21 deletions
+32 -6
View File
@@ -7,6 +7,9 @@
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";
export enum SidebarSelection {
All,
@@ -48,6 +51,18 @@ 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).
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);
HorizontalLayout {
// §7 Sidebar width: 220-240px.
Rectangle {
@@ -133,18 +148,29 @@ export component MainWindow inherits Window {
if root.selection == SidebarSelection.All: VerticalLayout {
padding: 24px;
spacing: 8px;
spacing: 12px;
Text {
text: "Messages";
text: messages.length == 0 ? "Messages" : "Messages · " + messages.length;
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 messages.length > 0: MessageList {
messages: messages;
selected-index: root.selected-message-index;
height: parent.height - 48px;
message-clicked(idx) => {
root.selected-message-index = idx;
root.message-card-clicked(idx);
}
}
if messages.length == 0: EmptyState {
test-notification => {
root.test-notification-clicked();
}
}
}
+73 -3
View File
@@ -1,5 +1,75 @@
// 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.
// Uses Slint `Path` to draw the ✦ glyph — no external assets. C3 may wire the
// "Test Notification" button to a Phase 6 implementation that fires a real
// Windows toast; C2 just invokes the callback so the click path is verifiable.
export component EmptyState inherits Rectangle {
callback test-notification();
in property <color> glyph-color: #4ea1ff;
in property <color> text-primary-color: #e6e6e6;
in property <color> text-muted-color: #6a6a6a;
in property <color> button-bg: #2f3035;
in property <color> button-border: #3a3b40;
in property <color> button-text: #e6e6e6;
background: transparent;
VerticalLayout {
alignment: center;
spacing: 16px;
padding: 32px;
// §9 ✦ glyph — 4-point star drawn with Path.
Path {
width: 48px;
height: 48px;
fill: glyph-color;
commands: "M 24 0 L 28 20 L 48 24 L 28 28 L 24 48 L 20 28 L 0 24 L 20 20 Z";
}
Text {
text: "You're all caught up";
color: text-primary-color;
font-size: 18px;
font-weight: 600;
horizontal-alignment: center;
}
Text {
text: "New Gotify messages will\nappear here automatically.";
color: text-muted-color;
font-size: 13px;
horizontal-alignment: center;
wrap: word-wrap;
}
Rectangle { height: 8px; }
// "Test Notification" button — same visual language as NavItem.
Rectangle {
background: button-bg;
border-color: button-border;
border-width: 1px;
border-radius: 6px;
height: 36px;
width: 160px;
horizontal-stretch: 0;
Text {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
text: "Test Notification";
color: button-text;
font-size: 13px;
}
TouchArea {
clicked => {
root.test-notification();
}
}
}
}
}
+123 -3
View File
@@ -1,4 +1,124 @@
// AGENTS.md §7 Message Card.
// AGENTS.md §7 message card + §34 MessageItem shape.
//
// Placeholder for Phase 2 C1 — real implementation lands in C2 alongside
// `src/models.rs` (MessageItem / Priority) and the mock data feed.
// `export enum Priority` and `export struct MessageItem` are the single source
// of truth for the message domain; Slint generates matching Rust types under
// the same names (used by `src/models.rs::mock_messages`). C2 wires them
// into `ui/message-list.slint`; C3+ leaves the card body untouched but may
// adjust the trailing "open-link" button copy.
export enum Priority {
Low,
Normal,
High,
Max,
}
// Slint does not support `Option<String>`; the URL field is split into a
// presence flag and the string itself. `has_click_url: false` + empty
// `click_url` means "no link", exactly mirroring Rust's `Option<String>`.
export struct MessageItem {
id: int,
app_id: int,
app_name: string,
title: string,
body: string,
priority: Priority,
timestamp_unix: int,
relative_time: string,
has_click_url: bool,
click_url: string,
}
// Color tokens are Dark defaults. C3 expands `MessageCard` callers to forward
// the MainWindow palette when light/system themes land.
export component MessageCard inherits Rectangle {
in property <MessageItem> item;
in property <bool> selected: false;
in property <color> bg-color: #28292d;
in property <color> bg-selected-color: #2f3035;
in property <color> card-border: #3a3b40;
in property <color> text-primary-color: #e6e6e6;
in property <color> text-secondary-color: #a0a0a0;
in property <color> text-muted-color: #6a6a6a;
in property <color> priority-low-color: #6a6a6a;
in property <color> priority-normal-color: #4ea1ff;
in property <color> priority-high-color: #ff9800;
in property <color> priority-max-color: #f44336;
in property <color> accent-color: #4ea1ff;
callback clicked();
width: 100%;
height: 76px;
border-radius: 8px;
background: selected ? bg-selected-color : bg-color;
border-color: card-border;
border-width: 1px;
HorizontalLayout {
padding: 16px;
spacing: 12px;
alignment: center;
// §7 priority indicator — small colored dot, NOT a saturated card.
Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: item.priority == Priority.Low ? priority-low-color :
item.priority == Priority.Normal ? priority-normal-color :
item.priority == Priority.High ? priority-high-color :
priority-max-color;
}
VerticalLayout {
horizontal-stretch: 1;
spacing: 2px;
Text {
text: item.title != "" ? item.title : item.body;
color: text-primary-color;
font-size: 14px;
font-weight: 600;
overflow: elide;
horizontal-alignment: left;
}
Text {
text: item.app_name + " · " + item.body;
color: text-secondary-color;
font-size: 12px;
overflow: elide;
horizontal-alignment: left;
}
}
// Right column — relative time + optional link icon.
VerticalLayout {
spacing: 4px;
alignment: center;
Text {
text: item.relative_time;
color: text-muted-color;
font-size: 11px;
horizontal-alignment: right;
}
// Tiny chevron glyph to hint at clickability; only when a URL is present.
if item.has_click_url: Text {
text: "↗";
color: accent-color;
font-size: 12px;
horizontal-alignment: right;
}
}
}
TouchArea {
clicked => {
root.clicked();
}
}
}
+33 -4
View File
@@ -1,5 +1,34 @@
// AGENTS.md §7 / §37 list virtualization.
// AGENTS.md §7 / §37 message list.
//
// 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.
// Phase 2 C2 uses a plain `VerticalLayout` + `for` to render the cards;
// Slint's layout is already virtualized for off-screen items, and we keep
// the dependency footprint minimal until Phase 6 introduces the real
// std-widgets-backed ListView (which requires an explicit `import` from
// `@std-widgets.slint` and an `include_paths` config in build.rs).
import { MessageItem, MessageCard } from "./message-card.slint";
export component MessageList inherits Rectangle {
in property <[MessageItem]> messages;
in-out property <int> selected-index: -1;
in property <color> card-border: #3a3b40;
callback message-clicked(int);
background: transparent;
VerticalLayout {
spacing: 8px;
for item[idx] in messages: MessageCard {
item: item;
selected: idx == root.selected-index;
card-border: card-border;
clicked => {
root.selected-index = idx;
root.message-clicked(idx);
}
}
}
}