From b8f0d7d49834bec2d3e2b538178f8a1900c85481 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 13 Jun 2026 23:53:52 -0400 Subject: [PATCH] feat(desktop): tray unread overlay + taskbar flash - set_tray_unread command re-renders the tray icon with a red unread dot composited onto the base icon (handle + base pixels kept in managed state). - flash_window command flashes the taskbar via request_user_attention. - Register both commands; bump cinny submodule to 107921e0 (web wiring). Co-Authored-By: Claude Fable 5 --- cinny | 2 +- src-tauri/src/lib.rs | 85 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/cinny b/cinny index 053b364..107921e 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 053b364a4414e6077cdd9b92e10b0a01a555636b +Subproject commit 107921e0d025106a5545108e49062bbeb88f9669 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 20b593e..0efaa5e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -272,6 +272,71 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { Ok(()) } +/// Held in managed state so the tray's unread overlay can be updated at runtime. +/// Keeping the TrayIcon handle here also keeps the tray alive. +struct TrayUnreadState { + tray: tauri::tray::TrayIcon, + base_rgba: Vec, + width: u32, + height: u32, +} + +/// Paint a small white-ringed red "unread" dot into the bottom-right corner of +/// an RGBA buffer, in place. Cross-platform (operates on raw pixels). +fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) { + let w = width as i32; + let h = height as i32; + if w <= 0 || h <= 0 { + return; + } + let r = ((w.min(h) as f32) * 0.30) as i32; + let ring = ((r as f32) * 0.18).max(1.0) as i32; + let cx = w - r - ((w as f32) * 0.06) as i32; + let cy = h - r - ((h as f32) * 0.06) as i32; + for y in (cy - r - ring).max(0)..(cy + r + ring).min(h) { + for x in (cx - r - ring).max(0)..(cx + r + ring).min(w) { + let dx = x - cx; + let dy = y - cy; + let dist2 = dx * dx + dy * dy; + let idx = ((y * w + x) as usize) * 4; + if idx + 3 >= rgba.len() { + continue; + } + if dist2 <= r * r { + rgba[idx] = 0xDD; + rgba[idx + 1] = 0x30; + rgba[idx + 2] = 0x30; + rgba[idx + 3] = 0xFF; + } else if dist2 <= (r + ring) * (r + ring) { + rgba[idx] = 0xFF; + rgba[idx + 1] = 0xFF; + rgba[idx + 2] = 0xFF; + rgba[idx + 3] = 0xFF; + } + } + } +} + +/// Overlay (or clear) the unread dot on the tray icon. +#[tauri::command] +fn set_tray_unread(unread: bool, state: tauri::State<'_, TrayUnreadState>) -> Result<(), String> { + let mut rgba = state.base_rgba.clone(); + if unread { + draw_unread_dot(&mut rgba, state.width, state.height); + } + let icon = tauri::image::Image::new_owned(rgba, state.width, state.height); + state.tray.set_icon(Some(icon)).map_err(|e| e.to_string()) +} + +/// Flash the taskbar button to draw attention (e.g. a new mention while the +/// window is unfocused). Clears automatically once the window is focused. +#[tauri::command] +fn flash_window(window: tauri::Window) -> Result<(), String> { + window + .request_user_attention(Some(tauri::UserAttentionType::Informational)) + .map_err(|e| e.to_string()) +} + pub fn run() { let port: u16 = 44548; let context = tauri::generate_context!(); @@ -295,6 +360,8 @@ pub fn run() { builder = builder .invoke_handler(tauri::generate_handler![ set_badge_count, + set_tray_unread, + flash_window, send_notification, check_for_update, install_update, @@ -314,12 +381,13 @@ pub fn run() { .setup(move |app| { // --- System tray: keeps Lotus Chat running in the background so // notifications keep arriving after the window is closed-to-tray. --- + let base_icon = app.default_window_icon().cloned(); let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?; let separator = PredefinedMenuItem::separator(app)?; let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?; - let _tray = TrayIconBuilder::with_id("main-tray") - .icon(app.default_window_icon().cloned().unwrap()) + let tray = TrayIconBuilder::with_id("main-tray") + .icon(base_icon.clone().expect("bundled window icon")) .tooltip("Lotus Chat") .menu(&tray_menu) .show_menu_on_left_click(false) @@ -347,6 +415,19 @@ pub fn run() { }) .build(app)?; + // Keep the tray handle (and base icon pixels) in managed state so + // set_tray_unread can re-render the icon at runtime. + if let Some(img) = base_icon { + let base_rgba = img.rgba().to_vec(); + let (width, height) = (img.width(), img.height()); + app.manage(TrayUnreadState { + tray, + base_rgba, + width, + height, + }); + } + #[cfg(debug_assertions)] let window_url = WebviewUrl::App(Default::default());