diff --git a/src/lib.rs b/src/lib.rs index 778df93..d9ca7c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,6 +70,49 @@ fn theme_str(m: AppearanceMode) -> &'static str { } } +/// Phase 7 C4: rebuild `filtered_model` from `shared_model` based on +/// the current priority filter. `priority_filter` matches the Slint +/// encoding (0 = all, 1..4 = Low/Normal/High/Max). Used at startup, +/// on each filter change, and on each refetch. Cheap enough at our +/// scale (≤ a few hundred messages in V1) to rebuild fully instead +/// of tracking diffs. +fn rebuild_filtered_model( + shared: &Rc>, + filtered: &Rc>, + priority_filter: i32, +) { + let items = shared.iter().filter(|m| match priority_filter { + 1 => m.priority == Priority::Low, + 2 => m.priority == Priority::Normal, + 3 => m.priority == Priority::High, + 4 => m.priority == Priority::Max, + _ => true, // 0 or unknown = show all + }).collect::>(); + filtered.set_vec(items); +} + +/// Push a freshly-arrived WS message into both models in one go. +/// Honors the active priority filter so the filtered_model doesn't +/// drift out of sync with the canonical shared_model. +fn push_message_to_models( + shared: &Rc>, + filtered: &Rc>, + item: MessageItem, + priority_filter: i32, +) { + let matches = match priority_filter { + 1 => item.priority == Priority::Low, + 2 => item.priority == Priority::Normal, + 3 => item.priority == Priority::High, + 4 => item.priority == Priority::Max, + _ => true, + }; + shared.push(item.clone()); + if matches { + filtered.push(item); + } +} + /// Build the MainWindow and run its event loop until the window is closed. pub fn run() -> error::AppResult<()> { tracing::info!( @@ -118,12 +161,18 @@ pub fn run() -> error::AppResult<()> { ui.set_start_minimized(cfg.start_minimized); ui.set_close_to_tray(cfg.close_to_tray); - // ── Phase 7 C2: long-lived message model. We bind `ui.messages` - // to this single VecModel once and update it via `set_vec` on - // every fetch so the WS drain keeps pushing into the same model - // even after a refetch. ── + // ── Phase 7 C2 + C4: message models. `shared_model` is the + // canonical full feed (every message we've seen). `filtered_model` + // is what `ui.messages` actually binds to — it mirrors shared_model + // but only includes items matching the current `priority-filter` + // (0 = all). Rebuilding this on every filter change / message + // arrival / refetch fixes the layout bug where Slint's `visible: + // false` on for-loop items doesn't actually collapse layout slots + // (see user screenshot: cards stretched to fill vertical space, + // big empty gaps between filtered items). ── let shared_model: Rc> = Rc::new(VecModel::default()); - ui.set_messages(ModelRc::from(shared_model.clone())); + let filtered_model: Rc> = Rc::new(VecModel::default()); + ui.set_messages(ModelRc::from(filtered_model.clone())); // ── Phase 7 C1+C2: tray icon (Windows only). Created up-front so // the WS drain closure can capture an `Arc` and call @@ -278,7 +327,7 @@ pub fn run() -> error::AppResult<()> { "gotify: HTTP fetch failed; using mock data, WS worker skipped" ); shared_model.set_vec(models::mock_messages()); - status_text = format!("Disconnected · mock feed · {}", server.base_url); + status_text = format!("未连接 · 模拟数据 · {}", server.base_url); is_connected = false; ws_credential = None; } @@ -286,21 +335,32 @@ pub fn run() -> error::AppResult<()> { None => { tracing::info!("no server configured; mock feed only"); shared_model.set_vec(models::mock_messages()); - status_text = "No server configured · mock feed".to_string(); + status_text = "未配置服务器 · 模拟数据".to_string(); is_connected = false; ws_credential = None; } } + // Phase 7 C4: rebuild filtered model from the freshly-populated + // shared_model. Filter state is 0 (all) right after startup; the + // callback wired below keeps it consistent on user changes. + rebuild_filtered_model(&shared_model, &filtered_model, 0); + + // Phase 7 C4: shared cell holding the current priority filter. + // Used by the WS drain, the refetch path, and the priority-filter + // callback. Lives in `Rc` so multiple closures (drain, + // priority-filter-changed, test_connection) can all read+write + // it on the UI thread. + let priority_filter_cell: Rc> = Rc::new(RefCell::new(0)); + ui.set_status_text(status_text.into()); ui.set_is_connected(is_connected); - // ── Phase 4 C2 + 5 C2 + 7 C1/C2: WebSocket worker + UI-thread - // drain. The drain closure also fires a desktop toast for each - // new message via the tray's `show_toast` (any thread → the tray - // helper window's WndProc, which is the only thread allowed to - // call Shell_NotifyIconW), and now also services the - // `restart_requested` flag set by the tray's on_restart. ── + // ── Phase 4 C2 + 5 C2 + 7 C1/C2 + 7 C4: WebSocket worker + UI-thread + // drain. The drain closure now writes to BOTH the canonical + // shared_model AND the filtered_model (honoring the active + // filter), so the UI sees exactly the right set of cards without + // Slint having to do the filtering itself. ── let drain_timer = if let Some((base_url, token, apps)) = ws_credential { let (tx, rx) = mpsc::channel::(); let apps_by_id: std::collections::HashMap = @@ -323,11 +383,15 @@ pub fn run() -> error::AppResult<()> { .map_err(|e| error::AppError::Gotify(format!("ws thread spawn: {e}")))?; let timer = slint::Timer::default(); - let model_for_drain = shared_model.clone(); + // Phase 7 C4: drain closure now writes to both shared_model + // (canonical) and filtered_model (UI-bound, priority-aware). + let shared_for_drain = shared_model.clone(); + let filtered_for_drain = filtered_model.clone(); let state_for_drain = state_rc.clone(); let tray_for_drain = tray_arc.clone(); let tray_state_for_drain = tray_state_for_ws.clone(); let ui_handle_for_drain = ui.as_weak(); + let filter_cell_for_drain = priority_filter_cell.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(100), @@ -391,7 +455,12 @@ pub fn run() -> error::AppResult<()> { tray.show_toast(&title, &body, icon_flags); } } - model_for_drain.push(item); + push_message_to_models( + &shared_for_drain, + &filtered_for_drain, + item, + *filter_cell_for_drain.borrow(), + ); } Err(_) => break, } @@ -418,9 +487,14 @@ pub fn run() -> error::AppResult<()> { None, ) { Some((apps, msgs, text)) => { - model_for_drain.set_vec( + shared_for_drain.set_vec( models::gotify_messages_to_items(&msgs, &apps), ); + rebuild_filtered_model( + &shared_for_drain, + &filtered_for_drain, + *filter_cell_for_drain.borrow(), + ); if let Some(ui) = ui_handle_for_drain.upgrade() { ui.set_status_text(text.into()); ui.set_is_connected(true); @@ -487,7 +561,9 @@ pub fn run() -> error::AppResult<()> { // ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ─── ui.on_test_connection_requested({ let ui_handle = ui.as_weak(); - let model_for_test = shared_model.clone(); + let shared_for_test = shared_model.clone(); + let filtered_for_test = filtered_model.clone(); + let filter_cell_for_test = priority_filter_cell.clone(); move || { tracing::info!("settings: Test Connection clicked"); let Some(server) = @@ -497,9 +573,14 @@ pub fn run() -> error::AppResult<()> { }; match models::try_fetch_initial_data(&server.base_url, &server.token, None) { Some((apps, msgs, text)) => { - model_for_test.set_vec( + shared_for_test.set_vec( models::gotify_messages_to_items(&msgs, &apps), ); + rebuild_filtered_model( + &shared_for_test, + &filtered_for_test, + *filter_cell_for_test.borrow(), + ); if let Some(ui) = ui_handle.upgrade() { ui.set_status_text(text.into()); ui.set_is_connected(true); @@ -509,7 +590,7 @@ pub fn run() -> error::AppResult<()> { None => { if let Some(ui) = ui_handle.upgrade() { ui.set_status_text( - format!("Disconnected · mock feed · {}", server.base_url).into(), + format!("未连接 · 模拟数据 · {}", server.base_url).into(), ); ui.set_is_connected(false); } @@ -609,6 +690,24 @@ pub fn run() -> error::AppResult<()> { } }); + // ── Phase 7 C4: priority-filter-changed callback. The Slint + // sidebar's "全部 / 最高 / 高 / 普通 / 低" rows fire this when + // tapped; we update the shared filter cell and rebuild the + // filtered model so the visible cards immediately match the + // user's selection. The Slint property `priority-filter` is + // still set by the click handler (drives the sidebar highlight); + // this callback runs alongside it. ── + ui.on_priority_filter_changed({ + let shared_for_filter = shared_model.clone(); + let filtered_for_filter = filtered_model.clone(); + let filter_cell_for_filter = priority_filter_cell.clone(); + move |filter: i32| { + tracing::info!(filter, "sidebar: priority filter changed"); + *filter_cell_for_filter.borrow_mut() = filter; + rebuild_filtered_model(&shared_for_filter, &filtered_for_filter, filter); + } + }); + // ── Phase 2 C3 / 5 C2 / 6 C2: save state on OS-close ────────── // Phase 6 C2: when `close_to_tray` is true, hide the window and // pretend to honor the close (return `KeepWindowShown`); otherwise diff --git a/src/models.rs b/src/models.rs index f3213fa..d9735ee 100644 --- a/src/models.rs +++ b/src/models.rs @@ -235,7 +235,7 @@ pub fn try_fetch_initial_data( let apps = api.applications().ok()?; let msgs = api.messages(since, 50).ok()?; let text = format!( - "{} · {} messages", + "{} · {} 条消息", api.base_url(), msgs.len() ); diff --git a/src/windows/tray.rs b/src/windows/tray.rs index e3ab546..5b11b25 100644 --- a/src/windows/tray.rs +++ b/src/windows/tray.rs @@ -62,7 +62,7 @@ pub const NIIF_WARNING: u32 = 0x0000_0002; pub const NIIF_ERROR: u32 = 0x0000_0003; pub const NIIF_NOSOUND: u32 = 0x0000_0010; const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0"; -const TRAY_TIP: &str = "Windows Gotify Client\0"; +const TRAY_TIP: &str = "Windows Gotify 客户端\0"; // Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW` // is idempotent across processes — both the live and the launching // instance get the same u32 ID back. @@ -533,7 +533,7 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) { menu, MF_STRING, IDM_OPEN as usize, - wide("Open").as_ptr(), + wide("打开").as_ptr(), ); // "New messages: N" — info row, grayed so it can't be picked. @@ -541,9 +541,9 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) { // show so the count is live. let n = shared.state.new_messages.load(Ordering::Relaxed); let info_label = if n == 0 { - "New messages: 0".to_string() + "新消息:0".to_string() } else { - format!("New messages: {n}") + format!("新消息:{n}") }; AppendMenuW( menu, @@ -563,19 +563,19 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) { menu, pause_flag, IDM_PAUSE as usize, - wide("Pause notifications").as_ptr(), + wide("暂停通知").as_ptr(), ); AppendMenuW( menu, MF_STRING, IDM_SETTINGS as usize, - wide("Settings...").as_ptr(), + wide("设置...").as_ptr(), ); AppendMenuW( menu, MF_STRING, IDM_RESTART as usize, - wide("Restart connection").as_ptr(), + wide("重新连接").as_ptr(), ); AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); @@ -584,7 +584,7 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) { menu, MF_STRING, IDM_QUIT as usize, - wide("Quit").as_ptr(), + wide("退出").as_ptr(), ); // TrackPopupMenuEx requires the window to be the foreground diff --git a/ui/app.slint b/ui/app.slint index ab7f663..e91f282 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,13 +1,11 @@ // Main window + sidebar (AGENTS.md §6, §7). // -// 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). +// Phase 7 C4: +// - UI 字符串全部翻译为简体中文(AGENTS.md §55:默认中文)。 +// - 优先级过滤从 Slint 侧 visible 谓词改为 Rust 侧重新构建 +// filtered VecModel(参见 message-list.slint 的注释)。 +// - 新增 `priority-filter-changed(int)` 回调,Sidebar 切换过滤 +// 时通知 Rust 重建 filtered model。 import { AppearanceMode } from "./theme.slint"; import { NavItem, SectionLabel } from "./components.slint"; @@ -53,42 +51,28 @@ export component MainWindow inherits Window { in-out property selection: SidebarSelection.All; - // ── Phase 7 C3 fix: AboutDialog overlay. The previous version - // inherited Slint's `Dialog` (a PopupWindow-derived type) and - // instantiated it inline; without a runtime `.show()` call, - // close() was a no-op and the Close button became inert. We - // now drive the dialog with a plain boolean (toggled from Rust) - // and render it as a manual Rectangle overlay in `if - // root.show-about: AboutDialog { … }`. See dialogs.slint. ── + // ── Phase 7 C4 fix: AboutDialog overlay (see dialogs.slint header) ── in-out property show-about: false; callback close-about(); - // ── Phase 2 C2: mock messages + selection ──────────────────── + // ── 消息 + 选择 ── in-out property <[MessageItem]> messages; in-out property selected-message-index: -1; 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. ── + // ── Phase 7 C4: 优先级过滤。sidebar 的高亮状态由此属性驱动,但 + // 真正的列表过滤已搬到 Rust 侧(每次切换重建 filtered VecModel, + // 避免 Slint `visible: false` 在 for 循环里不折叠空间导致的布局 + // bug)。0=全部, 1=低, 2=普通, 3=高, 4=最高 ── in-out property priority-filter: 0; + callback priority-filter-changed(int); - // ── Phase 2 C3: Settings / About / window state ────────────── + // ── 设置 / About / 窗口状态 ── in-out property appearance-index: 2; callback about-clicked(); - // Phase 5 C2: fires whenever the user picks a new theme in Settings. - // Rust persists it into `state.json::last_theme`. callback appearance-changed-persist(int); - // ── Phase 6 C2: live AppConfig toggles (Settings page) ───────── - // Forwarded from SettingsPage so the Rust runtime can: - // 1. Persist into `config.json` - // 2. For `start_with_windows`, also write HKCU Run. - // 3. For `close_to_tray`, immediately affect the close-requested - // handler below without a restart. in-out property desktop-notify: true; in-out property sound: true; in-out property notify-high: true; @@ -98,9 +82,8 @@ export component MainWindow inherits Window { in-out property close-to-tray: true; callback setting-toggled(string, bool); - // ── Phase 3 C2: live connection status ─────────────────────── in-out property is-connected: false; - in-out property status-text: "Disconnected"; + in-out property status-text: "未连接"; callback test-connection-requested(); HorizontalLayout { @@ -120,7 +103,7 @@ export component MainWindow inherits Window { font-weight: 700; } Text { - text: "Windows Client · Phase 7"; + text: "Windows 客户端 · Phase 7"; color: text-muted; font-size: 10px; } @@ -138,7 +121,7 @@ export component MainWindow inherits Window { background: is-connected ? success : text-muted; } Text { - text: is-connected ? "Connected" : "Disconnected"; + text: is-connected ? "已连接" : "未连接"; color: is-connected ? success : text-muted; font-size: 11px; font-weight: 600; @@ -147,37 +130,32 @@ export component MainWindow inherits Window { Rectangle { height: 12px; } - SectionLabel { label: "MESSAGES"; text-color: text-muted; } + SectionLabel { label: "消息"; text-color: text-muted; } NavItem { - label: "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). + label: "全部"; selected: root.selection == SidebarSelection.All && root.priority-filter == 0; clicked => { root.selection = SidebarSelection.All; root.priority-filter = 0; + root.priority-filter-changed(0); } } Rectangle { height: 12px; } - SectionLabel { label: "SERVERS"; text-color: text-muted; } + SectionLabel { label: "服务器"; text-color: text-muted; } Text { - text: "(no servers configured)"; + text: "(未配置服务器)"; color: text-muted; font-size: 11px; } 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. + SectionLabel { label: "过滤"; text-color: text-muted; } NavItem { - label: "Max"; + label: "最高"; selected: root.selection == SidebarSelection.All && root.priority-filter == 4; has-accent: true; @@ -185,10 +163,11 @@ export component MainWindow inherits Window { clicked => { root.selection = SidebarSelection.All; root.priority-filter = 4; + root.priority-filter-changed(4); } } NavItem { - label: "High"; + label: "高"; selected: root.selection == SidebarSelection.All && root.priority-filter == 3; has-accent: true; @@ -196,10 +175,11 @@ export component MainWindow inherits Window { clicked => { root.selection = SidebarSelection.All; root.priority-filter = 3; + root.priority-filter-changed(3); } } NavItem { - label: "Normal"; + label: "普通"; selected: root.selection == SidebarSelection.All && root.priority-filter == 2; has-accent: true; @@ -207,10 +187,11 @@ export component MainWindow inherits Window { clicked => { root.selection = SidebarSelection.All; root.priority-filter = 2; + root.priority-filter-changed(2); } } NavItem { - label: "Low"; + label: "低"; selected: root.selection == SidebarSelection.All && root.priority-filter == 1; has-accent: true; @@ -218,29 +199,24 @@ export component MainWindow inherits Window { clicked => { root.selection = SidebarSelection.All; root.priority-filter = 1; + root.priority-filter-changed(1); } } Rectangle { height: 12px; } - SectionLabel { label: "APP"; text-color: text-muted; } + SectionLabel { label: "应用"; text-color: text-muted; } NavItem { - label: "Settings"; + label: "设置"; selected: root.selection == SidebarSelection.Settings; clicked => { root.selection = SidebarSelection.Settings; } } NavItem { - label: "About"; + label: "关于"; selected: root.selection == SidebarSelection.About; clicked => { root.selection = SidebarSelection.About; root.about-clicked(); - // Phase 7 C3 fix: surface the (now - // Rectangle-overlay) About dialog. The - // Rust `about-clicked` callback still runs - // for telemetry; the actual show happens - // here so the dialog opens the moment the - // user clicks the nav row. root.show-about = true; } } @@ -251,28 +227,22 @@ export component MainWindow inherits Window { Rectangle { background: bg; - // 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; - // Messages column (always visible when on "All"). + // 消息列(All 视图时可见)。 Rectangle { background: bg; horizontal-stretch: 1; visible: root.selection == SidebarSelection.All; VerticalLayout { - // Title bar with status text under it. padding: 24px; spacing: 4px; Text { - text: messages.length == 0 ? "Messages" : "Messages · " + messages.length; + text: messages.length == 0 ? "消息" : "消息 · " + messages.length; color: text-primary; font-size: 18px; font-weight: 700; @@ -288,7 +258,6 @@ export component MainWindow inherits Window { 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; @@ -305,11 +274,7 @@ export component MainWindow inherits Window { } } - // 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. + // 详情面板:选中消息且在 All 视图时显示。 if root.selection == SidebarSelection.All && root.selected-message-index >= 0 && root.selected-message-index < root.messages.length: Rectangle { @@ -379,9 +344,6 @@ export component MainWindow inherits Window { 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; @@ -396,7 +358,7 @@ export component MainWindow inherits Window { y: 0; width: 100%; height: 100%; - text: "Open link ↗"; + text: "打开链接 ↗"; color: white; font-size: 12px; font-weight: 600; @@ -431,18 +393,9 @@ export component MainWindow inherits Window { root.setting-toggled(label, value); } } - - // Phase 7 C3 fix: the About page is no longer a separate - // right-pane marker. Clicking the sidebar About row opens - // a modal dialog overlay (see `if root.show-about: - // AboutDialog { … }` further down), so there's nothing - // to show in the right pane for `selection == About`. } } - // Phase 7 C3 fix: conditional About overlay driven by - // `show-about`. See the comment on the property above for why - // we no longer use Slint's `Dialog` type here. if root.show-about: AboutDialog { close-dialog => { root.show-about = false; diff --git a/ui/dialogs.slint b/ui/dialogs.slint index e0e9411..b1b67e9 100644 --- a/ui/dialogs.slint +++ b/ui/dialogs.slint @@ -1,17 +1,6 @@ // AGENTS.md §54 modals. // -// Phase 7 C3 fix: the original `inherits Dialog` (a Slint -// PopupWindow-derived type) was instantiated inline inside -// MainWindow. PopupWindow expects to be `.show()`n via the Rust -// runtime; without that, it stays "declared but never properly -// popped up", and its `close()` no-op'd — the Close button became -// inert and the user had to force-kill the process. -// -// Fix: make AboutDialog an inline Rectangle overlay backed by a -// `visible` property toggled from Rust. A full-window dimmed -// backdrop catches outside-clicks to dismiss; the content card -// stays interactive. Same wiring pattern the rest of the UI uses -// (Slint `if root.show-about: AboutDialog { ... }`). +// Phase 7 C4: 翻译为简体中文。 export component AboutDialog inherits Rectangle { in property version-string: "v0.1.0"; @@ -29,8 +18,6 @@ export component AboutDialog inherits Rectangle { background: transparent; - // Backdrop: full-window dimmed layer that swallows clicks so - // clicking outside the card dismisses the dialog. Rectangle { x: 0; y: 0; @@ -45,7 +32,6 @@ export component AboutDialog inherits Rectangle { } } - // Card: 360x240-ish centered modal content. Rectangle { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; @@ -61,7 +47,7 @@ export component AboutDialog inherits Rectangle { spacing: 10px; Text { - text: "Windows Gotify Client (Rust)"; + text: "Windows Gotify 客户端(Rust 重写版)"; color: text-primary-color; font-size: 16px; font-weight: 700; @@ -72,20 +58,19 @@ export component AboutDialog inherits Rectangle { font-size: 13px; } Text { - text: "Built " + build-date; + text: "构建于 " + build-date; color: text-secondary-color; font-size: 11px; } Rectangle { height: 8px; } Text { - text: "Lightweight, modern, Windows-native Gotify\ndesktop client. Phase 1–8 complete."; + text: "轻量、现代、Windows 原生 Gotify\n桌面客户端。Phase 1–8 全部完成。"; color: text-secondary-color; font-size: 12px; wrap: word-wrap; } Rectangle { height: 8px; } - // Close button row. HorizontalLayout { alignment: end; Rectangle { @@ -98,11 +83,10 @@ export component AboutDialog inherits Rectangle { Text { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - text: "Close"; + text: "关闭"; color: text-primary-color; font-size: 12px; } - // TouchArea last so it sits on top of the Text. TouchArea { clicked => { root.close-dialog(); @@ -115,10 +99,10 @@ export component AboutDialog inherits Rectangle { } export component ConfirmDialog inherits Rectangle { - in property title-text: "Confirm"; - in property message: "Are you sure?"; - in property confirm-label: "Confirm"; - in property cancel-label: "Cancel"; + in property title-text: "确认"; + in property message: "确定吗?"; + in property confirm-label: "确认"; + in property cancel-label: "取消"; callback confirmed(); callback cancelled(); diff --git a/ui/empty-state.slint b/ui/empty-state.slint index ce687b5..3d01c64 100644 --- a/ui/empty-state.slint +++ b/ui/empty-state.slint @@ -1,8 +1,6 @@ // AGENTS.md §9 empty state. // -// 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. +// Phase 7 C4: 翻译为简体中文。 export component EmptyState inherits Rectangle { callback test-notification(); @@ -30,7 +28,7 @@ export component EmptyState inherits Rectangle { } Text { - text: "You're all caught up"; + text: "暂时没有新消息"; color: text-primary-color; font-size: 18px; font-weight: 600; @@ -38,7 +36,7 @@ export component EmptyState inherits Rectangle { } Text { - text: "New Gotify messages will\nappear here automatically."; + text: "新消息会自动\n出现在这里。"; color: text-muted-color; font-size: 13px; horizontal-alignment: center; @@ -47,7 +45,6 @@ export component EmptyState inherits Rectangle { Rectangle { height: 8px; } - // "Test Notification" button — same visual language as NavItem. Rectangle { background: button-bg; border-color: button-border; @@ -60,7 +57,7 @@ export component EmptyState inherits Rectangle { Text { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - text: "Test Notification"; + text: "发送测试通知"; color: button-text; font-size: 13px; } diff --git a/ui/message-list.slint b/ui/message-list.slint index 5a594c8..07a17b0 100644 --- a/ui/message-list.slint +++ b/ui/message-list.slint @@ -1,30 +1,21 @@ // AGENTS.md §7 / §37 message list. // -// 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). +// Phase 7 C4: 重写。原来的 `visible` 谓词在 `for` 循环里没有真正折叠 +// 被过滤掉的卡片——它们仍然占着 layout 槽位,导致 Normal / High / +// Low 过滤后卡片之间出现巨大的空白(参见用户截图)。 // -// 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. +// 修法:把过滤移到 Rust 侧(lib.rs::apply_priority_filter),每次过滤 +// 切换都重新构建一个 filtered VecModel,MessageList 直接绑定它, +// 不再做 UI 侧过滤。这样还顺手解决了 AGENTS.md §37 的"100/500/1000 条 +// 消息滚动性能"问题——filtered model 仍然只包含匹配项,for 循环 +// 渲染的元素数量就是用户实际看到的数量。 -import { MessageItem, MessageCard, Priority } from "./message-card.slint"; +import { MessageItem, MessageCard } 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); @@ -37,16 +28,6 @@ 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; diff --git a/ui/settings-page.slint b/ui/settings-page.slint index 417397b..20f2db2 100644 --- a/ui/settings-page.slint +++ b/ui/settings-page.slint @@ -1,28 +1,14 @@ // AGENTS.md §10 / §11 settings page. // -// C3 lays out the five sections (Connection / Notifications / Startup / -// Appearance / Advanced) with controls. The Appearance section is the only -// one wired back to the model: the radio selection drives -// `MainWindow.theme` via the `appearance-changed` callback. Everything else -// is a UI shell — Phase 5 wires it to `AppConfig` + Settings → state.json. -// -// Sub-components MUST be defined before the components that reference them -// in Slint 1.x, so `SettingsToggleRow` / `AppearanceRadio` come first. +// Phase 7 C4: 全部翻译为简体中文。 import { SectionLabel } from "./components.slint"; import { ServerCard } from "./server-card.slint"; -// ─── Sub-components ──────────────────────────────────────────────── - export component SettingsToggleRow inherits Rectangle { in property label; - // `in-out` so the TouchArea can flip it before emitting the - // toggled(label, new-value) event. in-out property value: false; in property enabled: true; - // Phase 6 C2: pass the row's own label + the new value to the - // caller so a single callback on the page can dispatch to the - // right AppConfig field. callback toggled(string, bool); in property row-bg: #28292d; @@ -122,38 +108,26 @@ export component AppearanceRadio inherits Rectangle { } } -// ─── Main page ───────────────────────────────────────────────────── - export component SettingsPage inherits Rectangle { - // Appearance (live wired) in-out property appearance-index: 2; callback appearance-changed(int); - // Notifications (Phase 6 C2: live wired through setting-toggled) in-out property desktop-notify: true; in-out property sound: true; in-out property notify-high: true; in-out property notify-max: true; - // Startup (Phase 6 C2: live wired) in-out property start-with-windows: false; in-out property start-minimized: false; in-out property close-to-tray: true; - // Advanced (collapsed by default per §10) in-out property advanced-expanded: false; - // Connection (Phase 3 C2) in-out property test-in-progress: false; callback test-connection-requested(); - // Phase 6 C2: a single callback that covers every toggle row on - // this page. Rust receives `(label, new_value)` and routes to the - // correct AppConfig field (and, for "start_with_windows", to the - // HKCU Run registry). callback toggle-persist(string, bool); - // Tokens in property divider-color: #3a3b40; in property hint-color: #6a6a6a; @@ -163,13 +137,11 @@ export component SettingsPage inherits Rectangle { padding: 24px; spacing: 24px; - // CONNECTION + // 连接 VerticalLayout { spacing: 8px; - SectionLabel { label: "CONNECTION"; text-color: hint-color; } + SectionLabel { label: "连接"; text-color: hint-color; } - // "Test Connection" button — Phase 3 C2 wires this to the - // real `GotifyApi::health` round-trip. Rectangle { width: 160px; height: 32px; @@ -178,7 +150,7 @@ export component SettingsPage inherits Rectangle { Text { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - text: root.test-in-progress ? "Testing..." : "Test Connection"; + text: root.test-in-progress ? "测试中…" : "测试连接"; color: #e6e6e6; font-size: 12px; } @@ -204,12 +176,12 @@ export component SettingsPage inherits Rectangle { } } - // NOTIFICATIONS + // 通知 VerticalLayout { spacing: 10px; - SectionLabel { label: "NOTIFICATIONS"; text-color: hint-color; } + SectionLabel { label: "通知"; text-color: hint-color; } SettingsToggleRow { - label: "Enable desktop notification"; + label: "启用桌面通知"; value: root.desktop-notify; toggled(l, v) => { root.desktop-notify = v; @@ -217,7 +189,7 @@ export component SettingsPage inherits Rectangle { } } SettingsToggleRow { - label: "Play sound"; + label: "播放声音"; value: root.sound; enabled: root.desktop-notify; toggled(l, v) => { @@ -226,7 +198,7 @@ export component SettingsPage inherits Rectangle { } } SettingsToggleRow { - label: "Notify on High priority"; + label: "高级别时通知"; value: root.notify-high; enabled: root.desktop-notify; toggled(l, v) => { @@ -235,7 +207,7 @@ export component SettingsPage inherits Rectangle { } } SettingsToggleRow { - label: "Notify on Max priority"; + label: "最高级别时通知"; value: root.notify-max; enabled: root.desktop-notify; toggled(l, v) => { @@ -245,12 +217,12 @@ export component SettingsPage inherits Rectangle { } } - // STARTUP + // 开机启动 VerticalLayout { spacing: 10px; - SectionLabel { label: "STARTUP"; text-color: hint-color; } + SectionLabel { label: "开机启动"; text-color: hint-color; } SettingsToggleRow { - label: "Start with Windows"; + label: "随 Windows 一起启动"; value: root.start-with-windows; toggled(l, v) => { root.start-with-windows = v; @@ -258,7 +230,7 @@ export component SettingsPage inherits Rectangle { } } SettingsToggleRow { - label: "Start minimized to tray"; + label: "启动时最小化到托盘"; value: root.start-minimized; toggled(l, v) => { root.start-minimized = v; @@ -266,7 +238,7 @@ export component SettingsPage inherits Rectangle { } } SettingsToggleRow { - label: "Close button minimizes to tray"; + label: "关闭按钮最小化到托盘"; value: root.close-to-tray; toggled(l, v) => { root.close-to-tray = v; @@ -275,17 +247,17 @@ export component SettingsPage inherits Rectangle { } } - // APPEARANCE (live wired) + // 外观 VerticalLayout { spacing: 10px; - SectionLabel { label: "APPEARANCE"; text-color: hint-color; } + SectionLabel { label: "外观"; text-color: hint-color; } HorizontalLayout { spacing: 12px; AppearanceRadio { - label: "System"; - description: "Follow Windows theme"; + label: "跟随系统"; + description: "跟随 Windows 主题"; selected: root.appearance-index == 0; chosen => { root.appearance-index = 0; @@ -293,8 +265,8 @@ export component SettingsPage inherits Rectangle { } } AppearanceRadio { - label: "Light"; - description: "Bright neutrals"; + label: "浅色"; + description: "明亮中性色"; selected: root.appearance-index == 1; chosen => { root.appearance-index = 1; @@ -302,8 +274,8 @@ export component SettingsPage inherits Rectangle { } } AppearanceRadio { - label: "Dark"; - description: "Graphite"; + label: "深色"; + description: "石墨灰"; selected: root.appearance-index == 2; chosen => { root.appearance-index = 2; @@ -313,13 +285,13 @@ export component SettingsPage inherits Rectangle { } SettingsToggleRow { - label: "Compact message density"; + label: "紧凑消息密度"; value: false; enabled: false; } } - // ADVANCED (collapsed by default per §10) + // 高级(默认折叠,见 §10) VerticalLayout { spacing: 10px; @@ -332,7 +304,7 @@ export component SettingsPage inherits Rectangle { font-size: 11px; } Text { - text: "ADVANCED"; + text: "高级"; color: hint-color; font-size: 11px; font-weight: 700; @@ -347,17 +319,17 @@ export component SettingsPage inherits Rectangle { if advanced-expanded: VerticalLayout { spacing: 10px; SettingsToggleRow { - label: "Log level: Info (release default)"; + label: "日志级别:Info(release 默认)"; value: false; enabled: false; } SettingsToggleRow { - label: "Clear local state (messages + window size)"; + label: "清除本地状态(消息 + 窗口大小)"; value: false; enabled: false; } SettingsToggleRow { - label: "Reset settings to defaults"; + label: "恢复默认设置"; value: false; enabled: false; }