Files
amane-config/src/tray.rs
T

884 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="org.kde.StatusNotifierWatcher">
<method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
<method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
<signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
<signal name="StatusNotifierHostRegistered"/>
</interface>
</node>"#;
// 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<PathBuf>,
// 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<Item>,
}
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<Item> = 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<String> {
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<String> = 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<PathBuf> {
static FOUND: Mutex<Option<HashMap<String, Option<PathBuf>>>> = 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<String> = 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<PathBuf> = 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<PathBuf> {
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<PathBuf> {
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<u8> = 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<bool>,
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<Entry>,
}
pub struct TrayUi {
// icons and menu rows under the pointer
hover: HashMap<String, Animation>,
open: Option<Open>,
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<Entry> {
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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<dyn Widget> = 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<dyn Widget>, 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<dyn Widget> = 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<dyn Widget> = 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<dyn Widget>,
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::<Vec<_>>().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::<Vec<_>>().join("\n")),
["open", index] | ["activate", index] => {
let Some(item) = index.parse::<usize>().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"),
}
}