diff --git a/src/lib.rs b/src/lib.rs index 862ad94..7fbafe1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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())) } diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..8a38111 --- /dev/null +++ b/src/models.rs @@ -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 { + 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` in a Slint model ready for `MainWindow.set_messages`. +pub fn mock_messages_model() -> ModelRc { + ModelRc::from(std::rc::Rc::new(VecModel::from(mock_messages()))) +} \ No newline at end of file diff --git a/ui/app.slint b/ui/app.slint index 46788df..9367a82 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -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 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 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(); + } } } diff --git a/ui/empty-state.slint b/ui/empty-state.slint index 6b1d107..ce687b5 100644 --- a/ui/empty-state.slint +++ b/ui/empty-state.slint @@ -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. \ No newline at end of file +// 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 glyph-color: #4ea1ff; + in property text-primary-color: #e6e6e6; + in property text-muted-color: #6a6a6a; + in property button-bg: #2f3035; + in property button-border: #3a3b40; + in property 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(); + } + } + } + } +} \ No newline at end of file diff --git a/ui/message-card.slint b/ui/message-card.slint index 6e85a45..ea3aa78 100644 --- a/ui/message-card.slint +++ b/ui/message-card.slint @@ -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. \ No newline at end of file +// `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`; 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`. +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 item; + in property selected: false; + + in property bg-color: #28292d; + in property bg-selected-color: #2f3035; + in property card-border: #3a3b40; + in property text-primary-color: #e6e6e6; + in property text-secondary-color: #a0a0a0; + in property text-muted-color: #6a6a6a; + in property priority-low-color: #6a6a6a; + in property priority-normal-color: #4ea1ff; + in property priority-high-color: #ff9800; + in property priority-max-color: #f44336; + in property 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(); + } + } +} \ No newline at end of file diff --git a/ui/message-list.slint b/ui/message-list.slint index 7df55af..d266cf9 100644 --- a/ui/message-list.slint +++ b/ui/message-list.slint @@ -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. \ No newline at end of file +// 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 selected-index: -1; + + in property 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); + } + } + } +} \ No newline at end of file