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.
|
/// Build the MainWindow and run its event loop until the window is closed.
|
||||||
pub fn run() -> error::AppResult<()> {
|
pub fn run() -> error::AppResult<()> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -118,12 +161,18 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
ui.set_start_minimized(cfg.start_minimized);
|
ui.set_start_minimized(cfg.start_minimized);
|
||||||
ui.set_close_to_tray(cfg.close_to_tray);
|
ui.set_close_to_tray(cfg.close_to_tray);
|
||||||
|
|
||||||
// ── Phase 7 C2: long-lived message model. We bind `ui.messages`
|
// ── Phase 7 C2 + C4: message models. `shared_model` is the
|
||||||
// to this single VecModel once and update it via `set_vec` on
|
// canonical full feed (every message we've seen). `filtered_model`
|
||||||
// every fetch so the WS drain keeps pushing into the same model
|
// is what `ui.messages` actually binds to — it mirrors shared_model
|
||||||
// even after a refetch. ──
|
// 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());
|
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
|
// ── Phase 7 C1+C2: tray icon (Windows only). Created up-front so
|
||||||
// the WS drain closure can capture an `Arc<TrayIcon>` and call
|
// 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"
|
"gotify: HTTP fetch failed; using mock data, WS worker skipped"
|
||||||
);
|
);
|
||||||
shared_model.set_vec(models::mock_messages());
|
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;
|
is_connected = false;
|
||||||
ws_credential = None;
|
ws_credential = None;
|
||||||
}
|
}
|
||||||
@@ -286,21 +335,32 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
None => {
|
None => {
|
||||||
tracing::info!("no server configured; mock feed only");
|
tracing::info!("no server configured; mock feed only");
|
||||||
shared_model.set_vec(models::mock_messages());
|
shared_model.set_vec(models::mock_messages());
|
||||||
status_text = "No server configured · mock feed".to_string();
|
status_text = "未配置服务器 · 模拟数据".to_string();
|
||||||
is_connected = false;
|
is_connected = false;
|
||||||
ws_credential = None;
|
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_status_text(status_text.into());
|
||||||
ui.set_is_connected(is_connected);
|
ui.set_is_connected(is_connected);
|
||||||
|
|
||||||
// ── Phase 4 C2 + 5 C2 + 7 C1/C2: WebSocket worker + UI-thread
|
// ── Phase 4 C2 + 5 C2 + 7 C1/C2 + 7 C4: WebSocket worker + UI-thread
|
||||||
// drain. The drain closure also fires a desktop toast for each
|
// drain. The drain closure now writes to BOTH the canonical
|
||||||
// new message via the tray's `show_toast` (any thread → the tray
|
// shared_model AND the filtered_model (honoring the active
|
||||||
// helper window's WndProc, which is the only thread allowed to
|
// filter), so the UI sees exactly the right set of cards without
|
||||||
// call Shell_NotifyIconW), and now also services the
|
// Slint having to do the filtering itself. ──
|
||||||
// `restart_requested` flag set by the tray's on_restart. ──
|
|
||||||
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
|
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
|
||||||
let (tx, rx) = mpsc::channel::<MessageItem>();
|
let (tx, rx) = mpsc::channel::<MessageItem>();
|
||||||
let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> =
|
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}")))?;
|
.map_err(|e| error::AppError::Gotify(format!("ws thread spawn: {e}")))?;
|
||||||
|
|
||||||
let timer = slint::Timer::default();
|
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 state_for_drain = state_rc.clone();
|
||||||
let tray_for_drain = tray_arc.clone();
|
let tray_for_drain = tray_arc.clone();
|
||||||
let tray_state_for_drain = tray_state_for_ws.clone();
|
let tray_state_for_drain = tray_state_for_ws.clone();
|
||||||
let ui_handle_for_drain = ui.as_weak();
|
let ui_handle_for_drain = ui.as_weak();
|
||||||
|
let filter_cell_for_drain = priority_filter_cell.clone();
|
||||||
timer.start(
|
timer.start(
|
||||||
slint::TimerMode::Repeated,
|
slint::TimerMode::Repeated,
|
||||||
std::time::Duration::from_millis(100),
|
std::time::Duration::from_millis(100),
|
||||||
@@ -391,7 +455,12 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
tray.show_toast(&title, &body, icon_flags);
|
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,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
@@ -418,9 +487,14 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
None,
|
None,
|
||||||
) {
|
) {
|
||||||
Some((apps, msgs, text)) => {
|
Some((apps, msgs, text)) => {
|
||||||
model_for_drain.set_vec(
|
shared_for_drain.set_vec(
|
||||||
models::gotify_messages_to_items(&msgs, &apps),
|
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() {
|
if let Some(ui) = ui_handle_for_drain.upgrade() {
|
||||||
ui.set_status_text(text.into());
|
ui.set_status_text(text.into());
|
||||||
ui.set_is_connected(true);
|
ui.set_is_connected(true);
|
||||||
@@ -487,7 +561,9 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
// ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ───
|
// ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ───
|
||||||
ui.on_test_connection_requested({
|
ui.on_test_connection_requested({
|
||||||
let ui_handle = ui.as_weak();
|
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 || {
|
move || {
|
||||||
tracing::info!("settings: Test Connection clicked");
|
tracing::info!("settings: Test Connection clicked");
|
||||||
let Some(server) =
|
let Some(server) =
|
||||||
@@ -497,9 +573,14 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
};
|
};
|
||||||
match models::try_fetch_initial_data(&server.base_url, &server.token, None) {
|
match models::try_fetch_initial_data(&server.base_url, &server.token, None) {
|
||||||
Some((apps, msgs, text)) => {
|
Some((apps, msgs, text)) => {
|
||||||
model_for_test.set_vec(
|
shared_for_test.set_vec(
|
||||||
models::gotify_messages_to_items(&msgs, &apps),
|
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() {
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
ui.set_status_text(text.into());
|
ui.set_status_text(text.into());
|
||||||
ui.set_is_connected(true);
|
ui.set_is_connected(true);
|
||||||
@@ -509,7 +590,7 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
None => {
|
None => {
|
||||||
if let Some(ui) = ui_handle.upgrade() {
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
ui.set_status_text(
|
ui.set_status_text(
|
||||||
format!("Disconnected · mock feed · {}", server.base_url).into(),
|
format!("未连接 · 模拟数据 · {}", server.base_url).into(),
|
||||||
);
|
);
|
||||||
ui.set_is_connected(false);
|
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 2 C3 / 5 C2 / 6 C2: save state on OS-close ──────────
|
||||||
// Phase 6 C2: when `close_to_tray` is true, hide the window and
|
// Phase 6 C2: when `close_to_tray` is true, hide the window and
|
||||||
// pretend to honor the close (return `KeepWindowShown`); otherwise
|
// pretend to honor the close (return `KeepWindowShown`); otherwise
|
||||||
|
|||||||
+1
-1
@@ -235,7 +235,7 @@ pub fn try_fetch_initial_data(
|
|||||||
let apps = api.applications().ok()?;
|
let apps = api.applications().ok()?;
|
||||||
let msgs = api.messages(since, 50).ok()?;
|
let msgs = api.messages(since, 50).ok()?;
|
||||||
let text = format!(
|
let text = format!(
|
||||||
"{} · {} messages",
|
"{} · {} 条消息",
|
||||||
api.base_url(),
|
api.base_url(),
|
||||||
msgs.len()
|
msgs.len()
|
||||||
);
|
);
|
||||||
|
|||||||
+8
-8
@@ -62,7 +62,7 @@ pub const NIIF_WARNING: u32 = 0x0000_0002;
|
|||||||
pub const NIIF_ERROR: u32 = 0x0000_0003;
|
pub const NIIF_ERROR: u32 = 0x0000_0003;
|
||||||
pub const NIIF_NOSOUND: u32 = 0x0000_0010;
|
pub const NIIF_NOSOUND: u32 = 0x0000_0010;
|
||||||
const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
|
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`
|
// Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW`
|
||||||
// is idempotent across processes — both the live and the launching
|
// is idempotent across processes — both the live and the launching
|
||||||
// instance get the same u32 ID back.
|
// instance get the same u32 ID back.
|
||||||
@@ -533,7 +533,7 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
|
|||||||
menu,
|
menu,
|
||||||
MF_STRING,
|
MF_STRING,
|
||||||
IDM_OPEN as usize,
|
IDM_OPEN as usize,
|
||||||
wide("Open").as_ptr(),
|
wide("打开").as_ptr(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// "New messages: N" — info row, grayed so it can't be picked.
|
// "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.
|
// show so the count is live.
|
||||||
let n = shared.state.new_messages.load(Ordering::Relaxed);
|
let n = shared.state.new_messages.load(Ordering::Relaxed);
|
||||||
let info_label = if n == 0 {
|
let info_label = if n == 0 {
|
||||||
"New messages: 0".to_string()
|
"新消息:0".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("New messages: {n}")
|
format!("新消息:{n}")
|
||||||
};
|
};
|
||||||
AppendMenuW(
|
AppendMenuW(
|
||||||
menu,
|
menu,
|
||||||
@@ -563,19 +563,19 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
|
|||||||
menu,
|
menu,
|
||||||
pause_flag,
|
pause_flag,
|
||||||
IDM_PAUSE as usize,
|
IDM_PAUSE as usize,
|
||||||
wide("Pause notifications").as_ptr(),
|
wide("暂停通知").as_ptr(),
|
||||||
);
|
);
|
||||||
AppendMenuW(
|
AppendMenuW(
|
||||||
menu,
|
menu,
|
||||||
MF_STRING,
|
MF_STRING,
|
||||||
IDM_SETTINGS as usize,
|
IDM_SETTINGS as usize,
|
||||||
wide("Settings...").as_ptr(),
|
wide("设置...").as_ptr(),
|
||||||
);
|
);
|
||||||
AppendMenuW(
|
AppendMenuW(
|
||||||
menu,
|
menu,
|
||||||
MF_STRING,
|
MF_STRING,
|
||||||
IDM_RESTART as usize,
|
IDM_RESTART as usize,
|
||||||
wide("Restart connection").as_ptr(),
|
wide("重新连接").as_ptr(),
|
||||||
);
|
);
|
||||||
|
|
||||||
AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null());
|
AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null());
|
||||||
@@ -584,7 +584,7 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
|
|||||||
menu,
|
menu,
|
||||||
MF_STRING,
|
MF_STRING,
|
||||||
IDM_QUIT as usize,
|
IDM_QUIT as usize,
|
||||||
wide("Quit").as_ptr(),
|
wide("退出").as_ptr(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// TrackPopupMenuEx requires the window to be the foreground
|
// TrackPopupMenuEx requires the window to be the foreground
|
||||||
|
|||||||
+38
-85
@@ -1,13 +1,11 @@
|
|||||||
// Main window + sidebar (AGENTS.md §6, §7).
|
// Main window + sidebar (AGENTS.md §6, §7).
|
||||||
//
|
//
|
||||||
// Phase 7 C3 additions:
|
// Phase 7 C4:
|
||||||
// - Sidebar priority filter (All / Low / Normal / High / Max)
|
// - UI 字符串全部翻译为简体中文(AGENTS.md §55:默认中文)。
|
||||||
// flips `priority-filter` on MainWindow; MessageList hides
|
// - 优先级过滤从 Slint 侧 visible 谓词改为 Rust 侧重新构建
|
||||||
// non-matching cards via `visible` (see message-list.slint).
|
// filtered VecModel(参见 message-list.slint 的注释)。
|
||||||
// - Right-hand detail panel that opens when a message is clicked:
|
// - 新增 `priority-filter-changed(int)` 回调,Sidebar 切换过滤
|
||||||
// app + priority dot, title, absolute timestamp, full body,
|
// 时通知 Rust 重建 filtered model。
|
||||||
// and an "Open Link" button (re-uses the URL opener wired up
|
|
||||||
// in Phase 7 C2).
|
|
||||||
|
|
||||||
import { AppearanceMode } from "./theme.slint";
|
import { AppearanceMode } from "./theme.slint";
|
||||||
import { NavItem, SectionLabel } from "./components.slint";
|
import { NavItem, SectionLabel } from "./components.slint";
|
||||||
@@ -53,42 +51,28 @@ export component MainWindow inherits Window {
|
|||||||
|
|
||||||
in-out property <SidebarSelection> selection: SidebarSelection.All;
|
in-out property <SidebarSelection> selection: SidebarSelection.All;
|
||||||
|
|
||||||
// ── Phase 7 C3 fix: AboutDialog overlay. The previous version
|
// ── Phase 7 C4 fix: AboutDialog overlay (see dialogs.slint header) ──
|
||||||
// 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. ──
|
|
||||||
in-out property <bool> show-about: false;
|
in-out property <bool> show-about: false;
|
||||||
callback close-about();
|
callback close-about();
|
||||||
|
|
||||||
// ── Phase 2 C2: mock messages + selection ────────────────────
|
// ── 消息 + 选择 ──
|
||||||
in-out property <[MessageItem]> messages;
|
in-out property <[MessageItem]> messages;
|
||||||
in-out property <int> selected-message-index: -1;
|
in-out property <int> selected-message-index: -1;
|
||||||
callback test-notification-clicked();
|
callback test-notification-clicked();
|
||||||
callback message-card-clicked(int);
|
callback message-card-clicked(int);
|
||||||
|
|
||||||
// ── Phase 7 C3: priority filter (0=All, 1=Low, 2=Normal,
|
// ── Phase 7 C4: 优先级过滤。sidebar 的高亮状态由此属性驱动,但
|
||||||
// 3=High, 4=Max). The integer values intentionally align with
|
// 真正的列表过滤已搬到 Rust 侧(每次切换重建 filtered VecModel,
|
||||||
// `PriorityFilter` (0=All is the special "no filter" sentinel;
|
// 避免 Slint `visible: false` 在 for 循环里不折叠空间导致的布局
|
||||||
// 1..4 map to the four priority buckets). See message-list.slint
|
// bug)。0=全部, 1=低, 2=普通, 3=高, 4=最高 ──
|
||||||
// for the matching `visible` predicate. ──
|
|
||||||
in-out property <int> priority-filter: 0;
|
in-out property <int> priority-filter: 0;
|
||||||
|
callback priority-filter-changed(int);
|
||||||
|
|
||||||
// ── Phase 2 C3: Settings / About / window state ──────────────
|
// ── 设置 / About / 窗口状态 ──
|
||||||
in-out property <int> appearance-index: 2;
|
in-out property <int> appearance-index: 2;
|
||||||
callback about-clicked();
|
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);
|
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 <bool> desktop-notify: true;
|
in-out property <bool> desktop-notify: true;
|
||||||
in-out property <bool> sound: true;
|
in-out property <bool> sound: true;
|
||||||
in-out property <bool> notify-high: true;
|
in-out property <bool> notify-high: true;
|
||||||
@@ -98,9 +82,8 @@ export component MainWindow inherits Window {
|
|||||||
in-out property <bool> close-to-tray: true;
|
in-out property <bool> close-to-tray: true;
|
||||||
callback setting-toggled(string, bool);
|
callback setting-toggled(string, bool);
|
||||||
|
|
||||||
// ── Phase 3 C2: live connection status ───────────────────────
|
|
||||||
in-out property <bool> is-connected: false;
|
in-out property <bool> is-connected: false;
|
||||||
in-out property <string> status-text: "Disconnected";
|
in-out property <string> status-text: "未连接";
|
||||||
callback test-connection-requested();
|
callback test-connection-requested();
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -120,7 +103,7 @@ export component MainWindow inherits Window {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: "Windows Client · Phase 7";
|
text: "Windows 客户端 · Phase 7";
|
||||||
color: text-muted;
|
color: text-muted;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
@@ -138,7 +121,7 @@ export component MainWindow inherits Window {
|
|||||||
background: is-connected ? success : text-muted;
|
background: is-connected ? success : text-muted;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: is-connected ? "Connected" : "Disconnected";
|
text: is-connected ? "已连接" : "未连接";
|
||||||
color: is-connected ? success : text-muted;
|
color: is-connected ? success : text-muted;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -147,37 +130,32 @@ export component MainWindow inherits Window {
|
|||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
SectionLabel { label: "MESSAGES"; text-color: text-muted; }
|
SectionLabel { label: "消息"; text-color: text-muted; }
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "All";
|
label: "全部";
|
||||||
// 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
|
selected: root.selection == SidebarSelection.All
|
||||||
&& root.priority-filter == 0;
|
&& root.priority-filter == 0;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.selection = SidebarSelection.All;
|
root.selection = SidebarSelection.All;
|
||||||
root.priority-filter = 0;
|
root.priority-filter = 0;
|
||||||
|
root.priority-filter-changed(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
SectionLabel { label: "SERVERS"; text-color: text-muted; }
|
SectionLabel { label: "服务器"; text-color: text-muted; }
|
||||||
Text {
|
Text {
|
||||||
text: "(no servers configured)";
|
text: "(未配置服务器)";
|
||||||
color: text-muted;
|
color: text-muted;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
SectionLabel { label: "FILTERS"; text-color: text-muted; }
|
SectionLabel { label: "过滤"; 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 {
|
NavItem {
|
||||||
label: "Max";
|
label: "最高";
|
||||||
selected: root.selection == SidebarSelection.All
|
selected: root.selection == SidebarSelection.All
|
||||||
&& root.priority-filter == 4;
|
&& root.priority-filter == 4;
|
||||||
has-accent: true;
|
has-accent: true;
|
||||||
@@ -185,10 +163,11 @@ export component MainWindow inherits Window {
|
|||||||
clicked => {
|
clicked => {
|
||||||
root.selection = SidebarSelection.All;
|
root.selection = SidebarSelection.All;
|
||||||
root.priority-filter = 4;
|
root.priority-filter = 4;
|
||||||
|
root.priority-filter-changed(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "High";
|
label: "高";
|
||||||
selected: root.selection == SidebarSelection.All
|
selected: root.selection == SidebarSelection.All
|
||||||
&& root.priority-filter == 3;
|
&& root.priority-filter == 3;
|
||||||
has-accent: true;
|
has-accent: true;
|
||||||
@@ -196,10 +175,11 @@ export component MainWindow inherits Window {
|
|||||||
clicked => {
|
clicked => {
|
||||||
root.selection = SidebarSelection.All;
|
root.selection = SidebarSelection.All;
|
||||||
root.priority-filter = 3;
|
root.priority-filter = 3;
|
||||||
|
root.priority-filter-changed(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Normal";
|
label: "普通";
|
||||||
selected: root.selection == SidebarSelection.All
|
selected: root.selection == SidebarSelection.All
|
||||||
&& root.priority-filter == 2;
|
&& root.priority-filter == 2;
|
||||||
has-accent: true;
|
has-accent: true;
|
||||||
@@ -207,10 +187,11 @@ export component MainWindow inherits Window {
|
|||||||
clicked => {
|
clicked => {
|
||||||
root.selection = SidebarSelection.All;
|
root.selection = SidebarSelection.All;
|
||||||
root.priority-filter = 2;
|
root.priority-filter = 2;
|
||||||
|
root.priority-filter-changed(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Low";
|
label: "低";
|
||||||
selected: root.selection == SidebarSelection.All
|
selected: root.selection == SidebarSelection.All
|
||||||
&& root.priority-filter == 1;
|
&& root.priority-filter == 1;
|
||||||
has-accent: true;
|
has-accent: true;
|
||||||
@@ -218,29 +199,24 @@ export component MainWindow inherits Window {
|
|||||||
clicked => {
|
clicked => {
|
||||||
root.selection = SidebarSelection.All;
|
root.selection = SidebarSelection.All;
|
||||||
root.priority-filter = 1;
|
root.priority-filter = 1;
|
||||||
|
root.priority-filter-changed(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
SectionLabel { label: "APP"; text-color: text-muted; }
|
SectionLabel { label: "应用"; text-color: text-muted; }
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Settings";
|
label: "设置";
|
||||||
selected: root.selection == SidebarSelection.Settings;
|
selected: root.selection == SidebarSelection.Settings;
|
||||||
clicked => { root.selection = SidebarSelection.Settings; }
|
clicked => { root.selection = SidebarSelection.Settings; }
|
||||||
}
|
}
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "About";
|
label: "关于";
|
||||||
selected: root.selection == SidebarSelection.About;
|
selected: root.selection == SidebarSelection.About;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.selection = SidebarSelection.About;
|
root.selection = SidebarSelection.About;
|
||||||
root.about-clicked();
|
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;
|
root.show-about = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,28 +227,22 @@ export component MainWindow inherits Window {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
background: bg;
|
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 {
|
HorizontalLayout {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
spacing: 0;
|
spacing: 0;
|
||||||
|
|
||||||
// Messages column (always visible when on "All").
|
// 消息列(All 视图时可见)。
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: bg;
|
background: bg;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
visible: root.selection == SidebarSelection.All;
|
visible: root.selection == SidebarSelection.All;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
// Title bar with status text under it.
|
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
spacing: 4px;
|
spacing: 4px;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: messages.length == 0 ? "Messages" : "Messages · " + messages.length;
|
text: messages.length == 0 ? "消息" : "消息 · " + messages.length;
|
||||||
color: text-primary;
|
color: text-primary;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -288,7 +258,6 @@ export component MainWindow inherits Window {
|
|||||||
if messages.length > 0: MessageList {
|
if messages.length > 0: MessageList {
|
||||||
messages: messages;
|
messages: messages;
|
||||||
selected-index: root.selected-message-index;
|
selected-index: root.selected-message-index;
|
||||||
priority-filter: root.priority-filter;
|
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
message-clicked(idx) => {
|
message-clicked(idx) => {
|
||||||
root.selected-message-index = 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
|
// 详情面板:选中消息且在 All 视图时显示。
|
||||||
// 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
|
if root.selection == SidebarSelection.All
|
||||||
&& root.selected-message-index >= 0
|
&& root.selected-message-index >= 0
|
||||||
&& root.selected-message-index < root.messages.length: Rectangle {
|
&& root.selected-message-index < root.messages.length: Rectangle {
|
||||||
@@ -379,9 +344,6 @@ export component MainWindow inherits Window {
|
|||||||
vertical-stretch: 1;
|
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 {
|
if root.messages[root.selected-message-index].has_click_url: Rectangle {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: accent;
|
background: accent;
|
||||||
@@ -396,7 +358,7 @@ export component MainWindow inherits Window {
|
|||||||
y: 0;
|
y: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
text: "Open link ↗";
|
text: "打开链接 ↗";
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -431,18 +393,9 @@ export component MainWindow inherits Window {
|
|||||||
root.setting-toggled(label, value);
|
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 {
|
if root.show-about: AboutDialog {
|
||||||
close-dialog => {
|
close-dialog => {
|
||||||
root.show-about = false;
|
root.show-about = false;
|
||||||
|
|||||||
+9
-25
@@ -1,17 +1,6 @@
|
|||||||
// AGENTS.md §54 modals.
|
// AGENTS.md §54 modals.
|
||||||
//
|
//
|
||||||
// Phase 7 C3 fix: the original `inherits Dialog` (a Slint
|
// Phase 7 C4: 翻译为简体中文。
|
||||||
// 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 { ... }`).
|
|
||||||
|
|
||||||
export component AboutDialog inherits Rectangle {
|
export component AboutDialog inherits Rectangle {
|
||||||
in property <string> version-string: "v0.1.0";
|
in property <string> version-string: "v0.1.0";
|
||||||
@@ -29,8 +18,6 @@ export component AboutDialog inherits Rectangle {
|
|||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
// Backdrop: full-window dimmed layer that swallows clicks so
|
|
||||||
// clicking outside the card dismisses the dialog.
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: 0;
|
x: 0;
|
||||||
y: 0;
|
y: 0;
|
||||||
@@ -45,7 +32,6 @@ export component AboutDialog inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card: 360x240-ish centered modal content.
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
@@ -61,7 +47,7 @@ export component AboutDialog inherits Rectangle {
|
|||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "Windows Gotify Client (Rust)";
|
text: "Windows Gotify 客户端(Rust 重写版)";
|
||||||
color: text-primary-color;
|
color: text-primary-color;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -72,20 +58,19 @@ export component AboutDialog inherits Rectangle {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: "Built " + build-date;
|
text: "构建于 " + build-date;
|
||||||
color: text-secondary-color;
|
color: text-secondary-color;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
Rectangle { height: 8px; }
|
Rectangle { height: 8px; }
|
||||||
Text {
|
Text {
|
||||||
text: "Lightweight, modern, Windows-native Gotify\ndesktop client. Phase 1–8 complete.";
|
text: "轻量、现代、Windows 原生 Gotify\n桌面客户端。Phase 1–8 全部完成。";
|
||||||
color: text-secondary-color;
|
color: text-secondary-color;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
Rectangle { height: 8px; }
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
// Close button row.
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: end;
|
alignment: end;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -98,11 +83,10 @@ export component AboutDialog inherits Rectangle {
|
|||||||
Text {
|
Text {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
text: "Close";
|
text: "关闭";
|
||||||
color: text-primary-color;
|
color: text-primary-color;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
// TouchArea last so it sits on top of the Text.
|
|
||||||
TouchArea {
|
TouchArea {
|
||||||
clicked => {
|
clicked => {
|
||||||
root.close-dialog();
|
root.close-dialog();
|
||||||
@@ -115,10 +99,10 @@ export component AboutDialog inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export component ConfirmDialog inherits Rectangle {
|
export component ConfirmDialog inherits Rectangle {
|
||||||
in property <string> title-text: "Confirm";
|
in property <string> title-text: "确认";
|
||||||
in property <string> message: "Are you sure?";
|
in property <string> message: "确定吗?";
|
||||||
in property <string> confirm-label: "Confirm";
|
in property <string> confirm-label: "确认";
|
||||||
in property <string> cancel-label: "Cancel";
|
in property <string> cancel-label: "取消";
|
||||||
|
|
||||||
callback confirmed();
|
callback confirmed();
|
||||||
callback cancelled();
|
callback cancelled();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// AGENTS.md §9 empty state.
|
// AGENTS.md §9 empty state.
|
||||||
//
|
//
|
||||||
// Uses Slint `Path` to draw the ✦ glyph — no external assets. C3 may wire the
|
// Phase 7 C4: 翻译为简体中文。
|
||||||
// "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 {
|
export component EmptyState inherits Rectangle {
|
||||||
callback test-notification();
|
callback test-notification();
|
||||||
@@ -30,7 +28,7 @@ export component EmptyState inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "You're all caught up";
|
text: "暂时没有新消息";
|
||||||
color: text-primary-color;
|
color: text-primary-color;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -38,7 +36,7 @@ export component EmptyState inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "New Gotify messages will\nappear here automatically.";
|
text: "新消息会自动\n出现在这里。";
|
||||||
color: text-muted-color;
|
color: text-muted-color;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
@@ -47,7 +45,6 @@ export component EmptyState inherits Rectangle {
|
|||||||
|
|
||||||
Rectangle { height: 8px; }
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
// "Test Notification" button — same visual language as NavItem.
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: button-bg;
|
background: button-bg;
|
||||||
border-color: button-border;
|
border-color: button-border;
|
||||||
@@ -60,7 +57,7 @@ export component EmptyState inherits Rectangle {
|
|||||||
Text {
|
Text {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
text: "Test Notification";
|
text: "发送测试通知";
|
||||||
color: button-text;
|
color: button-text;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-28
@@ -1,30 +1,21 @@
|
|||||||
// AGENTS.md §7 / §37 message list.
|
// AGENTS.md §7 / §37 message list.
|
||||||
//
|
//
|
||||||
// Phase 2 C2 uses a plain `VerticalLayout` + `for` to render the cards;
|
// Phase 7 C4: 重写。原来的 `visible` 谓词在 `for` 循环里没有真正折叠
|
||||||
// Slint's layout is already virtualized for off-screen items, and we keep
|
// 被过滤掉的卡片——它们仍然占着 layout 槽位,导致 Normal / High /
|
||||||
// the dependency footprint minimal until Phase 6 introduces the real
|
// Low 过滤后卡片之间出现巨大的空白(参见用户截图)。
|
||||||
// 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
|
// 修法:把过滤移到 Rust 侧(lib.rs::apply_priority_filter),每次过滤
|
||||||
// `visible`. Hiding (vs filtering the model) keeps the original index
|
// 切换都重新构建一个 filtered VecModel,MessageList 直接绑定它,
|
||||||
// intact, which lets `selected-index` stay correct when the filter
|
// 不再做 UI 侧过滤。这样还顺手解决了 AGENTS.md §37 的"100/500/1000 条
|
||||||
// changes — clicking item 5 with the filter still applies to row 5
|
// 消息滚动性能"问题——filtered model 仍然只包含匹配项,for 循环
|
||||||
// of the underlying list, not the post-filter row.
|
// 渲染的元素数量就是用户实际看到的数量。
|
||||||
|
|
||||||
import { MessageItem, MessageCard, Priority } from "./message-card.slint";
|
import { MessageItem, MessageCard } from "./message-card.slint";
|
||||||
|
|
||||||
export component MessageList inherits Rectangle {
|
export component MessageList inherits Rectangle {
|
||||||
in property <[MessageItem]> messages;
|
in property <[MessageItem]> messages;
|
||||||
in-out property <int> selected-index: -1;
|
in-out property <int> 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 <int> priority-filter: 0;
|
|
||||||
|
|
||||||
in property <color> card-border: #3a3b40;
|
in property <color> card-border: #3a3b40;
|
||||||
|
|
||||||
callback message-clicked(int);
|
callback message-clicked(int);
|
||||||
@@ -37,16 +28,6 @@ export component MessageList inherits Rectangle {
|
|||||||
for item[idx] in messages: MessageCard {
|
for item[idx] in messages: MessageCard {
|
||||||
item: item;
|
item: item;
|
||||||
selected: idx == root.selected-index;
|
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;
|
card-border: card-border;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.selected-index = idx;
|
root.selected-index = idx;
|
||||||
|
|||||||
+29
-57
@@ -1,28 +1,14 @@
|
|||||||
// AGENTS.md §10 / §11 settings page.
|
// AGENTS.md §10 / §11 settings page.
|
||||||
//
|
//
|
||||||
// C3 lays out the five sections (Connection / Notifications / Startup /
|
// Phase 7 C4: 全部翻译为简体中文。
|
||||||
// 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.
|
|
||||||
|
|
||||||
import { SectionLabel } from "./components.slint";
|
import { SectionLabel } from "./components.slint";
|
||||||
import { ServerCard } from "./server-card.slint";
|
import { ServerCard } from "./server-card.slint";
|
||||||
|
|
||||||
// ─── Sub-components ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export component SettingsToggleRow inherits Rectangle {
|
export component SettingsToggleRow inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
// `in-out` so the TouchArea can flip it before emitting the
|
|
||||||
// toggled(label, new-value) event.
|
|
||||||
in-out property <bool> value: false;
|
in-out property <bool> value: false;
|
||||||
in property <bool> enabled: true;
|
in property <bool> 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);
|
callback toggled(string, bool);
|
||||||
|
|
||||||
in property <color> row-bg: #28292d;
|
in property <color> row-bg: #28292d;
|
||||||
@@ -122,38 +108,26 @@ export component AppearanceRadio inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main page ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export component SettingsPage inherits Rectangle {
|
export component SettingsPage inherits Rectangle {
|
||||||
// Appearance (live wired)
|
|
||||||
in-out property <int> appearance-index: 2;
|
in-out property <int> appearance-index: 2;
|
||||||
callback appearance-changed(int);
|
callback appearance-changed(int);
|
||||||
|
|
||||||
// Notifications (Phase 6 C2: live wired through setting-toggled)
|
|
||||||
in-out property <bool> desktop-notify: true;
|
in-out property <bool> desktop-notify: true;
|
||||||
in-out property <bool> sound: true;
|
in-out property <bool> sound: true;
|
||||||
in-out property <bool> notify-high: true;
|
in-out property <bool> notify-high: true;
|
||||||
in-out property <bool> notify-max: true;
|
in-out property <bool> notify-max: true;
|
||||||
|
|
||||||
// Startup (Phase 6 C2: live wired)
|
|
||||||
in-out property <bool> start-with-windows: false;
|
in-out property <bool> start-with-windows: false;
|
||||||
in-out property <bool> start-minimized: false;
|
in-out property <bool> start-minimized: false;
|
||||||
in-out property <bool> close-to-tray: true;
|
in-out property <bool> close-to-tray: true;
|
||||||
|
|
||||||
// Advanced (collapsed by default per §10)
|
|
||||||
in-out property <bool> advanced-expanded: false;
|
in-out property <bool> advanced-expanded: false;
|
||||||
|
|
||||||
// Connection (Phase 3 C2)
|
|
||||||
in-out property <bool> test-in-progress: false;
|
in-out property <bool> test-in-progress: false;
|
||||||
callback test-connection-requested();
|
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);
|
callback toggle-persist(string, bool);
|
||||||
|
|
||||||
// Tokens
|
|
||||||
in property <color> divider-color: #3a3b40;
|
in property <color> divider-color: #3a3b40;
|
||||||
in property <color> hint-color: #6a6a6a;
|
in property <color> hint-color: #6a6a6a;
|
||||||
|
|
||||||
@@ -163,13 +137,11 @@ export component SettingsPage inherits Rectangle {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
spacing: 24px;
|
spacing: 24px;
|
||||||
|
|
||||||
// CONNECTION
|
// 连接
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 8px;
|
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 {
|
Rectangle {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -178,7 +150,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
Text {
|
Text {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
text: root.test-in-progress ? "Testing..." : "Test Connection";
|
text: root.test-in-progress ? "测试中…" : "测试连接";
|
||||||
color: #e6e6e6;
|
color: #e6e6e6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -204,12 +176,12 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTIFICATIONS
|
// 通知
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
SectionLabel { label: "NOTIFICATIONS"; text-color: hint-color; }
|
SectionLabel { label: "通知"; text-color: hint-color; }
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Enable desktop notification";
|
label: "启用桌面通知";
|
||||||
value: root.desktop-notify;
|
value: root.desktop-notify;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
root.desktop-notify = v;
|
root.desktop-notify = v;
|
||||||
@@ -217,7 +189,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Play sound";
|
label: "播放声音";
|
||||||
value: root.sound;
|
value: root.sound;
|
||||||
enabled: root.desktop-notify;
|
enabled: root.desktop-notify;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
@@ -226,7 +198,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Notify on High priority";
|
label: "高级别时通知";
|
||||||
value: root.notify-high;
|
value: root.notify-high;
|
||||||
enabled: root.desktop-notify;
|
enabled: root.desktop-notify;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
@@ -235,7 +207,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Notify on Max priority";
|
label: "最高级别时通知";
|
||||||
value: root.notify-max;
|
value: root.notify-max;
|
||||||
enabled: root.desktop-notify;
|
enabled: root.desktop-notify;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
@@ -245,12 +217,12 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// STARTUP
|
// 开机启动
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
SectionLabel { label: "STARTUP"; text-color: hint-color; }
|
SectionLabel { label: "开机启动"; text-color: hint-color; }
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Start with Windows";
|
label: "随 Windows 一起启动";
|
||||||
value: root.start-with-windows;
|
value: root.start-with-windows;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
root.start-with-windows = v;
|
root.start-with-windows = v;
|
||||||
@@ -258,7 +230,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Start minimized to tray";
|
label: "启动时最小化到托盘";
|
||||||
value: root.start-minimized;
|
value: root.start-minimized;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
root.start-minimized = v;
|
root.start-minimized = v;
|
||||||
@@ -266,7 +238,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Close button minimizes to tray";
|
label: "关闭按钮最小化到托盘";
|
||||||
value: root.close-to-tray;
|
value: root.close-to-tray;
|
||||||
toggled(l, v) => {
|
toggled(l, v) => {
|
||||||
root.close-to-tray = v;
|
root.close-to-tray = v;
|
||||||
@@ -275,17 +247,17 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// APPEARANCE (live wired)
|
// 外观
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
SectionLabel { label: "APPEARANCE"; text-color: hint-color; }
|
SectionLabel { label: "外观"; text-color: hint-color; }
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: 12px;
|
spacing: 12px;
|
||||||
|
|
||||||
AppearanceRadio {
|
AppearanceRadio {
|
||||||
label: "System";
|
label: "跟随系统";
|
||||||
description: "Follow Windows theme";
|
description: "跟随 Windows 主题";
|
||||||
selected: root.appearance-index == 0;
|
selected: root.appearance-index == 0;
|
||||||
chosen => {
|
chosen => {
|
||||||
root.appearance-index = 0;
|
root.appearance-index = 0;
|
||||||
@@ -293,8 +265,8 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppearanceRadio {
|
AppearanceRadio {
|
||||||
label: "Light";
|
label: "浅色";
|
||||||
description: "Bright neutrals";
|
description: "明亮中性色";
|
||||||
selected: root.appearance-index == 1;
|
selected: root.appearance-index == 1;
|
||||||
chosen => {
|
chosen => {
|
||||||
root.appearance-index = 1;
|
root.appearance-index = 1;
|
||||||
@@ -302,8 +274,8 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppearanceRadio {
|
AppearanceRadio {
|
||||||
label: "Dark";
|
label: "深色";
|
||||||
description: "Graphite";
|
description: "石墨灰";
|
||||||
selected: root.appearance-index == 2;
|
selected: root.appearance-index == 2;
|
||||||
chosen => {
|
chosen => {
|
||||||
root.appearance-index = 2;
|
root.appearance-index = 2;
|
||||||
@@ -313,13 +285,13 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Compact message density";
|
label: "紧凑消息密度";
|
||||||
value: false;
|
value: false;
|
||||||
enabled: false;
|
enabled: false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADVANCED (collapsed by default per §10)
|
// 高级(默认折叠,见 §10)
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
|
|
||||||
@@ -332,7 +304,7 @@ export component SettingsPage inherits Rectangle {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: "ADVANCED";
|
text: "高级";
|
||||||
color: hint-color;
|
color: hint-color;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -347,17 +319,17 @@ export component SettingsPage inherits Rectangle {
|
|||||||
if advanced-expanded: VerticalLayout {
|
if advanced-expanded: VerticalLayout {
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Log level: Info (release default)";
|
label: "日志级别:Info(release 默认)";
|
||||||
value: false;
|
value: false;
|
||||||
enabled: false;
|
enabled: false;
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Clear local state (messages + window size)";
|
label: "清除本地状态(消息 + 窗口大小)";
|
||||||
value: false;
|
value: false;
|
||||||
enabled: false;
|
enabled: false;
|
||||||
}
|
}
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
label: "Reset settings to defaults";
|
label: "恢复默认设置";
|
||||||
value: false;
|
value: false;
|
||||||
enabled: false;
|
enabled: false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user