feat(tray): show call state on the tray icon (#4)

`set_tray_call_state({active, muted, deafened})` adds a small ringed dot
to the tray icon's bottom-left: green in a call, amber muted, red
deafened (deafened implies muted). The unread dot stays bottom-right.
The tooltip spells it out ("Lotus Chat — in call, muted · update ready").

Unread, call state and the pending update (#6) now live in one
`TrayIndicators` and re-render together, so none of them overwrites
another. The unread painter is generalised to `draw_dot(left, color)`.
Unit tests cover the tooltip text and dot placement.

Bump cinny to 25fa0f6e (the web half, plus the stale-state fix that
removed a "muted + deafened" flash at every join).

Closes #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-23 21:54:04 -04:00
co-authored by Claude Opus 5.5
parent 687642a096
commit 33d8871ac8
2 changed files with 147 additions and 21 deletions
+1 -1
Submodule cinny updated: 6edfe70020...25fa0f6ef9
+146 -20
View File
@@ -498,6 +498,71 @@ struct TrayUnreadState {
base_rgba: Vec<u8>,
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<TrayIndicators>,
}
/// 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<String>,
}
/// "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<String>) -> Resu
let Some(state) = app.try_state::<TrayUpdateState>() else {
return Ok(());
};
let unread = app.try_state::<TrayUnreadState>();
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<String>) -> 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::<TrayUnreadState>() {
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]);
}
}