diff --git a/cinny b/cinny index 6edfe70..25fa0f6 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 6edfe700204a626d97c62ae2b1c3d0faca49933c +Subproject commit 25fa0f6ef9f4beed98d0b8dcb244523ac99077a9 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 22723ed..c88e0ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -498,6 +498,71 @@ struct TrayUnreadState { base_rgba: Vec, width: u32, height: u32, + /// Everything the icon + tooltip reflect, re-rendered together so the + /// unread dot, call state (#4) and pending update (#6) never clobber + /// each other. + indicators: std::sync::Mutex, +} + +/// cinny-desktop #4: the call state shown on the tray icon. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +enum TrayCall { + #[default] + None, + InCall, + Muted, + Deafened, +} + +#[derive(Default)] +struct TrayIndicators { + unread: bool, + call: TrayCall, + update: Option, +} + +/// "Lotus Chat — in call, muted · update ready" +fn tray_tooltip(ind: &TrayIndicators) -> String { + let mut parts: Vec<&str> = Vec::new(); + match ind.call { + TrayCall::None => {} + TrayCall::InCall => parts.push("in call"), + TrayCall::Muted => parts.push("in call, muted"), + TrayCall::Deafened => parts.push("in call, deafened"), + } + if ind.update.is_some() { + parts.push("update ready"); + } + if parts.is_empty() { + "Lotus Chat".to_string() + } else { + format!("Lotus Chat — {}", parts.join(" · ")) + } +} + +/// Re-render the tray icon (base + call dot bottom-left + unread dot +/// bottom-right) and tooltip from the current indicators. +fn refresh_tray(state: &TrayUnreadState) -> Result<(), String> { + let ind = state.indicators.lock().map_err(|e| e.to_string())?; + let mut rgba = state.base_rgba.clone(); + let call_color = match ind.call { + TrayCall::None => None, + TrayCall::InCall => Some([0x2E, 0xB8, 0x5C]), + TrayCall::Muted => Some([0xF0, 0xA0, 0x20]), + TrayCall::Deafened => Some([0xD0, 0x30, 0x30]), + }; + if let Some(color) = call_color { + draw_dot(&mut rgba, state.width, state.height, true, color); + } + if ind.unread { + draw_dot(&mut rgba, state.width, state.height, false, [0xDD, 0x30, 0x30]); + } + let icon = tauri::image::Image::new_owned(rgba, state.width, state.height); + state.tray.set_icon(Some(icon)).map_err(|e| e.to_string())?; + state + .tray + .set_tooltip(Some(tray_tooltip(&ind))) + .map_err(|e| e.to_string()) } /// Holds a clone of the tray "Do Not Disturb" `CheckMenuItem` so `get_tray_dnd` @@ -517,9 +582,10 @@ fn get_tray_dnd(app: tauri::AppHandle) -> bool { .unwrap_or(false) } -/// 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) { +/// Paint a small white-ringed dot of `color` into the bottom-right (or, with +/// `left`, bottom-left) corner of an RGBA buffer, in place. Cross-platform +/// (operates on raw pixels). +fn draw_dot(rgba: &mut [u8], width: u32, height: u32, left: bool, color: [u8; 3]) { let w = width as i32; let h = height as i32; if w <= 0 || h <= 0 { @@ -527,7 +593,8 @@ fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) { } 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 inset = ((w as f32) * 0.06) as i32; + let cx = if left { r + inset } else { w - r - inset }; 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) { @@ -539,9 +606,9 @@ fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) { continue; } if dist2 <= r * r { - rgba[idx] = 0xDD; - rgba[idx + 1] = 0x30; - rgba[idx + 2] = 0x30; + rgba[idx] = color[0]; + rgba[idx + 1] = color[1]; + rgba[idx + 2] = color[2]; rgba[idx + 3] = 0xFF; } else if dist2 <= (r + ring) * (r + ring) { rgba[idx] = 0xFF; @@ -556,12 +623,37 @@ fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) { /// 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); + state.indicators.lock().map_err(|e| e.to_string())?.unread = unread; + refresh_tray(&state) +} + +/// cinny-desktop #4: reflect the call state on the tray icon + tooltip. Sent +/// from the same web hook as the taskbar thumbbar so the two can't drift. +/// Deafened implies muted. +#[tauri::command] +fn set_tray_call_state( + active: bool, + muted: bool, + deafened: bool, + state: tauri::State<'_, TrayUnreadState>, +) -> Result<(), String> { + let call = if !active { + TrayCall::None + } else if deafened { + TrayCall::Deafened + } else if muted { + TrayCall::Muted + } else { + TrayCall::InCall + }; + { + let mut ind = state.indicators.lock().map_err(|e| e.to_string())?; + if ind.call == call { + return Ok(()); + } + ind.call = call; } - let icon = tauri::image::Image::new_owned(rgba, state.width, state.height); - state.tray.set_icon(Some(icon)).map_err(|e| e.to_string()) + refresh_tray(&state) } /// cinny-desktop #6: the tray's "Restart to update" item. Inserted at the top @@ -582,9 +674,8 @@ fn set_tray_update_ready(app: tauri::AppHandle, version: Option) -> Resu let Some(state) = app.try_state::() else { return Ok(()); }; - let unread = app.try_state::(); let mut shown = state.shown.lock().map_err(|e| e.to_string())?; - match version { + match version.clone() { Some(version) => { state .item @@ -594,20 +685,19 @@ fn set_tray_update_ready(app: tauri::AppHandle, version: Option) -> Resu state.menu.insert(&state.item, 0).map_err(|e| e.to_string())?; *shown = true; } - if let Some(u) = unread { - let _ = u.tray.set_tooltip(Some("Lotus Chat — update ready")); - } } None => { if *shown { state.menu.remove(&state.item).map_err(|e| e.to_string())?; *shown = false; } - if let Some(u) = unread { - let _ = u.tray.set_tooltip(Some("Lotus Chat")); - } } } + drop(shown); + if let Some(tray) = app.try_state::() { + tray.indicators.lock().map_err(|e| e.to_string())?.update = version; + refresh_tray(&tray)?; + } Ok(()) } @@ -659,6 +749,7 @@ pub fn run() { set_tray_unread, get_tray_dnd, set_tray_update_ready, + set_tray_call_state, flash_window, focus_main_window, send_notification, @@ -788,6 +879,7 @@ pub fn run() { base_rgba, width, height, + indicators: std::sync::Mutex::new(TrayIndicators::default()), }); // Keep a handle to the DND CheckMenuItem so `get_tray_dnd` can @@ -986,3 +1078,37 @@ pub fn run() { .run(context) .expect("error while building tauri application"); } + +#[cfg(test)] +mod tray_tests { + use super::*; + + #[test] + fn tooltip_reflects_call_and_update() { + let mut ind = TrayIndicators::default(); + assert_eq!(tray_tooltip(&ind), "Lotus Chat"); + ind.call = TrayCall::Muted; + assert_eq!(tray_tooltip(&ind), "Lotus Chat — in call, muted"); + ind.update = Some("4.13.0".into()); + assert_eq!(tray_tooltip(&ind), "Lotus Chat — in call, muted · update ready"); + ind.call = TrayCall::None; + assert_eq!(tray_tooltip(&ind), "Lotus Chat — update ready"); + } + + #[test] + fn dots_land_in_opposite_bottom_corners() { + let (w, h) = (32u32, 32u32); + let px = |rgba: &[u8], x: u32, y: u32| { + let i = ((y * w + x) * 4) as usize; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] + }; + let mut rgba = vec![0u8; (w * h * 4) as usize]; + draw_dot(&mut rgba, w, h, true, [1, 2, 3]); + draw_dot(&mut rgba, w, h, false, [4, 5, 6]); + // Dot centres: r = 9, inset = 1 → left (10, 21), right (21, 21). + assert_eq!(px(&rgba, 10, 21), [1, 2, 3, 0xFF]); + assert_eq!(px(&rgba, 21, 21), [4, 5, 6, 0xFF]); + // Top-left stays untouched. + assert_eq!(px(&rgba, 2, 2), [0, 0, 0, 0]); + } +}