use std::collections::HashSet;
use crate::render::{Bounds, RenderContext};
use super::{Command, Msg, Widget, WidgetStyle, draw_centered};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrayStatus {
Passive,
Active,
NeedsAttention,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrayMenuMode {
#[default]
None,
Secondary,
Primary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrayMenu {
pub key: String,
pub revision: u32,
pub items: Vec<TrayMenuItem>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrayMenuItem {
pub id: i32,
pub label: String,
pub enabled: bool,
pub visible: bool,
pub separator: bool,
pub toggle: Option<TrayMenuToggle>,
pub children: Vec<TrayMenuItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrayMenuToggle {
pub kind: TrayMenuToggleKind,
pub state: TrayMenuToggleState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrayMenuToggleKind {
Checkmark,
Radio,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrayMenuToggleState {
Off,
On,
Indeterminate,
}
impl TrayStatus {
pub fn from_sni(status: &str) -> TrayStatus {
match status {
"Active" => TrayStatus::Active,
"NeedsAttention" => TrayStatus::NeedsAttention,
_ => TrayStatus::Passive,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrayIcon {
width: u32,
height: u32,
rgba: Vec<u8>,
}
impl TrayIcon {
pub fn from_argb32(width: u32, height: u32, argb: &[u8]) -> Option<TrayIcon> {
let pixels = (width as usize).checked_mul(height as usize)?;
let needed = pixels.checked_mul(4)?;
if width == 0 || height == 0 || argb.len() < needed {
return None;
}
let mut rgba = Vec::with_capacity(needed);
for px in argb[..needed].chunks_exact(4) {
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
rgba.extend_from_slice(&premultiply(r, g, b, a));
}
Some(TrayIcon {
width,
height,
rgba,
})
}
pub fn from_png_bytes(bytes: &[u8]) -> Result<TrayIcon, IconError> {
let image = image::load_from_memory_with_format(bytes, image::ImageFormat::Png)
.map_err(|e| IconError(e.to_string()))?
.into_rgba8();
let (width, height) = image.dimensions();
let mut rgba = Vec::with_capacity(image.as_raw().len());
for px in image.as_raw().chunks_exact(4) {
rgba.extend_from_slice(&premultiply(px[0], px[1], px[2], px[3]));
}
Ok(TrayIcon {
width,
height,
rgba,
})
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
}
fn premultiply(r: u8, g: u8, b: u8, a: u8) -> [u8; 4] {
let scale = |c: u8| ((c as u16 * a as u16 + 127) / 255) as u8;
[scale(r), scale(g), scale(b), a]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IconError(String);
impl std::fmt::Display for IconError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "failed to decode tray icon: {}", self.0)
}
}
impl std::error::Error for IconError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrayItem {
key: String,
title: String,
status: TrayStatus,
icon: Option<TrayIcon>,
menu_mode: TrayMenuMode,
}
impl TrayItem {
pub fn new(
key: impl Into<String>,
title: impl Into<String>,
status: TrayStatus,
icon: Option<TrayIcon>,
) -> Self {
Self {
key: key.into().trim().to_string(),
title: title.into().trim().to_string(),
status,
icon,
menu_mode: TrayMenuMode::None,
}
}
pub fn with_menu(mut self, mode: TrayMenuMode) -> Self {
self.menu_mode = mode;
self
}
pub fn menu_mode(&self) -> TrayMenuMode {
self.menu_mode
}
pub fn key(&self) -> &str {
&self.key
}
pub fn title(&self) -> &str {
&self.title
}
pub fn status(&self) -> TrayStatus {
self.status
}
pub fn icon(&self) -> Option<&TrayIcon> {
self.icon.as_ref()
}
pub fn fallback_label(&self) -> String {
self.title
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TrayState {
items: Vec<TrayItem>,
}
impl TrayState {
pub fn new(items: impl IntoIterator<Item = TrayItem>) -> Self {
let mut items: Vec<TrayItem> = items.into_iter().collect();
let mut seen = HashSet::new();
items.retain(|item| seen.insert(item.key.clone()));
items.sort_by(|a, b| a.key.cmp(&b.key));
Self { items }
}
pub fn items(&self) -> &[TrayItem] {
&self.items
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
pub struct TrayWidget {
bounds: Bounds,
state: Option<TrayState>,
style: WidgetStyle,
}
impl TrayWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn len(&self) -> usize {
self.state.as_ref().map(TrayState::len).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn item_cells(&self) -> Vec<(&TrayItem, Bounds)> {
let Some(state) = &self.state else {
return Vec::new();
};
let side = self.bounds.height;
if self.bounds.width == 0 || side == 0 {
return Vec::new();
}
let right = self.bounds.x + self.bounds.width;
let mut cells = Vec::new();
for (i, item) in state.items().iter().enumerate() {
let x = self.bounds.x + side * i as u32;
if x >= right {
break;
}
let width = side.min(right - x);
cells.push((
item,
Bounds::new(x, self.bounds.y, width, self.bounds.height),
));
}
cells
}
}
impl Widget for TrayWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::Tray(next) => {
let unchanged = match &self.state {
Some(current) => current == next,
None => next.is_empty(),
};
if unchanged {
return false;
}
self.state = Some(next.clone());
true
}
_ => false,
}
}
fn measure(&self, _ctx: &mut RenderContext, height: u32) -> u32 {
self.len() as u32 * height
}
fn draw(&self, ctx: &mut RenderContext) {
let scale = ctx.scale_factor();
let radius = (self.style.radius * scale) as f32;
for (item, cell) in self.item_cells() {
let needs_attention = item.status() == TrayStatus::NeedsAttention;
let pill = if needs_attention {
self.style.attention.background
} else {
self.style.background
};
if let Some(bg) = pill {
ctx.fill_rounded_rect(cell, bg, radius);
}
let requested_inset = self.style.padding * scale;
let max_inset = cell.width.min(cell.height).saturating_sub(1) / 2;
let inset = requested_inset.min(max_inset);
let content = Bounds::new(
cell.x + inset,
cell.y + inset,
cell.width - 2 * inset,
cell.height - 2 * inset,
);
match item.icon() {
Some(icon) => ctx.draw_icon(icon.rgba(), icon.width(), icon.height(), content),
None => {
let color = if needs_attention {
self.style.attention.foreground
} else {
self.style.foreground
};
draw_centered(ctx, &item.fallback_label(), content, color);
}
}
if let Some(border) = self.style.border {
ctx.stroke_rounded_rect(
cell,
border,
radius,
(self.style.border_width * scale) as f32,
);
}
}
}
fn bounds(&self) -> Bounds {
self.bounds
}
fn set_bounds(&mut self, bounds: Bounds) {
self.bounds = bounds;
}
fn on_click(&self, px: u32, py: u32, button: super::ClickButton) -> Option<Command> {
self.item_cells()
.into_iter()
.find(|(_, cell)| cell.contains(px, py))
.map(|(item, _)| {
let key = item.key().to_string();
let (x, y) = (px as i32, py as i32);
if button == super::ClickButton::Right || item.menu_mode() == TrayMenuMode::Primary
{
Command::OpenTrayMenu { key, x, y }
} else {
Command::ActivateTrayItem { key, x, y }
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::ClickButton;
use std::io::Cursor;
fn solid_png(width: u32, height: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut img = image::RgbaImage::new(width, height);
for px in img.pixels_mut() {
*px = image::Rgba(rgba);
}
let mut bytes = Cursor::new(Vec::new());
img.write_to(&mut bytes, image::ImageFormat::Png)
.expect("encode png");
bytes.into_inner()
}
fn item(key: &str, title: &str) -> TrayItem {
TrayItem::new(key, title, TrayStatus::Active, None)
}
#[test]
fn status_normalizes_known_values_and_defaults_the_rest() {
assert_eq!(TrayStatus::from_sni("Active"), TrayStatus::Active);
assert_eq!(
TrayStatus::from_sni("NeedsAttention"),
TrayStatus::NeedsAttention
);
assert_eq!(TrayStatus::from_sni("Passive"), TrayStatus::Passive);
assert_eq!(TrayStatus::from_sni("bogus"), TrayStatus::Passive);
assert_eq!(TrayStatus::from_sni(""), TrayStatus::Passive);
}
#[test]
fn argb32_pixmap_is_reordered_and_premultiplied() {
let icon = TrayIcon::from_argb32(1, 1, &[128, 255, 0, 0]).expect("valid pixmap");
assert_eq!((icon.width(), icon.height()), (1, 1));
assert_eq!(icon.rgba(), &[128, 0, 0, 128]);
}
#[test]
fn argb32_pixmap_with_inconsistent_length_is_dropped() {
assert_eq!(TrayIcon::from_argb32(2, 2, &[0, 0, 0, 0]), None);
assert_eq!(TrayIcon::from_argb32(0, 4, &[0, 0, 0, 0]), None);
}
#[test]
fn png_icon_decodes_to_premultiplied_rgba() {
let png = solid_png(3, 2, [0, 255, 0, 255]); let icon = TrayIcon::from_png_bytes(&png).expect("valid png");
assert_eq!((icon.width(), icon.height()), (3, 2));
assert_eq!(icon.rgba().len(), 3 * 2 * 4);
assert_eq!(&icon.rgba()[..4], &[0, 255, 0, 255]);
}
#[test]
fn non_png_bytes_are_a_decode_error_not_a_panic() {
assert!(TrayIcon::from_png_bytes(b"not a png at all").is_err());
}
#[test]
fn item_trims_key_and_title() {
let it = TrayItem::new(" :1.42/Item ", " Volume ", TrayStatus::Active, None);
assert_eq!(it.key(), ":1.42/Item");
assert_eq!(it.title(), "Volume");
}
#[test]
fn fallback_label_is_the_titles_initial_or_a_placeholder() {
assert_eq!(item("k", "discord").fallback_label(), "D");
assert_eq!(item("k", "").fallback_label(), "?");
}
#[test]
fn state_dedupes_by_key_and_sorts() {
let state = TrayState::new([
item(":1.3", "c"),
item(":1.1", "a"),
item(":1.1", "duplicate"),
item(":1.2", "b"),
]);
let keys: Vec<&str> = state.items().iter().map(TrayItem::key).collect();
assert_eq!(keys, [":1.1", ":1.2", ":1.3"]);
assert_eq!(state.items()[0].title(), "a");
assert_eq!(state.len(), 3);
}
#[test]
fn order_of_registration_does_not_change_the_snapshot() {
let a = TrayState::new([item(":1.1", "a"), item(":1.2", "b")]);
let b = TrayState::new([item(":1.2", "b"), item(":1.1", "a")]);
assert_eq!(a, b);
}
#[test]
fn first_message_changes_state_and_unrelated_message_is_ignored() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.is_empty());
assert!(widget.update(&Msg::Tray(TrayState::new([item(":1.1", "a")]))));
assert_eq!(widget.len(), 1);
let tick = Msg::tick_now();
assert!(!widget.update(&tick));
assert_eq!(widget.len(), 1);
}
#[test]
fn an_empty_first_snapshot_is_not_a_visible_change() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 320, 32));
assert!(!widget.update(&Msg::Tray(TrayState::default())));
assert!(widget.is_empty());
assert!(widget.update(&Msg::Tray(TrayState::new([item(":1.1", "a")]))));
}
#[test]
fn identical_snapshot_is_not_a_visible_change() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&Msg::Tray(TrayState::new([
item(":1.1", "a"),
item(":1.2", "b")
]))));
assert!(!widget.update(&Msg::Tray(TrayState::new([
item(":1.2", "b"),
item(":1.1", "a")
]))));
}
#[test]
fn adding_an_item_is_a_visible_change() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&Msg::Tray(TrayState::new([item(":1.1", "a")]))));
assert!(widget.update(&Msg::Tray(TrayState::new([
item(":1.1", "a"),
item(":1.2", "b")
]))));
assert_eq!(widget.len(), 2);
}
#[test]
fn empty_tray_before_any_message_takes_no_clicks() {
let widget = TrayWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.on_click(0, 0, ClickButton::Left), None);
}
#[test]
fn click_on_an_item_activates_it_by_key() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&Msg::Tray(TrayState::new([
item(":1.1", "a"),
item(":1.2", "b"),
])));
assert_eq!(
widget.on_click(10, 16, ClickButton::Left),
Some(Command::ActivateTrayItem {
key: ":1.1".to_string(),
x: 10,
y: 16,
})
);
assert_eq!(
widget.on_click(40, 16, ClickButton::Left),
Some(Command::ActivateTrayItem {
key: ":1.2".to_string(),
x: 40,
y: 16,
})
);
assert_eq!(widget.on_click(100, 16, ClickButton::Left), None);
}
#[test]
fn primary_menu_item_opens_its_menu_on_left_click() {
let menu_item = item(":1.1", "app").with_menu(TrayMenuMode::Primary);
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32));
widget.update(&Msg::Tray(TrayState::new([menu_item])));
assert_eq!(
widget.on_click(12, 18, ClickButton::Left),
Some(Command::OpenTrayMenu {
key: ":1.1".to_string(),
x: 12,
y: 18,
})
);
}
#[test]
fn secondary_menu_item_activates_on_left_and_opens_on_right() {
let menu_item = item(":1.1", "app").with_menu(TrayMenuMode::Secondary);
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32));
widget.update(&Msg::Tray(TrayState::new([menu_item])));
assert!(matches!(
widget.on_click(12, 18, ClickButton::Left),
Some(Command::ActivateTrayItem { .. })
));
assert_eq!(
widget.on_click(12, 18, ClickButton::Right),
Some(Command::OpenTrayMenu {
key: ":1.1".to_string(),
x: 12,
y: 18,
})
);
}
#[test]
fn right_click_requests_context_menu_fallback_even_without_exported_menu() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32));
widget.update(&Msg::Tray(TrayState::new([item(":1.1", "app")])));
assert_eq!(
widget.on_click(12, 18, ClickButton::Right),
Some(Command::OpenTrayMenu {
key: ":1.1".to_string(),
x: 12,
y: 18,
})
);
}
#[test]
fn items_are_clipped_to_the_widget_slot() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32));
widget.update(&Msg::Tray(TrayState::new([
item(":1.1", "a"),
item(":1.2", "b"),
])));
for x in [0, 16, 31] {
assert_eq!(
widget.on_click(x, 16, ClickButton::Left),
Some(Command::ActivateTrayItem {
key: ":1.1".to_string(),
x: x as i32,
y: 16,
})
);
}
}
#[test]
fn draw_renders_icons_and_initial_fallbacks_without_panicking() {
let png = solid_png(8, 8, [0, 0, 255, 255]);
let icon = TrayIcon::from_png_bytes(&png).unwrap();
let with_icon = TrayItem::new(":1.1", "blue", TrayStatus::Active, Some(icon));
let mut widget = TrayWidget::new(Bounds::new(0, 0, 64, 32));
widget.update(&Msg::Tray(TrayState::new([with_icon, item(":1.2", "x")])));
let mut ctx = RenderContext::new(64, 32);
ctx.fill_background();
widget.draw(&mut ctx);
assert_eq!(ctx.pixels().len(), 64 * 32 * 4);
let px = ctx.pixels();
let center = (16 * 64 + 16) * 4;
assert!(px[center + 2] > 0x80, "icon cell not blue");
}
#[test]
fn configured_padding_insets_icons_without_shrinking_the_cell() {
let png = solid_png(8, 8, [0, 0, 255, 255]);
let icon = TrayIcon::from_png_bytes(&png).unwrap();
let tray_item = TrayItem::new(":1.1", "blue", TrayStatus::Active, Some(icon));
let style = WidgetStyle {
background: Some((0x20, 0x40, 0x20, 0xFF)),
padding: 6,
radius: 0,
..WidgetStyle::default()
};
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32)).with_style(style);
widget.update(&Msg::Tray(TrayState::new([tray_item])));
let mut ctx = RenderContext::new(32, 32);
ctx.fill_background();
widget.draw(&mut ctx);
let px = ctx.pixels();
let padded_edge = (16 * 32 + 2) * 4;
let center = (16 * 32 + 16) * 4;
assert_eq!(&px[padded_edge..padded_edge + 4], &[0x20, 0x40, 0x20, 0xFF]);
assert!(px[center + 2] > 0x80, "padded icon center not blue");
assert_eq!(
widget.on_click(2, 16, ClickButton::Left),
Some(Command::ActivateTrayItem {
key: ":1.1".to_string(),
x: 2,
y: 16,
})
);
}
#[test]
fn an_attention_item_draws_an_alert_pill_behind_its_cell() {
let attn = TrayItem::new(":1.1", "urgent", TrayStatus::NeedsAttention, None);
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32));
widget.update(&Msg::Tray(TrayState::new([attn])));
let mut ctx = RenderContext::new(32, 32);
ctx.fill_background();
widget.draw(&mut ctx);
let px = ctx.pixels();
let p = (16 * 32 + 3) * 4;
assert!(
px[p] > 0x80 && px[p] > px[p + 2],
"attention cell not alert-filled"
);
}
#[test]
fn a_passive_item_with_the_default_style_draws_no_pill() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 32, 32));
widget.update(&Msg::Tray(TrayState::new([item(":1.1", "app")])));
let mut ctx = RenderContext::new(32, 32);
ctx.fill_background();
widget.draw(&mut ctx);
let px = ctx.pixels();
assert_eq!(&px[0..4], &[0x18, 0x18, 0x18, 0xFF]);
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = TrayWidget::new(Bounds::new(0, 0, 1, 1));
widget.set_bounds(Bounds::new(10, 0, 200, 32));
assert_eq!(widget.bounds(), Bounds::new(10, 0, 200, 32));
}
}