#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrayCommand {
Show,
Hide,
Quit,
OpenTerminal {
project: uuid::Uuid,
environment: bombadil_core::model::VenvLocation,
},
OpenPycharm {
project: uuid::Uuid,
environment: bombadil_core::model::VenvLocation,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuEntry {
Item {
label: String,
command: TrayCommand,
},
Disabled(String),
Separator,
Submenu {
label: String,
children: Vec<MenuEntry>,
},
}
pub fn menu_entries(entries: &[crate::sidebar::Entry]) -> Vec<MenuEntry> {
use crate::sidebar::EnvironmentRow;
let actions = |project: uuid::Uuid, row: &EnvironmentRow| {
vec![
MenuEntry::Item {
label: "Open terminal".into(),
command: TrayCommand::OpenTerminal {
project,
environment: row.location.clone(),
},
},
MenuEntry::Item {
label: "Open in PyCharm".into(),
command: TrayCommand::OpenPycharm {
project,
environment: row.location.clone(),
},
},
]
};
let label = |row: &EnvironmentRow| {
let name = row
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| row.path.display().to_string());
let python = match &row.python_version {
Some(version) => format!("Python {version}"),
None => "not created".to_string(),
};
let active = if row.active { " (active)" } else { "" };
format!("{name} \u{b7} {python}{active}")
};
let mut menu = vec![
MenuEntry::Item {
label: "Show Bombadil".into(),
command: TrayCommand::Show,
},
MenuEntry::Separator,
];
for entry in entries {
let children = match entry.environments.as_slice() {
[] => vec![MenuEntry::Disabled("No environment".into())],
[only] => actions(entry.id, only),
several => several
.iter()
.map(|row| MenuEntry::Submenu {
label: label(row),
children: actions(entry.id, row),
})
.collect(),
};
menu.push(MenuEntry::Submenu {
label: entry.label.clone(),
children,
});
}
menu.push(MenuEntry::Separator);
menu.push(MenuEntry::Item {
label: "Quit".into(),
command: TrayCommand::Quit,
});
menu
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowAction {
Show,
Hide,
Close,
Stay,
}
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,
TrayCommand::OpenTerminal { .. } | TrayCommand::OpenPycharm { .. } => WindowAction::Stay,
}
}
pub struct Tray {
#[cfg_attr(
not(any(target_os = "linux", target_os = "macos", target_os = "windows")),
allow(dead_code) // Held for its `Drop`.
)]
inner: Inner,
}
impl Tray {
#[cfg(target_os = "linux")]
pub fn set_menu(&self, entries: Vec<MenuEntry>) {
self.inner.0.update(|menu| menu.entries = entries);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub fn set_menu(&self, entries: Vec<MenuEntry>) {
let (menu, ids) = native::menu(entries);
if let Ok(mut commands) = self.inner.commands.lock() {
commands.extend(ids);
}
self.inner.icon.set_menu(Some(Box::new(menu)));
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub fn set_menu(&self, entries: Vec<MenuEntry>) {
let _ = entries;
}
}
#[cfg(target_os = "linux")]
fn argb32(rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rgba.len());
for [r, g, b, a] in rgba.as_chunks::<4>().0 {
out.extend_from_slice(&[*a, *r, *g, *b]);
}
out
}
#[cfg(target_os = "linux")]
mod linux {
use super::{MenuEntry, TrayCommand, argb32};
pub fn literal(label: &str) -> String {
label.replace('_', "__")
}
pub fn item(entry: MenuEntry) -> ksni::MenuItem<Menu> {
use ksni::menu::{MenuItem, StandardItem, SubMenu};
match entry {
MenuEntry::Item { label, command } => StandardItem {
label: literal(&label),
activate: Box::new(move |this: &mut Menu| {
let _ = this.commands.send(command.clone());
}),
..Default::default()
}
.into(),
MenuEntry::Disabled(label) => StandardItem {
label: literal(&label),
enabled: false,
..Default::default()
}
.into(),
MenuEntry::Separator => MenuItem::Separator,
MenuEntry::Submenu { label, children } => SubMenu {
label: literal(&label),
submenu: children.into_iter().map(item).collect(),
..Default::default()
}
.into(),
}
}
use std::sync::mpsc::Sender;
pub struct Handle(#[allow(dead_code)] pub ksni::blocking::Handle<Menu>);
pub struct Menu {
pub commands: Sender<TrayCommand>,
pub entries: Vec<MenuEntry>,
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>> {
self.entries.iter().cloned().map(item).collect()
}
}
}
#[cfg(target_os = "linux")]
type Inner = linux::Handle;
#[cfg(any(target_os = "macos", target_os = "windows"))]
type Inner = native::Handle;
#[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,
entries: menu_entries(&[]),
rgba,
side: side as i32,
}
.spawn()
.ok()
.map(linux::Handle)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod native {
use super::{MenuEntry, TrayCommand};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tray_icon::menu::{IsMenuItem, Menu, MenuId, MenuItem, PredefinedMenuItem, Submenu};
pub type Commands = Arc<Mutex<HashMap<MenuId, TrayCommand>>>;
pub struct Handle {
pub icon: tray_icon::TrayIcon,
pub commands: Commands,
}
fn literal(label: &str) -> String {
if cfg!(target_os = "windows") {
label.replace('&', "&&")
} else {
label.to_string()
}
}
fn build(entry: MenuEntry, ids: &mut HashMap<MenuId, TrayCommand>) -> Box<dyn IsMenuItem> {
match entry {
MenuEntry::Item { label, command } => {
let item = MenuItem::new(literal(&label), true, None);
ids.insert(item.id().clone(), command);
Box::new(item)
}
MenuEntry::Disabled(label) => Box::new(MenuItem::new(literal(&label), false, None)),
MenuEntry::Separator => Box::new(PredefinedMenuItem::separator()),
MenuEntry::Submenu { label, children } => {
let sub = Submenu::new(literal(&label), true);
for child in children {
let _ = sub.append(build(child, ids).as_ref());
}
Box::new(sub)
}
}
}
pub fn menu(entries: Vec<MenuEntry>) -> (Menu, HashMap<MenuId, TrayCommand>) {
let menu = Menu::new();
let mut ids = HashMap::new();
for entry in entries {
let _ = menu.append(build(entry, &mut ids).as_ref());
}
(menu, ids)
}
}
#[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::MenuEvent;
use tray_icon::{MouseButton, TrayIconEvent};
let icon = tray_icon::Icon::from_rgba(rgba.to_vec(), side, side).ok()?;
let (menu, ids) = native::menu(menu_entries(&[]));
let ids: native::Commands = std::sync::Arc::new(std::sync::Mutex::new(ids));
let tray = tray_icon::TrayIconBuilder::new()
.with_tooltip("Bombadil")
.with_icon(icon)
.with_menu(Box::new(menu))
.with_menu_on_left_click(cfg!(target_os = "macos"))
.build()
.ok()?;
let menu_commands = commands.clone();
let menu_ids = ids.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
let command = menu_ids
.lock()
.ok()
.and_then(|ids| ids.get(&event.id).cloned());
if let Some(command) = command {
let _ = menu_commands.send(command);
}
}));
TrayIconEvent::set_event_handler(Some(move |event: TrayIconEvent| {
if let TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
} = event
{
let _ = commands.send(TrayCommand::Show);
}
}));
Some(native::Handle {
icon: tray,
commands: ids,
})
}
#[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::*;
fn project(label: &str, envs: &[&str]) -> crate::sidebar::Entry {
crate::sidebar::Entry {
id: uuid::Uuid::nil(),
label: label.into(),
requires_python: None,
environments: envs
.iter()
.map(|e| crate::sidebar::EnvironmentRow {
location: bombadil_core::model::VenvLocation::Custom { path: e.into() },
path: e.into(),
python_version: None,
pin: bombadil_core::model::PythonPin::Unpinned,
active: false,
})
.collect(),
expanded: false,
git: None,
}
}
fn shape(entries: &[MenuEntry]) -> Vec<String> {
entries
.iter()
.map(|e| match e {
MenuEntry::Item { label, .. } => label.clone(),
MenuEntry::Disabled(label) => format!("({label})"),
MenuEntry::Separator => "--".into(),
MenuEntry::Submenu { label, children } => {
format!("{label}[{}]", shape(children).join(","))
}
})
.collect()
}
#[cfg(target_os = "linux")]
#[test]
fn underscores_in_names_survive_the_linux_menu() {
assert_eq!(linux::literal("my_api"), "my__api");
assert_eq!(linux::literal("plain"), "plain");
}
#[test]
fn the_menu_nests_projects_and_environments() {
let menu = menu_entries(&[
project("api", &["a", "b"]),
project("worker", &["w"]),
project("empty", &[]),
]);
assert_eq!(
shape(&menu),
[
"Show Bombadil",
"--",
"api[a \u{b7} not created[Open terminal,Open in PyCharm],\
b \u{b7} not created[Open terminal,Open in PyCharm]]",
"worker[Open terminal,Open in PyCharm]",
"empty[(No environment)]",
"--",
"Quit",
]
);
}
#[test]
fn a_menu_press_names_the_environment_it_was_built_for() {
let menu = menu_entries(&[project("worker", &["w"])]);
let MenuEntry::Submenu { children, .. } = &menu[2] else {
panic!("{:?}", shape(&menu));
};
let MenuEntry::Item { command, .. } = &children[0] else {
panic!();
};
assert_eq!(
*command,
TrayCommand::OpenTerminal {
project: uuid::Uuid::nil(),
environment: bombadil_core::model::VenvLocation::Custom { path: "w".into() },
}
);
}
#[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);
}
}