/* * the system tray, right of the workspaces. tray apps (StatusNotifierItem) register * with a watcher on the session bus; nothing on this desktop runs one, so the shell * owns org.kde.StatusNotifierWatcher and serves it, and is its own host. each app's * icon comes from its IconName (looked up in the icon themes) or its IconPixmap * (written out as a png, see png.rs). a left click activates the app (or opens its * menu if that's all it has), a right click opens its menu (com.canonical.dbusmenu) * drawn here in the island's look, a middle click is the app's secondary action, a * scroll its scroll. everything on the bus happens on threads, never while drawing */ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Mutex, PoisonError, mpsc}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::{env, fs, thread}; use amane::{ Animation, Argument, Blend, Bus, Button, Center, Color, Column, Full, Horizontal, Image, InputArea, Layer, LayerWindow, Margin, Monitor, Padding, Parent, Pointer, Rectangle, Row, Scroll, Service, SpaceBetween, Start, Text, Value, Vertical, Weight, Widget, Zone, }; use crate::config::appearance::{motion, overlays, text}; use crate::config::island::pill as island_pill; use crate::config::tray::{menu, pill}; use crate::glyph::{Glyph, icon}; use crate::island::{CLEAR, faded, veiled}; use crate::{edge, parse, png, surface, theme}; const WATCHER: &str = "org.kde.StatusNotifierWatcher"; const WATCHER_PATH: &str = "/StatusNotifierWatcher"; const ITEM: &str = "org.kde.StatusNotifierItem"; const MENU: &str = "com.canonical.dbusmenu"; const PROPERTIES: &str = "org.freedesktop.DBus.Properties"; // the tray's pill (a slot per icon, like the workspaces' slots) and its menu are settings (config.rs: tray.*) const INTROSPECTION: &str = r#" "#; // one app in the tray #[derive(Clone, PartialEq)] pub struct Item { // what RegisteredStatusNotifierItems lists: its bus name and object path, together pub key: String, bus: String, path: String, // its unique name (":1.42"), which its signals come from owner: String, pub title: String, pub icon: Option, // NeedsAttention: a ring around it; Passive: not shown pub attention: bool, passive: bool, // its dbusmenu's object path, and whether a click should open that rather than activate menu: String, only_menu: bool, } #[derive(Default)] pub struct Tray { pub items: Vec, } impl Service for Tray { fn new() -> Self { Self::default() } fn listen() { let bus = Bus::session(); // the calls are watched before the name is taken, so none is missed let calls = bus.methods(WATCHER_PATH, WATCHER); let properties = bus.methods(WATCHER_PATH, PROPERTIES); let introspection = bus.methods(WATCHER_PATH, "org.freedesktop.DBus.Introspectable"); // another watcher runs (another bar): leave it be, and show nothing if !bus.own(WATCHER) { return; } bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierHostRegistered", &[]); thread::spawn(move || { for call in properties { let value = |name: &str| match name { "RegisteredStatusNotifierItems" => Some(Argument::TextList(Tray::read().items.iter().map(|item| item.key.clone()).collect())), "IsStatusNotifierHostRegistered" => Some(Argument::Bool(true)), "ProtocolVersion" => Some(Argument::Int(0)), _ => None, }; match call.name() { "Get" => { let name = call.arguments().get(1).map(|name| name.text().to_string()).unwrap_or_default(); match value(&name) { Some(value) => call.reply(&[Argument::Variant(Box::new(value))]), None => call.reply(&[]), } } "GetAll" => { let all = ["RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"] .into_iter() .filter_map(|name| Some((name.to_string(), value(name)?))) .collect(); call.reply(&[Argument::Map(all)]); } _ => call.reply(&[]), } } }); thread::spawn(move || { for call in introspection { call.reply(&[Argument::from(INTROSPECTION)]); } }); // an app that quits leaves the bus: its icon goes thread::spawn(|| { for signal in Bus::session().signals("org.freedesktop.DBus", "NameOwnerChanged") { let arguments = signal.arguments(); let (name, gone) = (arguments.first().map_or("", Value::text), arguments.get(2).is_some_and(|owner| owner.text().is_empty())); if gone && !name.is_empty() { forget(name); } } }); // an app that changed its icon, title or status says so for change in ["NewIcon", "NewAttentionIcon", "NewStatus", "NewTitle", "NewToolTip", "NewMenu"] { thread::spawn(move || { for signal in Bus::session().signals(ITEM, change) { let stale: Vec = Tray::read().items.iter().filter(|item| item.owner == signal.sender() && item.path == signal.path()).cloned().collect(); for item in stale { refresh(&item.bus, &item.path, &item.owner); } } }); } for call in calls { let name = call.name().to_string(); let service = call.arguments().first().map(|service| service.text().to_string()).unwrap_or_default(); // answered at once: the app waits for it call.reply(&[]); if name == "RegisterStatusNotifierItem" && !service.is_empty() { thread::spawn(move || register(&service)); } } } } /* * an app's registration: a bus name (its item at /StatusNotifierItem), or, from * libappindicator, only the object path. amane doesn't say who called, so for a path * the newest connections are asked which one has that item */ fn register(service: &str) { let bus = Bus::session(); let (name, path) = if service.starts_with('/') { let Some(owner) = find_owner(&bus, service) else { return; }; (owner, service.to_string()) } else { match service.split_once('/') { Some((name, path)) => (name.to_string(), format!("/{path}")), None => (service.to_string(), String::from("/StatusNotifierItem")), } }; let owner = if name.starts_with(':') { name.clone() } else { bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner", &[Argument::from(name.as_str())]).text().to_string() }; if owner.is_empty() { return; } refresh(&name, &path, &owner); let key = format!("{name}{path}"); bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemRegistered", &[Argument::from(key)]); } /* * the connection that serves an item at this path. asked all at once, first answer * wins: some connections never answer a call they don't know (the shell's own among * them), and each of those would hold a one-by-one search for the bus's whole timeout */ fn find_owner(bus: &Bus, path: &str) -> Option { let names = bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames", &[]); let (found, answers) = mpsc::channel(); for name in names.list().iter().map(Value::text).filter(|name| name.starts_with(':')) { let (name, path, found) = (name.to_string(), path.to_string(), found.clone()); thread::spawn(move || { let answer = Bus::session().call(&name, &path, PROPERTIES, "Get", &[Argument::from(ITEM), Argument::from("Id")]); if !matches!(answer, Value::Nothing) { let _ = found.send(name); } }); } drop(found); answers.recv_timeout(Duration::from_secs(5)).ok() } // reads an item again and puts it in the tray, or takes it out if it doesn't answer fn refresh(name: &str, path: &str, owner: &str) { let key = format!("{name}{path}"); let all = Bus::session().call(name, path, PROPERTIES, "GetAll", &[Argument::from(ITEM)]); if !matches!(all, Value::Map(_)) { Tray::write().items.retain(|item| item.key != key); return; } let text = |property: &str| all.get(property).text().to_string(); let status = text("Status"); let attention = status == "NeedsAttention"; // the attention icon while it wants attention, else the icon; a name before pixels let (name_property, pixmap_property) = if attention && !text("AttentionIconName").is_empty() { ("AttentionIconName", "AttentionIconPixmap") } else { ("IconName", "IconPixmap") }; let icon = icon_file(&text(name_property), &text("IconThemePath")).or_else(|| pixmap_file(all.get(pixmap_property))); // the tooltip's title is often the clearest name let tooltip = all.get("ToolTip").list().get(2).map(|title| title.text().to_string()).unwrap_or_default(); let title = [text("Title"), tooltip, text("Id")].into_iter().find(|title| !title.is_empty()).unwrap_or_default(); let item = Item { key: key.clone(), bus: name.to_string(), path: path.to_string(), owner: owner.to_string(), title, icon, attention, passive: status == "Passive", menu: text("Menu"), only_menu: all.get("ItemIsMenu").bool(), }; let mut tray = Tray::write(); match tray.items.iter_mut().find(|known| known.key == key) { Some(known) if *known == item => {} Some(known) => *known = item, None => tray.items.push(item), } } fn forget(name: &str) { let gone: Vec = Tray::read().items.iter().filter(|item| item.owner == name || item.bus == name).map(|item| item.key.clone()).collect(); if gone.is_empty() { return; } Tray::write().items.retain(|item| !gone.contains(&item.key)); let bus = Bus::session(); for key in gone { bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemUnregistered", &[Argument::from(key)]); } } // icons /* * an icon name as a file: the app's own theme folder first, then the icon themes * (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once * per name, the answer kept */ pub fn icon_file(name: &str, theme_path: &str) -> Option { static FOUND: Mutex>>> = Mutex::new(None); if name.is_empty() { return None; } if name.starts_with('/') { return Path::new(name).exists().then(|| PathBuf::from(name)); } let key = format!("{theme_path}\u{1f}{name}"); if let Some(found) = FOUND.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(HashMap::new).get(&key) { return found.clone(); } let home = env::var("HOME").unwrap_or_default(); let bases = [format!("{home}/.local/share/icons"), format!("{home}/.icons"), String::from("/usr/share/icons")]; let mut themes: Vec = Vec::new(); let gtk = fs::read_to_string(format!("{home}/.config/gtk-3.0/settings.ini")).unwrap_or_default(); if let Some(chosen) = gtk.lines().find_map(|line| line.trim().strip_prefix("gtk-icon-theme-name")).map(|rest| rest.trim_start_matches([' ', '=']).trim().to_string()) { themes.push(chosen); } themes.extend(["hicolor", "AdwaitaLegacy", "Adwaita"].map(String::from)); let mut roots: Vec = Vec::new(); if !theme_path.is_empty() { roots.push(PathBuf::from(theme_path)); } for theme in &themes { for base in &bases { roots.push(Path::new(base).join(theme)); } } let mut found = roots.iter().find_map(|root| best_in(root, name)); if found.is_none() { found = ["png", "svg", "xpm"].iter().map(|extension| PathBuf::from(format!("/usr/share/pixmaps/{name}.{extension}"))).find(|path| path.exists() && !path.ends_with(".xpm")); } FOUND.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(HashMap::new).insert(key, found.clone()); found } // the best-fitting file for this name anywhere under a theme folder (see parse::icon_score) fn best_in(root: &Path, name: &str) -> Option { let mut best: Option<(u32, PathBuf)> = None; let mut folders = vec![(root.to_path_buf(), 0)]; while let Some((folder, depth)) = folders.pop() { let Ok(entries) = fs::read_dir(&folder) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if depth < 3 { folders.push((path, depth + 1)); } continue; } let stem = path.file_stem().and_then(|stem| stem.to_str()); let extension = path.extension().and_then(|extension| extension.to_str()); if stem == Some(name) && matches!(extension, Some("png" | "svg")) { let score = parse::icon_score(&path.to_string_lossy()); if best.as_ref().is_none_or(|(known, _)| score < *known) { best = Some((score, path)); } } } } best.map(|(_, path)| path) } /* * IconPixmap as a png: the size nearest 32 px of those it sends, in the runtime folder, * named after its pixels so a new icon is a new path (amane keeps images by path) */ fn pixmap_file(pixmaps: &Value) -> Option { let chosen = pixmaps.list().iter().filter(|pixmap| pixmap.list().len() == 3).min_by_key(|pixmap| (pixmap.list()[0].number() as i64 - 32).abs())?; let (width, height) = (chosen.list()[0].number() as u32, chosen.list()[1].number() as u32); let argb: Vec = chosen.list()[2].list().iter().map(|byte| byte.number() as u8).collect(); if width == 0 || height == 0 || argb.len() != (width * height * 4) as usize { return None; } // fnv-1a over the pixels let hash = argb.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)); let folder = Path::new(&env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| String::from("/tmp"))).join("amane-tray"); let path = folder.join(format!("{hash:016x}.png")); if !path.exists() { fs::create_dir_all(&folder).ok()?; fs::write(&path, png::encode(width, height, &png::argb_to_rgba(&argb))).ok()?; } Some(path) } // what a click, a middle click or a scroll asks of an app; all on threads fn activate(item: &Item, method: &'static str) { let item = item.clone(); thread::spawn(move || { Bus::session().call(&item.bus, &item.path, ITEM, method, &[Argument::Int(0), Argument::Int(0)]); }); } fn scroll(item: &Item, delta: i32) { let item = item.clone(); thread::spawn(move || { Bus::session().call(&item.bus, &item.path, ITEM, "Scroll", &[Argument::Int(delta), Argument::from("vertical")]); }); } // the menu // one entry of an app's menu #[derive(Clone, PartialEq)] struct Entry { id: i32, label: String, enabled: bool, separator: bool, // a checkmark or radio, and whether it's on toggle: Option, submenu: bool, } #[derive(Clone)] struct Open { item: Item, monitor: String, // the menu's left edge, from the monitor's left x: f32, // the submenus entered, with their labels, for the way back trail: Vec<(i32, String)>, entries: Vec, } pub struct TrayUi { // icons and menu rows under the pointer hover: HashMap, open: Option, appear: Animation, inside: bool, // bumped when a menu opens or the pointer comes back, so an older close timer does nothing moves: u32, } impl Service for TrayUi { fn new() -> Self { Self { hover: HashMap::new(), open: None, appear: Animation::new(0.0), inside: false, moves: 0 } } // changes only through input and the apps' answers fn listen() {} } fn hovering(spot: String) -> impl Fn(bool) + 'static { move |inside| { TrayUi::write() .hover .entry(spot.clone()) .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(motion::hover_ms()))) .to(if inside { 1.0 } else { 0.0 }); } } /* * the app's menu (or the submenu `parent`): AboutToShow lets it fill in what it fills * in late, GetLayout gives the entries. (u revision, (i id, a{sv} properties, av children)) */ fn layout(item: &Item, parent: i32) -> Vec { let bus = Bus::session(); bus.call(&item.bus, &item.menu, MENU, "AboutToShow", &[Argument::Int(parent)]); let answer = bus.call(&item.bus, &item.menu, MENU, "GetLayout", &[Argument::Int(parent), Argument::Int(1), Argument::TextList(Vec::new())]); let root = answer.list().get(1); let children = root.and_then(|root| root.list().get(2)).map_or(&[][..], Value::list); children .iter() .filter_map(|child| { let fields = child.list(); let properties = fields.get(1)?; // missing properties have their defaults: visible, enabled, a plain entry let visible = !matches!(properties.get("visible"), Value::Bool(false)); if !visible { return None; } let toggle_type = properties.get("toggle-type").text(); Some(Entry { id: fields.first()?.number() as i32, label: parse::strip_mnemonic(properties.get("label").text()), enabled: !matches!(properties.get("enabled"), Value::Bool(false)), separator: properties.get("type").text() == "separator", toggle: (!toggle_type.is_empty()).then(|| properties.get("toggle-state").number() == 1.0), submenu: properties.get("children-display").text() == "submenu", }) }) .collect() } // opens the app's menu under its icon, once it has answered fn open_menu(item: &Item, monitor: &str, x: f32) { let (item, monitor) = (item.clone(), monitor.to_string()); thread::spawn(move || { if item.menu.is_empty() { Bus::session().call(&item.bus, &item.path, ITEM, "ContextMenu", &[Argument::Int(0), Argument::Int(0)]); return; } let entries = layout(&item, 0); if entries.is_empty() { return; } let mut ui = TrayUi::write(); ui.open = Some(Open { item, monitor, x, trail: Vec::new(), entries }); ui.appear = Animation::new(0.0).duration(Duration::from_millis(menu::appear_ms())); ui.appear.to(1.0); ui.moves += 1; ui.hover.retain(|spot, _| !spot.starts_with("row:")); }); } // into a submenu, or back out of one (`id` none) fn enter(id: Option<(i32, String)>) { let Some(mut open) = TrayUi::read().open.clone() else { return; }; thread::spawn(move || { match id { Some(step) => open.trail.push(step), None => { open.trail.pop(); } } let parent = open.trail.last().map_or(0, |(id, _)| *id); open.entries = layout(&open.item, parent); let mut ui = TrayUi::write(); ui.open = Some(open); ui.hover.retain(|spot, _| !spot.starts_with("row:")); }); } // a click on an entry: the app hears it, the menu closes fn choose(item: &Item, id: i32) { let item = item.clone(); close_menu(); thread::spawn(move || { let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |time| time.as_secs() as u32); Bus::session().call(&item.bus, &item.menu, MENU, "Event", &[Argument::Int(id), Argument::from("clicked"), Argument::Variant(Box::new(Argument::Int(0))), Argument::Unsigned(now)]); }); } fn close_menu() { let mut ui = TrayUi::write(); ui.open = None; ui.inside = false; } // the pointer left the menu: it closes unless it comes back within a moment fn leave_soon() { let moves = { let mut ui = TrayUi::write(); ui.inside = false; ui.moves += 1; ui.moves }; thread::spawn(move || { thread::sleep(Duration::from_millis(menu::close_ms())); let gone = { let ui = TrayUi::read(); ui.moves == moves && !ui.inside }; if gone { close_menu(); } }); } /* * the tray's pill for the workspaces' window: (the pill, its width), nothing without * icons. `left` is where it starts, from the monitor's left, for its menus */ pub fn pill(monitor: &str, left: f32) -> Option<(Rectangle, f32)> { let tray = Tray::read(); let ui = TrayUi::read(); let shown: Vec<&Item> = tray.items.iter().filter(|item| !item.passive).collect(); if shown.is_empty() { return None; } let theme = theme::now(); let count = shown.len() as f32; let width = 2.0 * pill::inset() + count * pill::slot() + (count - 1.0) * pill::slot_gap(); let mut slots: Vec> = Vec::new(); for (index, item) in shown.iter().enumerate() { let spot = format!("icon:{}", item.key); let lit = ui.hover.get(&spot).map_or(0.0, Animation::value); let open = ui.open.as_ref().is_some_and(|open| open.item.key == item.key && open.monitor == monitor); let picture = match &item.icon { Some(path) => Rectangle::new().width(pill::icon()).height(pill::icon()).fill(Image::contain(path.clone())), // no icon to find: its first letter None => Rectangle::new() .width(pill::icon()) .height(pill::icon()) .align_child(Center, Center) .child(Text::new(item.title.chars().next().map_or(String::from("?"), |letter| letter.to_uppercase().collect())).size(text::body()).weight(Weight::Bold).color(theme.text)), }; let slot = Rectangle::new() .width(pill::slot()) .height(pill::slot()) .radius(Full) .fill(if open { faded(theme.accent, 90) } else { Color::blend(CLEAR, overlays::chip_hover(), lit) }) .scale(1.0 + 0.08 * lit) .align_child(Center, Center) .cursor(Pointer) .on_hover(hovering(spot)); let slot = if item.attention { slot.border(1.5, theme.accent) } else { slot }; let x = left + pill::inset() + index as f32 * (pill::slot() + pill::slot_gap()); let (clicked, scrolled, monitor) = ((*item).clone(), (*item).clone(), monitor.to_string()); let slot = slot .on_click(move |button| match button { Button::Left if clicked.only_menu => open_menu(&clicked, &monitor, x), Button::Left => activate(&clicked, "Activate"), Button::Middle => activate(&clicked, "SecondaryActivate"), Button::Right => open_menu(&clicked, &monitor, x), }) .on_scroll(move |step: Scroll| scroll(&scrolled, if step.y > 0.0 { 1 } else { -1 })) .child(picture); slots.push(Box::new(slot)); } let content = Rectangle::new().width(width).height(island_pill::height()).padding(pill::inset()).align_child(Start, Center).child(Row::new(slots).gap(pill::slot_gap()).align(Center)); let pill = surface::shell(width, island_pill::height(), island_pill::height() / 2.0, &theme.surfaces(), 0.0, content).on_hover(edge::over_bar); Some((pill, width)) } // an app's menu, under its icon on the monitor it was opened on pub fn menu_view(monitor: &Monitor) -> LayerWindow { theme::begin(); let ui = TrayUi::read(); let window = |width: f32, height: f32| { LayerWindow::new() .width(width) .height(height) .anchor_vertical(Vertical::Top) .anchor_horizontal(Horizontal::Left) .layer(Layer::Overlay) .space(Zone::Ignore) .namespace("magic-tray-menu") }; // never hidden (see the island's view): a tiny, empty click-through window instead let Some(open) = ui.open.as_ref().filter(|open| open.monitor == monitor.name) else { return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0)); }; let theme = theme::now(); let colors = theme.surfaces(); let inner_w = menu::width() - 2.0 * menu::pad(); let mut rows: Vec> = Vec::new(); let mut height = 2.0 * menu::pad(); // the app's name, or the submenu's with the way back let lit_back = ui.hover.get("row:back").map_or(0.0, Animation::value); let heading: Box = match open.trail.last() { Some((_, label)) => Box::new( Rectangle::new() .width(inner_w) .height(menu::row_height()) .radius(9.0) .fill(Color::blend(CLEAR, overlays::chip_hover(), lit_back)) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 }) .align_child(Start, Center) .cursor(Pointer) .on_hover(hovering(String::from("row:back"))) .on_click(|_| enter(None)) .child(Row::new(vec![Box::new(icon(Glyph::Back, 12.0, theme.dim)) as Box, Box::new(Text::new(label.as_str()).size(text::body()).weight(Weight::Bold).color(theme.text).elide())]).gap(8.0).align(Center)), ), None => Box::new(Rectangle::new().width(inner_w).height(menu::row_height()).padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 10.0 }).align_child(Start, Center).child(Text::new(open.item.title.as_str()).size(text::small()).weight(Weight::Bold).color(theme.dim).elide())), }; rows.push(heading); height += menu::row_height(); for entry in open.entries.iter().take(menu::max_rows()) { if entry.separator { rows.push(Box::new(Rectangle::new().width(inner_w).height(menu::separator_height()).align_child(Center, Center).child(Rectangle::new().width(inner_w - 16.0).height(1.0).fill(faded(theme.outline, 160))))); height += menu::separator_height(); continue; } let spot = format!("row:{}", entry.id); let lit = if entry.enabled { ui.hover.get(&spot).map_or(0.0, Animation::value) } else { 0.0 }; let ink = if entry.enabled { Color::blend(theme.text, theme.flame, 0.25 * lit) } else { faded(theme.dim, 140) }; // a mark at the left for checkmarks and radios that are on, a chevron at the right for submenus let mark: Box = match entry.toggle { Some(true) => Box::new(Rectangle::new().width(14.0).height(14.0).align_child(Center, Center).child(Rectangle::new().width(7.0).height(7.0).radius(Full).fill(theme.accent))), _ => Box::new(Rectangle::new().width(14.0).height(14.0)), }; let more: Box = Box::new(Text::new(if entry.submenu { "›" } else { "" }).size(text::heading()).color(theme.dim)); let row = Rectangle::new() .width(inner_w) .height(menu::row_height()) .radius(9.0) .fill(Color::blend(CLEAR, overlays::chip_hover(), lit)) .padding(Padding { top: 0.0, right: 8.0, bottom: 0.0, left: 6.0 }) .align_child(Start, Center) .child( Row::new(vec![ Box::new(Row::new(vec![mark, Box::new(Text::new(entry.label.as_str()).size(text::body()).color(ink).elide())]).gap(8.0).align(Center)) as Box, more, ]) .justify(SpaceBetween) .align(Center) .width(inner_w - 14.0), ); let row = if entry.enabled { let (item, id, label, submenu) = (open.item.clone(), entry.id, entry.label.clone(), entry.submenu); row.cursor(Pointer).on_hover(hovering(spot)).on_click(move |_| if submenu { enter(Some((id, label.clone()))) } else { choose(&item, id) }) } else { row }; rows.push(Box::new(row)); height += menu::row_height(); } let appear = ui.appear.value(); let content = Rectangle::new().width(menu::width()).height(height).padding(menu::pad()).child(Column::new(rows)); // raised a little over the bar let card = surface::shell(menu::width(), height, 16.0, &colors, 0.125, veiled(content, menu::width(), height, 16.0, appear, &colors)) .translate(0.0, -6.0 * (1.0 - appear)) .on_hover(|inside| { if inside { let mut ui = TrayUi::write(); ui.inside = true; ui.moves += 1; } else { leave_soon(); } }); let left = (open.x - 8.0 - island_pill::shadow()).max(0.0); let top = island_pill::top_gap() + island_pill::height() + 6.0 - island_pill::shadow(); let region = InputArea { x: island_pill::shadow() as i32, y: island_pill::shadow() as i32, width: menu::width() as i32, height: height.ceil() as i32 }; window(menu::width() + 2.0 * island_pill::shadow(), height + 2.0 * island_pill::shadow()) .margin(Margin { top: top.max(0.0) as i32, right: 0, bottom: 0, left: left as i32 }) .input_region(vec![region]) .child(Rectangle::new().width(Parent).height(Parent).padding(island_pill::shadow()).align_child(Start, Start).child(card)) } // for `amane ipc call tray ...`: list the items; open an item's menu (as a right click), choose or enter an entry, go back, close pub fn command(words: &[String]) -> String { let words: Vec<&str> = words.iter().map(String::as_str).collect(); match words.as_slice() { ["list"] | [] => Tray::read().items.iter().map(|item| format!("{}\t{}\t{}{}", item.key, item.title, item.icon.as_ref().map_or(String::from("-"), |path| path.display().to_string()), if item.passive { "\tpassive" } else { "" })).collect::>().join("\n"), ["menu"] => TrayUi::read().open.as_ref().map_or(String::from("closed"), |open| open.entries.iter().map(|entry| if entry.separator { String::from("---") } else { format!("{} {}{}{}", entry.id, entry.label, if entry.toggle == Some(true) { " [on]" } else { "" }, if entry.submenu { " >" } else { "" }) }).collect::>().join("\n")), ["open", index] | ["activate", index] => { let Some(item) = index.parse::().ok().and_then(|index| Tray::read().items.iter().filter(|item| !item.passive).nth(index).cloned()) else { return String::from("no such item"); }; if words[0] == "open" { open_menu(&item, &crate::island::focused_monitor().unwrap_or_default(), 200.0); } else { activate(&item, "Activate"); } String::from("ok") } ["choose", id] | ["enter", id] => { let Some((item, entry)) = TrayUi::read().open.as_ref().and_then(|open| Some((open.item.clone(), open.entries.iter().find(|entry| entry.id.to_string() == *id)?.clone()))) else { return String::from("no such entry"); }; if entry.submenu { enter(Some((entry.id, entry.label))) } else { choose(&item, entry.id) } String::from("ok") } ["back"] => { enter(None); String::from("ok") } ["close"] => { close_menu(); String::from("ok") } _ => String::from("usage: tray list|menu|open N|activate N|choose ID|enter ID|back|close"), } }