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:
co-authored by
Claude Opus 5.5
parent
687642a096
commit
33d8871ac8
+1
-1
Submodule cinny updated: 6edfe70020...25fa0f6ef9
+146
-20
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user