fix(ui): Phase 7 C4 — 卡片间距修复 + 全 UI 翻译为简体中文
两个问题修一处:\n\n1. 布局 bug:filter 切到 Max/High/Normal/Low 后可见卡片之间留出巨大空白。原因:Slint isible: false 在 for 循环里没真正折叠 layout 槽位,被隐藏的卡片仍占 76px 高 + 8px spacing。修法:把过滤从 Slint 侧搬到 Rust 侧,新增 filtered_model:UI 绑 filtered_model,shared_model 作为全量 canonical;切换过滤时 rebuild_filtered_model + WS drain 用 push_message_to_models 同步两表;测试连接 / tray restart 也都双表更新。\n\n2. UI 默认中文:sidebar(MESSAGES/SERVERS/FILTERS/APP → 消息/服务器/过滤/应用;All/Max/High/Normal/Low/Settings/About → 全部/最高/高/普通/低/设置/关于)、详情面板(Open link → 打开链接)、空态(You're all caught up → 暂时没有新消息;Test Notification → 发送测试通知)、设置页(5 个分组 + 所有 toggle + Appearance radio)、AboutDialog、托盘菜单(打开/暂停通知/设置.../重新连接/退出/新消息:N)、状态文本(Disconnected → 未连接;mock feed → 模拟数据;messages → 条消息)。\n\n新增 priority-filter-changed(int) 回调,sidebar 切过滤时通知 Rust 重建 filtered model。SharedModel/FilteredModel 同步逻辑:HTTP catch-up → WS drain push → 测试连接 → tray restart 都用 helper 函数维护一致性。
This commit is contained in:
+118
-19
@@ -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<VecModel<MessageItem>>,
|
||||
filtered: &Rc<VecModel<MessageItem>>,
|
||||
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::<Vec<_>>();
|
||||
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<VecModel<MessageItem>>,
|
||||
filtered: &Rc<VecModel<MessageItem>>,
|
||||
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<VecModel<MessageItem>> = Rc::new(VecModel::default());
|
||||
ui.set_messages(ModelRc::from(shared_model.clone()));
|
||||
let filtered_model: Rc<VecModel<MessageItem>> = 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<TrayIcon>` 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<RefCell>` so multiple closures (drain,
|
||||
// priority-filter-changed, test_connection) can all read+write
|
||||
// it on the UI thread.
|
||||
let priority_filter_cell: Rc<RefCell<i32>> = 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::<MessageItem>();
|
||||
let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> =
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user