From 97becf5c4be920fbc2ba9c873bc70ae04ed94ffe Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 08:23:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Phase=207=20C3=20=E2=80=94=20Priori?= =?UTF-8?q?ty=20=E8=BF=87=E6=BB=A4=20+=20=E6=B6=88=E6=81=AF=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidebar FILTERS 加 Max/High/Normal/Low 四档(dot 色与 priority 配色一致),点击切换 priority-filter(0=All, 1=Low, 2=Normal, 3=High, 4=Max) - MessageList 加 priority-filter property,通过 visible 谓词按 Slint Priority enum 匹配;保留原 idx 让 selected-index 不随 filter 错位 - 右栏加 detail panel:app + priority dot、title、id、relative_time、body、has_click_url 时显示 Open link 按钮(复用 C2 的 message-card-clicked 走 url::open) - detail panel 与消息列表水平分割,selected-message-index < 0 时自动隐藏;切换 Settings/About 时不显示 --- ui/app.slint | 254 +++++++++++++++++++++++++++++++++++------- ui/message-list.slint | 25 ++++- 2 files changed, 235 insertions(+), 44 deletions(-) diff --git a/ui/app.slint b/ui/app.slint index c2af4bd..6b53065 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,12 +1,17 @@ // Main window + sidebar (AGENTS.md §6, §7). // -// Phase 3 C2 wires the live connection status (dot + status text under the -// title bar) and accepts a `test-connection-requested` callback fired by -// the Settings → Connection section. +// Phase 7 C3 additions: +// - Sidebar priority filter (All / Low / Normal / High / Max) +// flips `priority-filter` on MainWindow; MessageList hides +// non-matching cards via `visible` (see message-list.slint). +// - Right-hand detail panel that opens when a message is clicked: +// app + priority dot, title, absolute timestamp, full body, +// and an "Open Link" button (re-uses the URL opener wired up +// in Phase 7 C2). import { AppearanceMode } from "./theme.slint"; import { NavItem, SectionLabel } from "./components.slint"; -import { MessageItem } from "./message-card.slint"; +import { MessageItem, Priority } from "./message-card.slint"; import { MessageList } from "./message-list.slint"; import { EmptyState } from "./empty-state.slint"; import { SettingsPage } from "./settings-page.slint"; @@ -54,6 +59,13 @@ export component MainWindow inherits Window { callback test-notification-clicked(); callback message-card-clicked(int); + // ── Phase 7 C3: priority filter (0=All, 1=Low, 2=Normal, + // 3=High, 4=Max). The integer values intentionally align with + // `PriorityFilter` (0=All is the special "no filter" sentinel; + // 1..4 map to the four priority buckets). See message-list.slint + // for the matching `visible` predicate. ── + in-out property priority-filter: 0; + // ── Phase 2 C3: Settings / About / window state ────────────── in-out property appearance-index: 2; callback about-clicked(); @@ -98,7 +110,7 @@ export component MainWindow inherits Window { font-weight: 700; } Text { - text: "Windows Client · Phase 3"; + text: "Windows Client · Phase 7"; color: text-muted; font-size: 10px; } @@ -128,8 +140,15 @@ export component MainWindow inherits Window { SectionLabel { label: "MESSAGES"; text-color: text-muted; } NavItem { label: "All"; - selected: root.selection == SidebarSelection.All; - clicked => { root.selection = SidebarSelection.All; } + // Phase 7 C3: highlight the All nav item whenever + // the priority filter is off (i.e. the user is + // looking at the unfiltered list). + selected: root.selection == SidebarSelection.All + && root.priority-filter == 0; + clicked => { + root.selection = SidebarSelection.All; + root.priority-filter = 0; + } } Rectangle { height: 12px; } @@ -144,23 +163,52 @@ export component MainWindow inherits Window { Rectangle { height: 12px; } SectionLabel { label: "FILTERS"; text-color: text-muted; } + // Phase 7 C3: each row maps to one priority bucket. + // The dot's color is the priority color so the row + // doubles as a legend. + NavItem { + label: "Max"; + selected: root.selection == SidebarSelection.All + && root.priority-filter == 4; + has-accent: true; + accent-color: priority-max; + clicked => { + root.selection = SidebarSelection.All; + root.priority-filter = 4; + } + } NavItem { label: "High"; - selected: false; - has-accent: false; - clicked => { /* TODO Phase 3 C2: priority filter */ } + selected: root.selection == SidebarSelection.All + && root.priority-filter == 3; + has-accent: true; + accent-color: priority-high; + clicked => { + root.selection = SidebarSelection.All; + root.priority-filter = 3; + } } NavItem { label: "Normal"; - selected: false; - has-accent: false; - clicked => { /* TODO Phase 3 C2: priority filter */ } + selected: root.selection == SidebarSelection.All + && root.priority-filter == 2; + has-accent: true; + accent-color: priority-normal; + clicked => { + root.selection = SidebarSelection.All; + root.priority-filter = 2; + } } NavItem { label: "Low"; - selected: false; - has-accent: false; - clicked => { /* TODO Phase 3 C2: priority filter */ } + selected: root.selection == SidebarSelection.All + && root.priority-filter == 1; + has-accent: true; + accent-color: priority-low; + clicked => { + root.selection = SidebarSelection.All; + root.priority-filter = 1; + } } Rectangle { height: 12px; } @@ -186,39 +234,159 @@ export component MainWindow inherits Window { Rectangle { background: bg; - VerticalLayout { - // Title bar with status text under it. - padding: 24px; - spacing: 4px; + // Phase 7 C3: split the right pane horizontally so the + // detail panel can sit alongside the message list when + // the user picks a card. The detail panel is hidden when + // no card is selected or when the user is on Settings / + // About (which have their own full-pane content). + HorizontalLayout { + padding: 0; + spacing: 0; - Text { - text: messages.length == 0 ? "Messages" : "Messages · " + messages.length; - color: text-primary; - font-size: 18px; - font-weight: 700; - } - Text { - text: status-text; - color: text-muted; - font-size: 11px; - } + // Messages column (always visible when on "All"). + Rectangle { + background: bg; + horizontal-stretch: 1; + visible: root.selection == SidebarSelection.All; - Rectangle { height: 12px; } + VerticalLayout { + // Title bar with status text under it. + padding: 24px; + spacing: 4px; - if messages.length > 0: MessageList { - messages: messages; - selected-index: root.selected-message-index; - vertical-stretch: 1; - message-clicked(idx) => { - root.selected-message-index = idx; - root.message-card-clicked(idx); + Text { + text: messages.length == 0 ? "Messages" : "Messages · " + messages.length; + color: text-primary; + font-size: 18px; + font-weight: 700; + } + Text { + text: status-text; + color: text-muted; + font-size: 11px; + } + + Rectangle { height: 12px; } + + if messages.length > 0: MessageList { + messages: messages; + selected-index: root.selected-message-index; + priority-filter: root.priority-filter; + vertical-stretch: 1; + message-clicked(idx) => { + root.selected-message-index = idx; + root.message-card-clicked(idx); + } + } + + if messages.length == 0: EmptyState { + vertical-stretch: 1; + test-notification => { + root.test-notification-clicked(); + } + } } } - if messages.length == 0: EmptyState { - vertical-stretch: 1; - test-notification => { - root.test-notification-clicked(); + // Phase 7 C3: detail panel. Visible when a card is + // selected AND the user is on the All view. Always + // reads from `messages[selected-message-index]` so + // it stays in sync with whatever the card list + // currently shows. + if root.selection == SidebarSelection.All + && root.selected-message-index >= 0 + && root.selected-message-index < root.messages.length: Rectangle { + width: 360px; + background: surface-elev; + border-color: border; + border-width: 1px; + + VerticalLayout { + padding: 20px; + spacing: 8px; + + HorizontalLayout { + spacing: 8px; + alignment: center; + Rectangle { + width: 10px; + height: 10px; + border-radius: 5px; + background: root.messages[root.selected-message-index].priority == Priority.Low ? priority-low : + root.messages[root.selected-message-index].priority == Priority.Normal ? priority-normal : + root.messages[root.selected-message-index].priority == Priority.High ? priority-high : + priority-max; + } + Text { + text: root.messages[root.selected-message-index].app_name; + color: text-second; + font-size: 12px; + font-weight: 600; + horizontal-stretch: 1; + } + Text { + text: root.messages[root.selected-message-index].timestamp_unix == 0 + ? "" + : "id #" + root.messages[root.selected-message-index].id; + color: text-muted; + font-size: 11px; + } + } + + Text { + text: root.messages[root.selected-message-index].title != "" + ? root.messages[root.selected-message-index].title + : root.messages[root.selected-message-index].body; + color: text-primary; + font-size: 16px; + font-weight: 700; + wrap: word-wrap; + } + + Text { + text: root.messages[root.selected-message-index].relative_time; + color: text-muted; + font-size: 11px; + } + + Rectangle { + height: 1px; + background: border; + } + + Text { + text: root.messages[root.selected-message-index].body; + color: text-primary; + font-size: 13px; + wrap: word-wrap; + vertical-stretch: 1; + } + + // Open-link button: only when the message + // carries a click URL. Reuses the same + // Rust callback as the card click. + if root.messages[root.selected-message-index].has_click_url: Rectangle { + height: 32px; + background: accent; + border-radius: 6px; + TouchArea { + clicked => { + root.message-card-clicked(root.selected-message-index); + } + } + Text { + x: 0; + y: 0; + width: 100%; + height: 100%; + text: "Open link ↗"; + color: white; + font-size: 12px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + } + } } } } diff --git a/ui/message-list.slint b/ui/message-list.slint index d266cf9..5a594c8 100644 --- a/ui/message-list.slint +++ b/ui/message-list.slint @@ -5,13 +5,26 @@ // 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). +// +// Phase 7 C3: a `priority-filter` property hides non-matching cards via +// `visible`. Hiding (vs filtering the model) keeps the original index +// intact, which lets `selected-index` stay correct when the filter +// changes — clicking item 5 with the filter still applies to row 5 +// of the underlying list, not the post-filter row. -import { MessageItem, MessageCard } from "./message-card.slint"; +import { MessageItem, MessageCard, Priority } from "./message-card.slint"; export component MessageList inherits Rectangle { in property <[MessageItem]> messages; in-out property selected-index: -1; + // 0 = show all (no filter). 1 = Priority::Low, 2 = Normal, + // 3 = High, 4 = Max. Off-by-one alignment with the Slint + // `Priority` enum (Low=0, Normal=1, High=2, Max=3) is + // intentional so the UI shows "All / Low / Normal / High / Max" + // while the wire-level enum keeps its 0..3 range. + in property priority-filter: 0; + in property card-border: #3a3b40; callback message-clicked(int); @@ -24,6 +37,16 @@ export component MessageList inherits Rectangle { for item[idx] in messages: MessageCard { item: item; selected: idx == root.selected-index; + // Show every card when filter is "All"; otherwise only + // the priority bucket the user picked. Slint lets us + // compare an enum directly against an int literal, so + // `item.priority == 0` matches Priority::Low without + // any explicit conversion. + visible: root.priority-filter == 0 + || (root.priority-filter == 1 && item.priority == Priority.Low) + || (root.priority-filter == 2 && item.priority == Priority.Normal) + || (root.priority-filter == 3 && item.priority == Priority.High) + || (root.priority-filter == 4 && item.priority == Priority.Max); card-border: card-border; clicked => { root.selected-index = idx;