884 lines
32 KiB
Rust
884 lines
32 KiB
Rust
/*
|
||
* 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"),
|
||
}
|
||
}
|