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>,
|
base_rgba: Vec<u8>,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: 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`
|
/// 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)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint a small white-ringed red "unread" dot into the bottom-right corner of
|
/// Paint a small white-ringed dot of `color` into the bottom-right (or, with
|
||||||
/// an RGBA buffer, in place. Cross-platform (operates on raw pixels).
|
/// `left`, bottom-left) corner of an RGBA buffer, in place. Cross-platform
|
||||||
fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) {
|
/// (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 w = width as i32;
|
||||||
let h = height as i32;
|
let h = height as i32;
|
||||||
if w <= 0 || h <= 0 {
|
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 r = ((w.min(h) as f32) * 0.30) as i32;
|
||||||
let ring = ((r as f32) * 0.18).max(1.0) 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;
|
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 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) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if dist2 <= r * r {
|
if dist2 <= r * r {
|
||||||
rgba[idx] = 0xDD;
|
rgba[idx] = color[0];
|
||||||
rgba[idx + 1] = 0x30;
|
rgba[idx + 1] = color[1];
|
||||||
rgba[idx + 2] = 0x30;
|
rgba[idx + 2] = color[2];
|
||||||
rgba[idx + 3] = 0xFF;
|
rgba[idx + 3] = 0xFF;
|
||||||
} else if dist2 <= (r + ring) * (r + ring) {
|
} else if dist2 <= (r + ring) * (r + ring) {
|
||||||
rgba[idx] = 0xFF;
|
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.
|
/// Overlay (or clear) the unread dot on the tray icon.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_tray_unread(unread: bool, state: tauri::State<'_, TrayUnreadState>) -> Result<(), String> {
|
fn set_tray_unread(unread: bool, state: tauri::State<'_, TrayUnreadState>) -> Result<(), String> {
|
||||||
let mut rgba = state.base_rgba.clone();
|
state.indicators.lock().map_err(|e| e.to_string())?.unread = unread;
|
||||||
if unread {
|
refresh_tray(&state)
|
||||||
draw_unread_dot(&mut rgba, state.width, state.height);
|
}
|
||||||
|
|
||||||
|
/// 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);
|
refresh_tray(&state)
|
||||||
state.tray.set_icon(Some(icon)).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// cinny-desktop #6: the tray's "Restart to update" item. Inserted at the top
|
/// 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 {
|
let Some(state) = app.try_state::<TrayUpdateState>() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let unread = app.try_state::<TrayUnreadState>();
|
|
||||||
let mut shown = state.shown.lock().map_err(|e| e.to_string())?;
|
let mut shown = state.shown.lock().map_err(|e| e.to_string())?;
|
||||||
match version {
|
match version.clone() {
|
||||||
Some(version) => {
|
Some(version) => {
|
||||||
state
|
state
|
||||||
.item
|
.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())?;
|
state.menu.insert(&state.item, 0).map_err(|e| e.to_string())?;
|
||||||
*shown = true;
|
*shown = true;
|
||||||
}
|
}
|
||||||
if let Some(u) = unread {
|
|
||||||
let _ = u.tray.set_tooltip(Some("Lotus Chat — update ready"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if *shown {
|
if *shown {
|
||||||
state.menu.remove(&state.item).map_err(|e| e.to_string())?;
|
state.menu.remove(&state.item).map_err(|e| e.to_string())?;
|
||||||
*shown = false;
|
*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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +749,7 @@ pub fn run() {
|
|||||||
set_tray_unread,
|
set_tray_unread,
|
||||||
get_tray_dnd,
|
get_tray_dnd,
|
||||||
set_tray_update_ready,
|
set_tray_update_ready,
|
||||||
|
set_tray_call_state,
|
||||||
flash_window,
|
flash_window,
|
||||||
focus_main_window,
|
focus_main_window,
|
||||||
send_notification,
|
send_notification,
|
||||||
@@ -788,6 +879,7 @@ pub fn run() {
|
|||||||
base_rgba,
|
base_rgba,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
indicators: std::sync::Mutex::new(TrayIndicators::default()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep a handle to the DND CheckMenuItem so `get_tray_dnd` can
|
// Keep a handle to the DND CheckMenuItem so `get_tray_dnd` can
|
||||||
@@ -986,3 +1078,37 @@ pub fn run() {
|
|||||||
.run(context)
|
.run(context)
|
||||||
.expect("error while building tauri application");
|
.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