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:
+19
-5
@@ -1,17 +1,18 @@
|
||||
//! Windows Gotify Client (Rust) — core library.
|
||||
//!
|
||||
//! Phase 2 C1 only:
|
||||
//! 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.
|
||||
//! - [`run`]: builds the MainWindow and runs its event loop
|
||||
//!
|
||||
//! Larger subsystems (gotify / storage / windows integration / mock data)
|
||||
//! land in later commits per AGENTS.md §57.
|
||||
//! Larger subsystems (gotify / storage / windows integration) land in later
|
||||
//! phases per AGENTS.md §57.
|
||||
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
|
||||
use slint::ComponentHandle;
|
||||
pub mod models;
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
@@ -31,6 +32,19 @@ pub fn run() -> error::AppResult<()> {
|
||||
// of the Settings page and to `state.json` so the choice persists.
|
||||
ui.set_theme(AppearanceMode::Dark);
|
||||
|
||||
// Phase 2 C2: hook up the two top-level callbacks. C6/Phase 6 swap these
|
||||
// for real Windows toast firing.
|
||||
ui.on_test_notification_clicked(|| {
|
||||
tracing::info!("empty-state: Test Notification clicked (stub)");
|
||||
});
|
||||
ui.on_message_card_clicked(|idx| {
|
||||
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.
|
||||
ui.set_messages(models::mock_messages_model());
|
||||
|
||||
ui.run()
|
||||
.map_err(|e| error::AppError::Ui(e.to_string()))
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
//! Domain model + mock data feed for Phase 2 C2.
|
||||
//!
|
||||
//! `MessageItem` and `Priority` are NOT defined here — both come from
|
||||
//! `ui/message-card.slint` via `slint::include_modules!()` so the Slint view
|
||||
//! and the Rust side agree on shape without duplicating the schema.
|
||||
//!
|
||||
//! This module only owns:
|
||||
//! - [`format_relative`]: timestamp -> "2 min ago" / "Yesterday" / date
|
||||
//! - [`mock_messages`]: the placeholder feed used until Phase 3 wires
|
||||
//! real Gotify data into the model.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use slint::{ModelRc, VecModel};
|
||||
|
||||
use crate::MessageItem;
|
||||
|
||||
/// Wall-clock now, as Unix seconds. Falls back to 0 if the system clock is
|
||||
/// somehow pre-1970, which should never happen on a real Windows install.
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Render `unix` as a coarse relative-time string for the message list:
|
||||
/// "just now" / "N min ago" / "N hours ago" / "Yesterday" / "MM-DD".
|
||||
///
|
||||
/// Used only by the mock data builder; the real formatter once we have a
|
||||
/// clock-driven timestamp stream is the same function with a different
|
||||
/// `now` source.
|
||||
fn format_relative(unix: i64, now: i64) -> String {
|
||||
let diff = now.saturating_sub(unix);
|
||||
if diff < 0 {
|
||||
// Future timestamp (clock skew or a synthetic test message).
|
||||
return "just now".into();
|
||||
}
|
||||
if diff < 60 {
|
||||
return "just now".into();
|
||||
}
|
||||
if diff < 3600 {
|
||||
let m = diff / 60;
|
||||
return format!("{m} min ago");
|
||||
}
|
||||
if diff < 86_400 {
|
||||
let h = diff / 3600;
|
||||
return format!("{h} hours ago");
|
||||
}
|
||||
if diff < 86_400 * 2 {
|
||||
return "Yesterday".into();
|
||||
}
|
||||
// Beyond yesterday, fall back to a compact date. No strftime / chrono
|
||||
// yet — keep the dependency footprint small until Phase 5 (Storage)
|
||||
// brings serde / anyhow / etc. anyway.
|
||||
let secs_per_day = 86_400;
|
||||
let days_ago = diff / secs_per_day;
|
||||
if days_ago < 7 {
|
||||
return format!("{days_ago} days ago");
|
||||
}
|
||||
format!("{}", unix % 100_000_000) // placeholder until Phase 5 introduces proper date fmt.
|
||||
}
|
||||
|
||||
/// Build the Phase 2 mock feed: 12 messages covering the priority spectrum
|
||||
/// (Low / Normal / High / Max), three different apps, time spread across
|
||||
/// "just now" through "3 days ago".
|
||||
pub fn mock_messages() -> Vec<MessageItem> {
|
||||
let now = now_unix();
|
||||
let mk = |id: i32,
|
||||
app_id: i32,
|
||||
app_name: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
priority: crate::Priority,
|
||||
seconds_ago: i64,
|
||||
url: Option<&str>| MessageItem {
|
||||
id: id as i32,
|
||||
app_id: app_id as i32,
|
||||
app_name: app_name.into(),
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
priority,
|
||||
timestamp_unix: (now - seconds_ago) as i32,
|
||||
relative_time: format_relative(now - seconds_ago, now).into(),
|
||||
has_click_url: url.is_some(),
|
||||
click_url: url.unwrap_or("").into(),
|
||||
};
|
||||
|
||||
vec![
|
||||
mk(1, 1, "GitLab Runner",
|
||||
"Build succeeded",
|
||||
"Deploy v1.4.2 to production completed in 3m 12s.",
|
||||
crate::Priority::Normal, 90,
|
||||
Some("https://gitlab.example.com/pipelines/123")),
|
||||
mk(2, 2, "Gotify SMS",
|
||||
"Incoming SMS",
|
||||
"Mom: 今晚回家吃饭吗?",
|
||||
crate::Priority::Normal, 600,
|
||||
None),
|
||||
mk(3, 3, "青龙",
|
||||
"签到失败",
|
||||
"JD 任务 \"摇京豆\" 返回 -1: cookie 已过期。",
|
||||
crate::Priority::High, 1_400,
|
||||
None),
|
||||
mk(4, 4, "Airflow",
|
||||
"DAG \"dsa-daily-report\" failed",
|
||||
"Task `fetch_quotes` exited 137 (OOM killed).",
|
||||
crate::Priority::Max, 1_900,
|
||||
Some("https://airflow.example.com/dags/dsa-daily-report")),
|
||||
mk(5, 5, "Galaxy S9",
|
||||
"Low battery",
|
||||
"Battery is at 15%, charger recommended.",
|
||||
crate::Priority::Low, 3_600,
|
||||
None),
|
||||
mk(6, 4, "Airflow",
|
||||
"DAG \"stock-analysis\" completed",
|
||||
"20 symbols processed, results in /reports/2026-09-19/.",
|
||||
crate::Priority::Low, 7_200,
|
||||
None),
|
||||
mk(7, 2, "Gotify SMS",
|
||||
"Incoming SMS",
|
||||
"快递员: 您的包裹已放在驿站。",
|
||||
crate::Priority::Normal, 14_400,
|
||||
None),
|
||||
mk(8, 1, "GitLab Runner",
|
||||
"Build failed",
|
||||
"Compile error in `crates/api/src/lib.rs:142`: mismatched types.",
|
||||
crate::Priority::High, 22_000,
|
||||
Some("https://gitlab.example.com/pipelines/124")),
|
||||
mk(9, 3, "青龙",
|
||||
"脚本运行成功",
|
||||
"jd_bean_sign.js 已完成,获得 12 京豆。",
|
||||
crate::Priority::Low, 43_200,
|
||||
None),
|
||||
mk(10, 4, "Airflow",
|
||||
"DAG \"backup-nas\" completed",
|
||||
"Backup of /volume1 to /volume2 finished, 142 GB transferred.",
|
||||
crate::Priority::Normal, 64_800,
|
||||
None),
|
||||
mk(11, 5, "Galaxy S20",
|
||||
"New SMS",
|
||||
"运营商: 您本月流量已使用 80%. ",
|
||||
crate::Priority::Low, 86_400,
|
||||
None),
|
||||
mk(12, 4, "Airflow",
|
||||
"DAG \"weekly-report\" completed",
|
||||
"Weekly summary exported to /reports/weekly/2026-W38.pdf.",
|
||||
crate::Priority::Normal, 172_800,
|
||||
None),
|
||||
]
|
||||
}
|
||||
|
||||
/// Wrap a `Vec<MessageItem>` in a Slint model ready for `MainWindow.set_messages`.
|
||||
pub fn mock_messages_model() -> ModelRc<MessageItem> {
|
||||
ModelRc::from(std::rc::Rc::new(VecModel::from(mock_messages())))
|
||||
}
|
||||
+32
-6
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user