#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrayCommand {
Show,
Hide,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowAction {
Show,
Hide,
Close,
}
pub fn window_action(command: TrayCommand, tray_is_running: bool) -> WindowAction {
match command {
TrayCommand::Show => WindowAction::Show,
TrayCommand::Hide if tray_is_running => WindowAction::Hide,
TrayCommand::Hide | TrayCommand::Quit => WindowAction::Close,
}
}
pub struct Tray {
#[allow(dead_code)] inner: Inner,
}
#[cfg(target_os = "linux")]
fn argb32(rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rgba.len());
for px in rgba.chunks_exact(4) {
out.extend_from_slice(&[px[3], px[0], px[1], px[2]]);
}
out
}
#[cfg(target_os = "linux")]
mod linux {
use super::{TrayCommand, argb32};
use std::sync::mpsc::Sender;
pub struct Handle(#[allow(dead_code)] pub ksni::blocking::Handle<Menu>);
pub struct Menu {
pub commands: Sender<TrayCommand>,
pub rgba: &'static [u8],
pub side: i32,
}
impl ksni::Tray for Menu {
fn id(&self) -> String {
"bombadil".into()
}
fn title(&self) -> String {
"Bombadil".into()
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
vec![ksni::Icon {
width: self.side,
height: self.side,
data: argb32(self.rgba),
}]
}
fn activate(&mut self, _x: i32, _y: i32) {
let _ = self.commands.send(TrayCommand::Show);
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::{MenuItem, StandardItem};
vec![
StandardItem {
label: "Show Bombadil".into(),
activate: Box::new(|this: &mut Self| {
let _ = this.commands.send(TrayCommand::Show);
}),
..Default::default()
}
.into(),
StandardItem {
label: "Hide".into(),
activate: Box::new(|this: &mut Self| {
let _ = this.commands.send(TrayCommand::Hide);
}),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Quit".into(),
activate: Box::new(|this: &mut Self| {
let _ = this.commands.send(TrayCommand::Quit);
}),
..Default::default()
}
.into(),
]
}
}
}
#[cfg(target_os = "linux")]
type Inner = linux::Handle;
#[cfg(any(target_os = "macos", target_os = "windows"))]
type Inner = tray_icon::TrayIcon;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
type Inner = ();
pub fn start(
rgba: &'static [u8],
side: u32,
) -> Option<(Tray, std::sync::mpsc::Receiver<TrayCommand>)> {
let (sender, receiver) = std::sync::mpsc::channel();
let inner = build(sender, rgba, side)?;
Some((Tray { inner }, receiver))
}
#[cfg(target_os = "linux")]
fn build(
commands: std::sync::mpsc::Sender<TrayCommand>,
rgba: &'static [u8],
side: u32,
) -> Option<Inner> {
use ksni::blocking::TrayMethods;
linux::Menu {
commands,
rgba,
side: side as i32,
}
.spawn()
.ok()
.map(linux::Handle)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn build(
commands: std::sync::mpsc::Sender<TrayCommand>,
rgba: &'static [u8],
side: u32,
) -> Option<Inner> {
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
let icon = tray_icon::Icon::from_rgba(rgba.to_vec(), side, side).ok()?;
let show = MenuItem::new("Show Bombadil", true, None);
let hide = MenuItem::new("Hide", true, None);
let quit = MenuItem::new("Quit", true, None);
let (show_id, hide_id, quit_id) = (show.id().clone(), hide.id().clone(), quit.id().clone());
let menu = Menu::new();
menu.append_items(&[&show, &hide, &PredefinedMenuItem::separator(), &quit])
.ok()?;
let tray = tray_icon::TrayIconBuilder::new()
.with_tooltip("Bombadil")
.with_icon(icon)
.with_menu(Box::new(menu))
.build()
.ok()?;
std::thread::spawn(move || {
while let Ok(event) = MenuEvent::receiver().recv() {
let command = if event.id == show_id {
TrayCommand::Show
} else if event.id == hide_id {
TrayCommand::Hide
} else if event.id == quit_id {
TrayCommand::Quit
} else {
continue;
};
if commands.send(command).is_err() {
break;
}
}
});
Some(tray)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn build(
_commands: std::sync::mpsc::Sender<TrayCommand>,
_rgba: &'static [u8],
_side: u32,
) -> Option<Inner> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn rgba_becomes_argb_in_network_byte_order() {
let rgba = [0x11, 0x22, 0x33, 0xFF, 0x44, 0x55, 0x66, 0x80];
assert_eq!(
argb32(&rgba),
vec![0xFF, 0x11, 0x22, 0x33, 0x80, 0x44, 0x55, 0x66]
);
}
#[cfg(target_os = "linux")]
#[test]
fn every_pixel_survives_the_conversion() {
let rgba: Vec<u8> = (0..64 * 64 * 4).map(|i| (i % 251) as u8).collect();
assert_eq!(argb32(&rgba).len(), rgba.len());
}
#[cfg(target_os = "linux")]
#[test]
fn a_trailing_partial_pixel_is_dropped_rather_than_read_out_of_bounds() {
let ragged = [0x11, 0x22, 0x33, 0xFF, 0x44, 0x55];
assert_eq!(argb32(&ragged), vec![0xFF, 0x11, 0x22, 0x33]);
}
#[test]
fn hiding_without_a_tray_closes_instead() {
assert_eq!(
window_action(TrayCommand::Hide, false),
WindowAction::Close,
"with no tray, the close button must close"
);
}
#[test]
fn hiding_with_a_tray_actually_hides() {
assert_eq!(
window_action(TrayCommand::Hide, true),
WindowAction::Hide,
"with a tray, the close button must hide"
);
}
#[test]
fn quit_closes_whether_or_not_a_tray_is_running() {
for running in [true, false] {
assert_eq!(
window_action(TrayCommand::Quit, running),
WindowAction::Close,
"Quit must quit (tray running: {running})"
);
}
}
#[test]
fn showing_is_unconditional() {
for running in [true, false] {
assert_eq!(
window_action(TrayCommand::Show, running),
WindowAction::Show
);
}
}
#[test]
fn the_three_commands_are_distinct() {
assert_ne!(TrayCommand::Show, TrayCommand::Hide);
assert_ne!(TrayCommand::Show, TrayCommand::Quit);
assert_ne!(TrayCommand::Hide, TrayCommand::Quit);
}
}