diff --git a/README.md b/README.md index 11a03fe..b587edd 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,11 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn - **Background:** a diagonal gradient in the wallpaper's colours (see Colours); optionally the open card takes the song cover's colours. - **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it. - **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's. -- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace takes the accent colour, the one shown on another monitor a softer accent, empty ones dim. Click one to switch to it. +- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace sits on an accent disc, which slides over (stretching a little on the way) when the workspace changes; the one shown on another monitor has a softer accent, empty ones are dim. Click one to switch to it. +- **System tray:** right of the workspace pill, a pill with every tray app's icon (StatusNotifierItem). Left click activates the app (or opens its menu if that's all it offers), right click opens its menu, drawn in the island's look (checkmarks, separators, submenus with a way back, disabled entries dim), middle click is the app's secondary action, scrolling its scroll. Nothing on this desktop ran a tray watcher, so the shell owns `org.kde.StatusNotifierWatcher` on the session bus and serves it (if another bar already does, the shell shows no tray). Icons come from the app's icon name (its own theme folder, gtk's icon theme, hicolor, AdwaitaLegacy, Adwaita, `/usr/share/pixmaps`) or from the pixels it sends, written as PNGs to `$XDG_RUNTIME_DIR/amane-tray/`. An app that quits takes its icon with it (`src/tray.rs`). - **Control centre:** top right on every monitor, a pill with Ethernet, Wi-Fi (or the hotspot), VPN, Bluetooth and the battery. Click it and it opens, in the island's way, into a panel like macOS's Control Center. See [Control centre](#control-centre). -- **Hidden until wanted:** the island, the workspace pill and the control centre's pill stay out of sight until the pointer rests on a monitor's top edge for 0.3 s; then they slide down on that monitor, and slide away 0.7 s after the pointer has left them and the edge. An open card or panel stays. A one-pixel strip along each monitor's top notices the pointer (nothing polls); it's an Overlay layer like the bar, so this works over fullscreen windows too, at the cost of their top pixel row. While hidden the bar draws nothing at all, a playing player included (`src/edge.rs`). +- **Out of the way of fullscreen windows:** on a monitor whose workspace shows a fullscreen window (Hyprland fullscreen 2 or 3; only maximised keeps the bar), the island, the workspace pill (with the tray) and the control centre's pill slide out of sight. Resting the pointer on that monitor's top edge for 0.3 s brings them back until it has been off them and the edge for 0.7 s; an open card or panel stays. Everywhere else the bar is always there. Hyprland's event socket says when windows go fullscreen or workspaces change, and its command socket answers `monitors` and `clients`; nothing polls. The edge strip is a one-pixel Overlay layer, there only on fullscreen monitors (it takes their top pixel row). While hidden the bar draws nothing at all, a playing player included (`src/edge.rs`). +- **Peeks:** some events bring one part in for a moment over a fullscreen window: the workspace pill for 1.6 s when the workspace changes (with the disc sliding over), the island for 3.5 s when the song changes or a player appears, on the focused monitor. The island also pops (it widens and glows, then lets go) on those events when it's visible anyway. The control centre never peeks. ## Control centre @@ -26,9 +28,9 @@ The panel has: - *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair (then trust and connect). Pairing and connecting go through a `bluetoothctl` session with its own agent: a PIN or passkey it needs, a code to confirm or to type on the device are asked in a popup, and a failure shows why. Click the bubble to search for devices (`bluetoothctl --timeout 15 scan on`). - *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn. start|stop`. - *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup). - - *Ethernet*: every real wired card in `/sys/class/net` (a device behind it, not wireless): connected or no cable, and the link speed it negotiated (`speed`, as 1 Gb/s). Shows only. + - *Ethernet*: every real wired card in `/sys/class/net` (a device behind it, not wireless): connected or no cable, and the link speed it negotiated (`speed`, as 1 Gb/s). A click opens a popup beside the panel with the interface and driver, status (speed, duplex), every IPv4 and IPv6 address, gateway, DNS servers, MAC, MTU and traffic since boot (`ip -o addr`, `ip -o route`, `/etc/resolv.conf`, sysfs). Addresses, gateway, DNS and MAC have a Copy chip; a click anywhere on such a row copies it (addresses without their prefix). - **Popups:** questions (a password, PIN or passkey), a code to confirm, a saved password or why something failed, in a small card over the menu. Only a popup with a field takes the keyboard (Enter answers, Escape cancels); the others leave keys to other windows until clicked. -- **Menus:** one shape the whole way: the panel fades its content out and takes the menu's size (the Wi-Fi and Bluetooth maps are 820 px wide, more than twice the panel), then the menu's content fades in over it from a window of its own (so the maps' animation redraws only that window). The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first. +- **Menus:** one shape the whole way: the panel fades its content out and takes the menu's size (the Wi-Fi and Bluetooth maps are 1100 × 560 px, three times the panel's width, with every other node a ring further in so neighbouring labels don't meet), then the menu's content fades in over it from a window of its own (so the maps' animation redraws only that window). The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first. - **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output. - **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness). - **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor. @@ -43,6 +45,10 @@ amane ipc call control status # expanded= menu= wifi= amane ipc call control wifi join # as a click on it in the map; also: wifi password , wifi search amane ipc call control bluetooth pair
# also: bluetooth connect
, bluetooth search amane ipc call control prompt answer # answers the popup; also: prompt cancel +amane ipc call control ethernet # as a click on the Ethernet tile +amane ipc call tray list # every tray item: key, title, icon file +amane ipc call tray open # the n-th icon's menu, as a right click; also: activate +amane ipc call tray menu # the open menu's entries; then: choose , enter , back, close ``` ### Hotspot setup @@ -125,7 +131,9 @@ src/workspaces.rs the workspace pill, top left on every monitor src/control.rs the control centre: status pill top right, its panel and menus, `control` IPC src/map.rs the Wi-Fi and Bluetooth menus' map: this desktop in the middle, what it can connect to around it src/prompt.rs the control centre's popup: passwords, PINs, codes, saved passwords, why a connection failed; joins through iwctl, pairs through bluetoothctl -src/edge.rs hides the bar until the pointer rests on a monitor's top edge +src/edge.rs hides the bar over fullscreen windows (Hyprland's sockets), the edge strip, peeks, the workspace slide +src/tray.rs the system tray: StatusNotifierWatcher and host, icon lookup, dbusmenu menus +src/png.rs a stdlib png writer, for tray icons sent as pixels src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade @@ -133,7 +141,7 @@ src/parse.rs pure text parsing with unit tests, no amane dependency matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme shell.conf settings: cover_colors, transition_ms, hotspot, extra_output island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) -tools/ remote test driver and fake MPRIS players, see AGENTS.md +tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), see AGENTS.md ``` ## Requirements @@ -144,7 +152,7 @@ On the desktop that runs the shell: - PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module) - `cava`, `curl` - the Fira Code font -- for the control centre: `iwd`, openrc `openvpn.` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre)) +- for the control centre: `iwd`, openrc `openvpn.` services, `pciutils` (`lspci`), `wl-clipboard` (`wl-copy`, for copying a saved password or an address), `iproute2` (`ip`, for the Ethernet details), optionally `bluez` (with `bluetoothctl`), and a doas rule without a password for `rc-service`, `sh` and `cat` (see [Control centre](#control-centre)) ## Install and run diff --git a/src/control.rs b/src/control.rs index 972441f..408ef3e 100644 --- a/src/control.rs +++ b/src/control.rs @@ -49,7 +49,7 @@ const PILL_PAD: f32 = 16.0; const PILL_GAP: f32 = 12.0; const PILL_HOVER_GROW: f32 = 6.0; -const PANEL_W: f32 = 360.0; +pub const PANEL_W: f32 = 360.0; const PANEL_RADIUS: f32 = 26.0; const PAD: f32 = 12.0; const INNER_W: f32 = PANEL_W - 2.0 * PAD; @@ -701,13 +701,26 @@ pub fn command(arguments: &[String]) -> String { // what a click on a map's bubble does ["wifi", "search"] => search_again(Menu::Wifi), ["bluetooth", "search"] => search_again(Menu::Bluetooth), + // what a click on the Ethernet tile does + ["ethernet"] => { + let card = { + let links = Links::read(); + + links.wired.iter().find(|card| card.connected).or(links.wired.first()).map(|card| card.name.clone()) + }; + + match card { + Some(card) => prompt::wired(&card), + None => return String::from("no wired card"), + } + } ["bluetooth", action @ ("pair" | "connect"), address] => { let name = Bluetooth::read().devices().iter().find(|device| device.address() == *address).map_or(address.to_string(), |device| device.name().to_string()); prompt::bluetooth(address, &name, *action == "pair"); } ["status"] => return status(), - _ => return String::from("usage: control open|close|toggle|menu |prompt |wifi SSID|wifi search|bluetooth ADDRESS|bluetooth search|status"), + _ => return String::from("usage: control open|close|toggle|menu |prompt |wifi SSID|wifi search|bluetooth ADDRESS|bluetooth search|ethernet|status"), } String::from("ok") @@ -940,7 +953,10 @@ fn tiles(scene: &Scene) -> Column { ]; if scene.layout.ethernet { - rows.push(Box::new(tile(control, Tile::Ethernet, "Ethernet", ethernet_state(scene.links), None, INNER_W))); + // a click lists what there is to know about the card, in a popup beside the panel + let card = scene.links.wired.iter().find(|card| card.connected).or(scene.links.wired.first()).map(|card| card.name.clone()).unwrap_or_default(); + + rows.push(Box::new(tile(control, Tile::Ethernet, "Ethernet", ethernet_state(scene.links), None, INNER_W).cursor(Pointer).on_click(move |_| prompt::wired(&card)))); } Column::new(rows).gap(GAP) diff --git a/src/edge.rs b/src/edge.rs index b7f5e3f..bbeca13 100644 --- a/src/edge.rs +++ b/src/edge.rs @@ -1,29 +1,69 @@ /* - * the bar hides: the island, the workspaces and the control centre's pill stay out of - * sight until the pointer rests on a monitor's top edge for DWELL_MS, then slide down on - * that monitor. a one-pixel strip along every monitor's top hears the pointer arrive, so - * nothing polls; it's an Overlay layer like the bar, so it works over fullscreen windows - * too (and takes their top pixel row). the bar slides away once the pointer has been off - * it and the edge for HIDE_MS; an open card or panel keeps its own part out (see their views) + * when the bar shows. on a monitor whose workspace shows a fullscreen window, the island, + * the workspaces and the control centre's pill stay out of sight until the pointer rests + * on that monitor's top edge for DWELL_MS; everywhere else they're always there. some + * events bring one part in for a moment even then (a peek): the workspaces when the + * workspace changes, the island when the song changes or a player appears; never the + * control centre. a workspace change also slides the workspaces' highlight across, and a + * new song makes the island pop, shown or peeking. + * + * hyprland says what changed on its event socket and answers questions on its other one, + * so nothing here polls hyprland; the media watch only reads the Media service's state. + * the edge strip is one pixel along the top, an Overlay layer like the bar, there only + * on fullscreen monitors (where it takes the window's top pixel row) */ -use std::collections::HashMap; -use std::thread; -use std::time::Duration; +use std::collections::{HashMap, HashSet}; +use std::f32::consts::PI; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use std::{env, thread}; -use amane::{Animation, Full, Layer, LayerWindow, Monitor, Parent, Rectangle, Service, Vertical, Zone}; +use amane::{Animation, Full, Layer, LayerWindow, Media, Monitor, Parent, Rectangle, Service, Vertical, Zone}; use crate::island::{COMPACT_H, SHADOW, TOP_GAP}; +use crate::parse; const DWELL_MS: u64 = 300; const HIDE_MS: u64 = 700; const SLIDE_MS: u64 = 220; +// how long a peek stays +const PEEK_WORKSPACES_MS: u64 = 1600; +const PEEK_ISLAND_MS: u64 = 3500; + +// the island's pop on a new song, and the workspaces' highlight sliding over, in seconds +const POP_S: f32 = 0.7; +const SWITCH_S: f32 = 0.35; + // how far a pill slides up to be out of sight, glow and all pub const HIDE_H: f32 = TOP_GAP + COMPACT_H + SHADOW; +// the parts an event may bring in alone +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum Part { + Island, + Workspaces, +} + +#[derive(Default)] pub struct Edge { - // how far the bar is in on each monitor, 0 to 1 + // the workspace each monitor shows, the focused monitor, and the monitors showing a fullscreen window + showing: HashMap, + focused: Option, + fullscreen: HashSet, + + // how far the whole bar is in on each monitor, 0 to 1, and where the pointer brought it back shown: HashMap, + revealed: Option, + + // a part brought in by an event: its timeline and when that began + peeks: HashMap<(Part, String), (Animation, Instant)>, + + // when the island last popped, and each monitor's workspace before its last change + pops: HashMap<(Part, String), Instant>, + switched: HashMap, // the monitor whose top edge the pointer rests on, and whether it's over a part of the bar at_edge: Option, @@ -35,16 +75,248 @@ pub struct Edge { impl Service for Edge { fn new() -> Self { - Self { shown: HashMap::new(), at_edge: None, over_bar: false, moves: 0 } + Self::default() } - // changes only through input - fn listen() {} + // hyprland's events, for as long as the shell runs; a lost socket is tried again + fn listen() { + thread::spawn(watch_media); + + loop { + if let Some(events) = socket(".socket2.sock").and_then(|path| UnixStream::connect(path).ok()) { + refresh(false); + + for line in BufReader::new(events).lines() { + let Ok(line) = line else { + break; + }; + + let event = line.split(">>").next().unwrap_or(""); + + if matches!( + event.trim_end_matches("v2"), + "fullscreen" | "workspace" | "focusedmon" | "openwindow" | "closewindow" | "movewindow" | "moveworkspace" | "activespecial" | "monitoradded" | "monitorremoved" + ) { + refresh(true); + } + } + } + + thread::sleep(Duration::from_secs(3)); + } + } } impl Edge { + // how far the whole bar is in on this monitor: all the way unless it shows a fullscreen window pub fn shown(&self, monitor: &str) -> f32 { - self.shown.get(monitor).map_or(0.0, Animation::value) + self.shown.get(monitor).map_or(1.0, Animation::value) + } + + // the same for a part an event may bring in alone + pub fn part(&self, part: Part, monitor: &str) -> f32 { + let peek = self.peeks.get(&(part, monitor.to_string())).map_or(0.0, |(peek, _)| peek.value()); + + self.shown(monitor).max(peek) + } + + // 0 to 1 and back over POP_S after a new song; the view asks for frames while it's above 0 + pub fn pop(&self, part: Part, monitor: &str) -> f32 { + let Some(at) = self.pops.get(&(part, monitor.to_string())) else { + return 0.0; + }; + + let t = at.elapsed().as_secs_f32() / POP_S; + + if t < 1.0 { (t * PI).sin() * (1.0 - t * 0.5) } else { 0.0 } + } + + // a workspace change on this monitor still sliding: the workspace it came from, and how far along, 0 to 1 + pub fn switched(&self, monitor: &str) -> Option<(i64, f32)> { + let (from, at) = self.switched.get(monitor)?; + let t = at.elapsed().as_secs_f32() / SWITCH_S; + + (t < 1.0).then_some((*from, t)) + } + + fn fullscreen_on(&self, monitor: &str) -> bool { + self.fullscreen.contains(monitor) + } +} + +// hyprland's sockets for this session +fn socket(name: &str) -> Option { + let runtime = env::var("XDG_RUNTIME_DIR").ok()?; + let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + + Some(PathBuf::from(runtime).join("hypr").join(signature).join(name)) +} + +// a question to hyprland, as `hyprctl ` would ask it; empty if it can't be asked +fn hyprctl(request: &str) -> String { + let Some(mut stream) = socket(".socket.sock").and_then(|path| UnixStream::connect(path).ok()) else { + return String::new(); + }; + + let mut answer = String::new(); + + if stream.write_all(request.as_bytes()).is_ok() { + let _ = stream.read_to_string(&mut answer); + } + + answer +} + +/* + * where every monitor is now: its workspace and whether that shows a fullscreen window. + * `announce`: a workspace that changed slides the highlight over and, on a hidden bar, + * brings the workspaces in for a moment + */ +fn refresh(announce: bool) { + let monitors = parse::hypr_monitors(&hyprctl("monitors")); + + if monitors.is_empty() { + return; + } + + let fullscreen_workspaces = parse::hypr_fullscreen_workspaces(&hyprctl("clients")); + let fullscreen: HashSet = monitors.iter().filter(|(_, workspace, _)| fullscreen_workspaces.contains(workspace)).map(|(name, _, _)| name.clone()).collect(); + let focused = monitors.iter().find(|(_, _, focused)| *focused).map(|(name, _, _)| name.clone()); + + let same = { + let edge = Edge::read(); + + edge.fullscreen == fullscreen && edge.focused == focused && monitors.iter().all(|(name, workspace, _)| edge.showing.get(name) == Some(workspace)) + }; + + if same { + return; + } + + let mut edge = Edge::write(); + + for (name, workspace, _) in &monitors { + let before = edge.showing.insert(name.clone(), *workspace); + + if announce + && let Some(before) = before + && before != *workspace + { + edge.switched.insert(name.clone(), (before, Instant::now())); + + if fullscreen.contains(name) { + peek(&mut edge, Part::Workspaces, name); + } + } + } + + edge.showing.retain(|name, _| monitors.iter().any(|(known, _, _)| known == name)); + edge.focused = focused; + edge.fullscreen = fullscreen; + + // the strip goes with the fullscreen window: the pointer can't be on it any more + if edge.at_edge.as_ref().is_some_and(|monitor| !edge.fullscreen.contains(monitor)) { + edge.at_edge = None; + } + + settle(&mut edge); +} + +// every monitor's bar towards where it belongs: in unless fullscreen, or brought back by the pointer +fn settle(edge: &mut Edge) { + let monitors: Vec = edge.showing.keys().cloned().collect(); + + for monitor in monitors { + let wanted = !edge.fullscreen_on(&monitor) || edge.revealed.as_deref() == Some(monitor.as_str()); + + edge.shown.entry(monitor).or_insert_with(|| sliding(1.0)).to(if wanted { 1.0 } else { 0.0 }); + } +} + +// one part in for a while; a newer peek of the same part keeps it longer +fn peek(edge: &mut Edge, part: Part, monitor: &str) { + let key = (part, monitor.to_string()); + let began = Instant::now(); + + let entry = edge.peeks.entry(key.clone()).or_insert_with(|| (sliding(0.0), began)); + + entry.0.to(1.0); + entry.1 = began; + + let hold = match part { + Part::Island => PEEK_ISLAND_MS, + Part::Workspaces => PEEK_WORKSPACES_MS, + }; + + thread::spawn(move || { + thread::sleep(Duration::from_millis(hold)); + + let mut edge = Edge::write(); + + // under the pointer it stays, until the pointer leaves (hide_soon) + if edge.over_bar { + return; + } + + if let Some((peeking, at)) = edge.peeks.get_mut(&key) + && *at == began + { + peeking.to(0.0); + } + }); +} + +/* + * a new song or a new player: the island pops on the focused monitor, and comes in for + * a moment there if the bar is hidden. the watch reads Media's state a few times a + * second; that's memory, not a player + */ +fn watch_media() { + let mut last: Option<(Vec, String)> = None; + + loop { + thread::sleep(Duration::from_millis(300)); + + let (players, song) = { + let media = Media::read(); + + let players: Vec = media.players().iter().map(|player| player.name().to_string()).collect(); + let song = media.active().filter(|player| !player.title().is_empty()).map(|player| format!("{}\u{1f}{}\u{1f}{}", player.name(), player.title(), player.artist())); + + (players, song) + }; + + // a player between songs has no title for a moment: wait for the next one + let Some(song) = song.or_else(|| last.as_ref().map(|(_, song)| song.clone())) else { + last = Some((players, String::new())); + + continue; + }; + + if let Some((known, before)) = &last { + let new_player = players.iter().any(|name| !known.contains(name)); + let new_song = !before.is_empty() && song != *before; + + if new_player || new_song { + announce_media(); + } + } + + last = Some((players, song)); + } +} + +fn announce_media() { + let mut edge = Edge::write(); + + let Some(monitor) = edge.focused.clone() else { + return; + }; + + edge.pops.insert((Part::Island, monitor.clone()), Instant::now()); + + if edge.fullscreen_on(&monitor) && edge.revealed.as_deref() != Some(monitor.as_str()) { + peek(&mut edge, Part::Island, &monitor); } } @@ -81,28 +353,22 @@ fn at_edge(monitor: String) -> impl Fn(bool) + 'static { let monitor = monitor.clone(); - // still resting there after the dwell: this monitor's bar comes, any other goes + // still resting there after the dwell: this monitor's bar comes back thread::spawn(move || { thread::sleep(Duration::from_millis(DWELL_MS)); let mut edge = Edge::write(); - if edge.moves != moves { - return; - } + if edge.moves == moves { + edge.revealed = Some(monitor); - for (name, shown) in &mut edge.shown { - if *name != monitor { - shown.to(0.0); - } + settle(&mut edge); } - - edge.shown.entry(monitor).or_insert_with(sliding).to(1.0); }); } } -// gone after HIDE_MS off the bar and the edge, unless the pointer came back meanwhile +// gone again after HIDE_MS off the bar and the edge, unless the pointer came back meanwhile; peeks with it fn hide_soon(moves: u32) { thread::spawn(move || { thread::sleep(Duration::from_millis(HIDE_MS)); @@ -110,15 +376,19 @@ fn hide_soon(moves: u32) { let mut edge = Edge::write(); if edge.moves == moves && edge.at_edge.is_none() && !edge.over_bar { - for shown in edge.shown.values_mut() { - shown.to(0.0); + edge.revealed = None; + + for (peeking, _) in edge.peeks.values_mut() { + peeking.to(0.0); } + + settle(&mut edge); } }); } -fn sliding() -> Animation { - Animation::new(0.0).duration(Duration::from_millis(SLIDE_MS)) +fn sliding(at: f32) -> Animation { + Animation::new(at).duration(Duration::from_millis(SLIDE_MS)) } /* @@ -129,14 +399,14 @@ pub fn hidden_by(shown: f32, open: f32, height: f32) -> f32 { -height * (1.0 - shown.max(open)) } -// the strip along the top of every monitor; registered before the bar, so the bar lies over it +// the strip along the top of a fullscreen monitor; registered before the bar, so the bar lies over it pub fn view(monitor: &Monitor) -> LayerWindow { - LayerWindow::new() - .width(Full) - .height(1.0) - .anchor_vertical(Vertical::Top) - .layer(Layer::Overlay) - .space(Zone::Ignore) - .namespace("magic-edge") - .child(Rectangle::new().width(Parent).height(Parent).on_hover(at_edge(monitor.name.clone()))) + let window = LayerWindow::new().width(Full).height(1.0).anchor_vertical(Vertical::Top).layer(Layer::Overlay).space(Zone::Ignore).namespace("magic-edge"); + + // never hidden (see the island's view): empty and click-through where nothing is fullscreen + if !Edge::read().fullscreen_on(&monitor.name) { + return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + } + + window.child(Rectangle::new().width(Parent).height(Parent).on_hover(at_edge(monitor.name.clone()))) } diff --git a/src/island.rs b/src/island.rs index 261e297..435e7be 100644 --- a/src/island.rs +++ b/src/island.rs @@ -11,7 +11,7 @@ use amane::{ }; use crate::art::Art; -use crate::edge::{self, Edge, HIDE_H}; +use crate::edge::{self, Edge, HIDE_H, Part}; use crate::glyph::{Glyph, bold_icon, icon}; use crate::parse::{self, BANDS, Sink}; use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; @@ -240,8 +240,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow { // the other monitors keep their pill while the card is open here let opened_here = island.opened_on.as_deref().is_none_or(|name| name == here); - // hidden until the pointer rests on the top edge (edge.rs); an open card keeps it in - let edge_in = Edge::read().shown(here); + // hidden on a fullscreen monitor until wanted (edge.rs), an open card keeps it in; a new song makes it pop + let (edge_in, pop) = { + let edge = Edge::read(); + + (edge.part(Part::Island, here), edge.pop(Part::Island, here)) + }; + let open = if opened_here { island.open.value() } else { 0.0 }; let slide = edge::hidden_by(edge_in, open, HIDE_H); @@ -296,19 +301,21 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let (grow, pill, reveal) = phases(open); - // the pill under the pointer widens a little and glows pink; the card doesn't + // the pill under the pointer widens a little and glows pink; the card doesn't. a new song does the same, bigger, and lets go let hovered_here = island.pointer_on.as_deref() == Some(here); - let lift = if hovered_here { island.lit(Spot::Island) } else { 0.0 } * (1.0 - grow); + let hovered = if hovered_here { island.lit(Spot::Island) } else { 0.0 }; + let lift = hovered.max(pop) * (1.0 - grow); /* * live levels keep frames coming the way an animation does: a service change - * alone asks with an empty commit, which hyprland only answers on its next repaint + * alone asks with an empty commit, which hyprland only answers on its next repaint. + * the pop is timed, not an Animation, so it asks for its frames too */ - if !levels.is_empty() { + if !levels.is_empty() || pop > 0.0 { amane::request_frame(); } - let width = mix(COMPACT_W + PILL_HOVER_GROW * lift, WIDE_W, grow); + let width = mix(COMPACT_W + PILL_HOVER_GROW * (lift + 2.0 * pop), WIDE_W, grow); let height = mix(COMPACT_H, WIDE_H, grow); let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); diff --git a/src/main.rs b/src/main.rs index a569875..0f9978b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,9 @@ mod prompt; mod sound; mod spectrum; mod system; +mod png; mod theme; +mod tray; mod workspaces; use amane::{App, Palette, Service}; @@ -25,11 +27,13 @@ fn main() { .font("Fira Code") .ipc("island", island::command) .ipc("control", control::command) + .ipc("tray", tray::command) .window_per_monitor(edge::view) .window_per_monitor(island::view) .window_per_monitor(workspaces::view) .window_per_monitor(control::view) .window_per_monitor(control::menu_view) + .window_per_monitor(tray::menu_view) // last, so the popup lies over the control centre .window_per_monitor(prompt::view) .run(); diff --git a/src/map.rs b/src/map.rs index 5d5c81f..880d8ff 100644 --- a/src/map.rs +++ b/src/map.rs @@ -16,10 +16,10 @@ use crate::island::{CHIP_OPEN, faded}; use crate::theme; // how wide the map menus are, against the panel's 360 -pub const MAP_W: f32 = 820.0; +pub const MAP_W: f32 = 1100.0; // the map's own height, under the menu's header -pub const AREA_H: f32 = 420.0; +pub const AREA_H: f32 = 560.0; const BUBBLE: f32 = 40.0; const NODE_R: f32 = 22.0; @@ -27,6 +27,13 @@ const ICON: f32 = 18.0; const LABEL_W: f32 = 140.0; const LABEL_H: f32 = 30.0; +// every other one sits this much further in +const STAGGER: f32 = 0.78; + +// how far each one floats from its place, either way +const DRIFT_X: f32 = 26.0; +const DRIFT_Y: f32 = 16.0; + // at most this many around the bubble pub const NODES: usize = 12; @@ -37,7 +44,7 @@ const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85); const RING_S: f32 = 1.6; const RING_GAP_S: f32 = 0.5; pub const RIPPLE_S: f32 = 2.0 * RING_GAP_S + RING_S; -const RING_REACH: f32 = 170.0; +const RING_REACH: f32 = 230.0; #[derive(Clone, Copy, PartialEq)] pub enum Link { @@ -89,10 +96,10 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang let bubble = BUBBLE * (1.0 + 0.08 * centre.lit) + (t * 1.3).sin() * 1.5; // the ellipse the farthest ones sit on, wider than tall like the menu, with room for their labels above or below - let (rx, ry) = (width / 2.0 - LABEL_W / 2.0 - 6.0, cy - NODE_R - LABEL_H - 9.0); + let (rx, ry) = (width / 2.0 - LABEL_W / 2.0 - DRIFT_X - 6.0, cy - NODE_R - LABEL_H - DRIFT_Y - 9.0); // and the one the nearest stay outside of, clear of the bubble, its ring and its name - let (inner_x, inner_y) = (BUBBLE + NODE_R + 110.0, BUBBLE + NODE_R + 58.0); + let (inner_x, inner_y) = (BUBBLE + NODE_R + 150.0, BUBBLE + NODE_R + 80.0); let count = nodes.len().max(1) as f32; @@ -110,15 +117,19 @@ pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectang // evenly round, a quarter step past the top, so no one sits right on top of or under the bubble let angle = -PI / 2.0 + TAU * (phase + 0.25) / count; - let reach = 1.0 - 0.4 * node.nearness.clamp(0.0, 1.0); + // the stronger the nearer, but never far in; every other one a step further in, so neighbours' labels don't meet + let reach = (1.0 - 0.25 * node.nearness.clamp(0.0, 1.0)) * if index % 2 == 1 { STAGGER } else { 1.0 }; // pushed out to the inner ellipse if the nearness took it inside let (ex, ey) = (angle.cos() * rx * reach, angle.sin() * ry * reach); let inside = ((ex / inner_x).powi(2) + (ey / inner_y).powi(2)).sqrt(); let push = if inside < 1.0 { 1.0 / inside.max(0.01) } else { 1.0 }; - // a slow float, each on its own rhythm - let drift = ((t * 0.55 + phase * 1.7).sin() * 6.0, (t * 0.45 + phase * 2.3).cos() * 5.0); + // a slow float, each on its own rhythm, two waves so the path never quite repeats + let drift = ( + ((t * 0.31 + phase * 1.7).sin() * 0.7 + (t * 0.53 + phase * 0.9).sin() * 0.3) * DRIFT_X, + ((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * DRIFT_Y, + ); let x = cx + ex * push + drift.0; let y = cy + ey * push + drift.1; diff --git a/src/parse.rs b/src/parse.rs index eb3f876..489785c 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -601,6 +601,137 @@ pub fn speed_words(mbit: u32) -> String { } } +/* + * `hyprctl monitors` (plain text): each monitor's name, the id of the workspace it + * shows, and whether it's focused + */ +pub fn hypr_monitors(printed: &str) -> Vec<(String, i64, bool)> { + let mut monitors: Vec<(String, i64, bool)> = Vec::new(); + + for line in printed.lines() { + if let Some(rest) = line.strip_prefix("Monitor ") { + monitors.push((rest.split_whitespace().next().unwrap_or("").to_string(), 0, false)); + } else if let Some(monitor) = monitors.last_mut() { + let line = line.trim(); + + if let Some(rest) = line.strip_prefix("active workspace:") { + monitor.1 = rest.split_whitespace().next().and_then(|id| id.parse().ok()).unwrap_or(0); + } else if let Some(rest) = line.strip_prefix("focused:") { + monitor.2 = rest.trim() == "yes"; + } + } + } + + monitors +} + +/* + * `hyprctl clients` (plain text): the workspaces showing a fullscreen window. fullscreen + * 2 is fullscreen and 3 fullscreen and maximised; 1 is only maximised, which keeps the bar + */ +pub fn hypr_fullscreen_workspaces(printed: &str) -> Vec { + let mut workspace = 0; + let mut found = Vec::new(); + + for line in printed.lines().map(str::trim) { + if let Some(rest) = line.strip_prefix("workspace:") { + workspace = rest.split_whitespace().next().and_then(|id| id.parse().ok()).unwrap_or(0); + } else if let Some(rest) = line.strip_prefix("fullscreen:") + && rest.trim().parse::().is_ok_and(|mode| mode >= 2) + && !found.contains(&workspace) + { + found.push(workspace); + } + } + + found +} + +// a card's addresses from `ip -o addr show dev `: (address with prefix, "IPv4" or "IPv6", scope) +pub fn ip_addresses(printed: &str) -> Vec<(String, &'static str, String)> { + printed + .lines() + .filter_map(|line| { + let words: Vec<&str> = line.split_whitespace().collect(); + let at = words.iter().position(|word| *word == "inet" || *word == "inet6")?; + let address = words.get(at + 1)?; + let scope = words.iter().position(|word| *word == "scope").and_then(|at| words.get(at + 1)).unwrap_or(&""); + + Some((address.to_string(), if words[at] == "inet" { "IPv4" } else { "IPv6" }, scope.to_string())) + }) + .collect() +} + +// the gateways from `ip -o route show default dev `, without repeats +pub fn ip_gateways(printed: &str) -> Vec { + let mut gateways: Vec = Vec::new(); + + for line in printed.lines() { + let words: Vec<&str> = line.split_whitespace().collect(); + + if let Some(gateway) = words.iter().position(|word| *word == "via").and_then(|at| words.get(at + 1)) + && !gateways.iter().any(|known| known == gateway) + { + gateways.push(gateway.to_string()); + } + } + + gateways +} + +// the name servers in /etc/resolv.conf +pub fn nameservers(resolv: &str) -> Vec { + resolv.lines().filter_map(|line| line.trim().strip_prefix("nameserver")).map(|server| server.trim().to_string()).filter(|server| !server.is_empty()).collect() +} + +// a byte count the way a person reads it: 1.4 GB, 820 MB, 12 kB +pub fn bytes_words(bytes: u64) -> String { + let units = [("GB", 1e9), ("MB", 1e6), ("kB", 1e3)]; + + for (unit, size) in units { + if bytes as f64 >= size { + let value = bytes as f64 / size; + + return if value < 10.0 { format!("{value:.1} {unit}") } else { format!("{value:.0} {unit}") }; + } + } + + format!("{bytes} B") +} + +// a menu label without its mnemonic marks: "_Open" is "Open", "Save __as" is "Save _as" +pub fn strip_mnemonic(label: &str) -> String { + let mut plain = String::with_capacity(label.len()); + let mut letters = label.chars().peekable(); + + while let Some(letter) = letters.next() { + if letter == '_' { + if letters.peek() == Some(&'_') { + letters.next(); + plain.push('_'); + } + } else { + plain.push(letter); + } + } + + plain +} + +/* + * how well an icon file fits a tray slot, lower is better: an svg scales, a png the + * nearer its folder's size (like 32x32 or 32) is to 32 px; symbolic icons are drawn + * dark for light panels, so they come last + */ +pub fn icon_score(path: &str) -> u32 { + let symbolic = path.contains("symbolic"); + let size = path.split('/').find_map(|part| part.split('x').next()?.parse::().ok()); + + let fit = if path.ends_with(".svg") { 0 } else { size.map_or(40, |size| size.abs_diff(32) + 1) }; + + fit + if symbolic { 1000 } else { 0 } +} + #[cfg(test)] mod tests { use super::*; @@ -801,6 +932,44 @@ Sink #80 assert_eq!(speed_words(1201), "1.2 Gb/s"); } + #[test] + fn reads_hyprland_and_ip() { + // as hyprctl and ip printed them on the desktop + let monitors = "Monitor eDP-1 (ID 0):\n\t1920x1200@60.00100 at 1920x620\n\tactive workspace: 1 (1)\n\tspecial workspace: 0 ()\n\tfocused: no\nMonitor DP-2 (ID 2):\n\tactive workspace: 3 (3)\n\tspecial workspace: 0 ()\n\tfocused: yes\n"; + + assert_eq!(hypr_monitors(monitors), [(String::from("eDP-1"), 1, false), (String::from("DP-2"), 3, true)]); + + let clients = "Window 561dbfbde7b0 -> Breaking Bad: watch — Firefox:\n\tworkspace: 9 (9)\n\tfullscreen: 0\n\tfullscreenClient: 0\nWindow 561dc0209510 -> Cider:\n\tworkspace: 5 (5)\n\tfullscreen: 2\n\tfullscreenClient: 2\nWindow 1 -> maximised:\n\tworkspace: 7 (7)\n\tfullscreen: 1\nWindow 2 -> both:\n\tworkspace: 1 (1)\n\tfullscreen: 3\n"; + + assert_eq!(hypr_fullscreen_workspaces(clients), [5, 1]); + + let addresses = "2: eth0 inet 192.168.2.169/24 brd 192.168.2.255 scope global eth0\\ valid_lft forever preferred_lft forever\n2: eth0 inet6 2003:fb:af07:db12:ce8c:fe9b:d4e6:d449/64 scope global dynamic mngtmpaddr noprefixroute \\ valid_lft 172768sec preferred_lft 86368sec\n2: eth0 inet6 fe80::627d:9ff:fe0e:929e/64 scope link proto kernel_ll \\ valid_lft forever preferred_lft forever\n"; + + assert_eq!( + ip_addresses(addresses), + [ + (String::from("192.168.2.169/24"), "IPv4", String::from("global")), + (String::from("2003:fb:af07:db12:ce8c:fe9b:d4e6:d449/64"), "IPv6", String::from("global")), + (String::from("fe80::627d:9ff:fe0e:929e/64"), "IPv6", String::from("link")), + ] + ); + + assert_eq!(ip_gateways("default via 192.168.2.1 dev eth0 metric 2 \ndefault via 192.168.2.1 dev eth0 proto dhcp src 192.168.2.197 metric 1002 \n"), ["192.168.2.1"]); + assert_eq!(nameservers("# by resolvconf\nnameserver 1.1.1.1\nnameserver 1.0.0.1\nsearch lan\n"), ["1.1.1.1", "1.0.0.1"]); + assert_eq!(bytes_words(1_400_000_000), "1.4 GB"); + assert_eq!(bytes_words(820_000_000), "820 MB"); + assert_eq!(bytes_words(512), "512 B"); + + assert_eq!(strip_mnemonic("_Open"), "Open"); + assert_eq!(strip_mnemonic("Save __as"), "Save _as"); + assert_eq!(strip_mnemonic("Plain"), "Plain"); + + // an svg beats a png, a 32 px png a 16 px one, anything a symbolic icon + assert!(icon_score("/usr/share/icons/hicolor/scalable/apps/steam.svg") < icon_score("/usr/share/icons/hicolor/32x32/apps/steam.png")); + assert!(icon_score("/usr/share/icons/hicolor/32x32/apps/steam.png") < icon_score("/usr/share/icons/hicolor/16x16/apps/steam.png")); + assert!(icon_score("/usr/share/icons/hicolor/16x16/apps/steam.png") < icon_score("/usr/share/icons/Adwaita/symbolic/status/audio-volume-high-symbolic.svg")); + } + #[test] fn reads_power_and_names() { // 40 Wh left at 20 W: two hours; 10 Wh to go at 30 W: twenty minutes diff --git a/src/png.rs b/src/png.rs new file mode 100644 index 0000000..e920219 --- /dev/null +++ b/src/png.rs @@ -0,0 +1,121 @@ +/* + * a png from raw rgba pixels, for tray icons that come as pixels (IconPixmap) rather + * than a name: amane draws images from files. stdlib only, so the deflate stream is + * stored, not compressed; tray icons are a few kB either way + */ + +// the image, straight rgba (not premultiplied), rows top to bottom +pub fn encode(width: u32, height: u32, rgba: &[u8]) -> Vec { + let row = width as usize * 4; + + // every row starts with its filter byte, 0: none + let mut raw = Vec::with_capacity((row + 1) * height as usize); + + for line in rgba.chunks(row).take(height as usize) { + raw.push(0); + raw.extend_from_slice(line); + } + + let mut header = Vec::with_capacity(13); + header.extend_from_slice(&width.to_be_bytes()); + header.extend_from_slice(&height.to_be_bytes()); + // 8 bits per channel, colour type 6 (rgba), deflate, adaptive filtering, no interlace + header.extend_from_slice(&[8, 6, 0, 0, 0]); + + let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']; + + chunk(&mut png, b"IHDR", &header); + chunk(&mut png, b"IDAT", &zlib_stored(&raw)); + chunk(&mut png, b"IEND", &[]); + + png +} + +/* + * IconPixmap's pixels: argb32 in network byte order, so a, r, g, b per pixel; the + * same as rgba for amane + */ +pub fn argb_to_rgba(argb: &[u8]) -> Vec { + argb.chunks_exact(4).flat_map(|pixel| [pixel[1], pixel[2], pixel[3], pixel[0]]).collect() +} + +fn chunk(png: &mut Vec, kind: &[u8; 4], data: &[u8]) { + png.extend_from_slice(&(data.len() as u32).to_be_bytes()); + png.extend_from_slice(kind); + png.extend_from_slice(data); + + let mut checked = kind.to_vec(); + checked.extend_from_slice(data); + + png.extend_from_slice(&crc32(&checked).to_be_bytes()); +} + +// zlib around deflate's stored blocks, at most 65535 bytes each +fn zlib_stored(data: &[u8]) -> Vec { + let mut out = vec![0x78, 0x01]; + let blocks: Vec<&[u8]> = if data.is_empty() { vec![&[]] } else { data.chunks(65_535).collect() }; + + for (index, block) in blocks.iter().enumerate() { + let last = index + 1 == blocks.len(); + let length = block.len() as u16; + + out.push(u8::from(last)); + out.extend_from_slice(&length.to_le_bytes()); + out.extend_from_slice(&(!length).to_le_bytes()); + out.extend_from_slice(block); + } + + out.extend_from_slice(&adler32(data).to_be_bytes()); + + out +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffff_u32; + + for byte in data { + crc ^= u32::from(*byte); + + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xedb8_8320 } else { crc >> 1 }; + } + } + + !crc +} + +fn adler32(data: &[u8]) -> u32 { + let (mut a, mut b) = (1_u32, 0_u32); + + for byte in data { + a = (a + u32::from(*byte)) % 65_521; + b = (b + a) % 65_521; + } + + (b << 16) | a +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checksums_and_layout() { + // the well-known values: IEND's crc, and adler32 of "Wikipedia" + assert_eq!(crc32(b"IEND"), 0xae42_6082); + assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398); + + let png = encode(2, 1, &[255, 0, 0, 255, 0, 0, 255, 128]); + + assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']); + assert_eq!(&png[12..16], b"IHDR"); + assert_eq!(&png[png.len() - 12..], &[0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xae, 0x42, 0x60, 0x82]); + + // one row: filter byte, then the pixels, in one final stored block + let idat = &png[33 + 8..]; + assert_eq!(&idat[..2], &[0x78, 0x01]); + assert_eq!(&idat[2..7], &[1, 9, 0, 0xf6, 0xff]); + + assert_eq!(argb_to_rgba(&[128, 1, 2, 3]), [1, 2, 3, 128]); + } +} diff --git a/src/prompt.rs b/src/prompt.rs index af596b1..dc048a5 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -18,15 +18,18 @@ use amane::{ Zone, children, }; -use crate::control::{self, Control, RIGHT}; +use crate::control::{self, Control, PANEL_W, RIGHT}; use crate::glyph::{Glyph, icon}; -use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled}; +use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled}; use crate::map::MAP_W; use crate::parse::{self, Pairing}; use crate::system; use crate::theme; const POPUP_W: f32 = 360.0; + +// the Ethernet details: wide enough for an IPv6 address with its prefix +const DETAILS_W: f32 = 500.0; const POPUP_PAD: f32 = 16.0; const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD; const HEADER_H: f32 = 36.0; @@ -36,6 +39,8 @@ const LINE_H: f32 = 18.0; const BUTTON_W: f32 = 92.0; const BUTTON_H: f32 = 32.0; const GAP: f32 = 14.0; +const DETAIL_H: f32 = 26.0; +const LABEL_W: f32 = 78.0; // a bluetoothctl session that hasn't ended by then is stopped const SESSION_S: u64 = 90; @@ -58,6 +63,9 @@ pub enum Ask { // why joining, pairing or connecting didn't work Failed { title: String, reason: String }, + + // what there is to know about a wired card: (label, value, what Copy takes) + Details(Vec<(String, String, Option)>), } #[derive(Clone, PartialEq)] @@ -79,8 +87,8 @@ pub struct Prompt { // what's typed so far, for the button that sends it typed: String, - // the saved password shown went to the clipboard - copied: bool, + // what went to the clipboard: the saved password (0) or that row of the details + copied: Option, appear: Animation, hover: HashMap, @@ -88,7 +96,7 @@ pub struct Prompt { impl Service for Prompt { fn new() -> Self { - Self { request: None, typed: String::new(), copied: false, appear: Animation::new(0.0), hover: HashMap::new() } + Self { request: None, typed: String::new(), copied: None, appear: Animation::new(0.0), hover: HashMap::new() } } // changes only through input, iwctl's answers and bluetoothctl's questions @@ -129,7 +137,7 @@ fn open(ask: Ask, wifi: bool, name: &str, target: &str) { prompt.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() }); prompt.typed.clear(); - prompt.copied = false; + prompt.copied = None; prompt.appear = Animation::new(0.0).duration(Duration::from_millis(220)); prompt.appear.to(1.0); } @@ -178,8 +186,19 @@ pub fn reveal(ssid: &str) { }); } -// the shown password to the clipboard through wl-copy, on its input so it never shows in the process list -fn copy(password: &str) { +// a click on the Ethernet tile: what there is to know about the card, gathered off the drawing thread +pub fn wired(card: &str) { + let card = card.to_string(); + + thread::spawn(move || { + let rows = system::wired_details(&card); + + open(Ask::Details(rows), false, &card, ""); + }); +} + +// a password or an address to the clipboard through wl-copy, on its input so it never shows in the process list +fn copy(password: &str, row: usize) { let password = password.to_string(); thread::spawn(move || { @@ -191,7 +210,7 @@ fn copy(password: &str) { // wl-copy leaves a child of its own behind to serve the clipboard and exits if child.wait().is_ok_and(|status| status.success()) && written { - Prompt::write().copied = true; + Prompt::write().copied = Some(row); } }); } @@ -372,7 +391,7 @@ fn ready(ask: &Ask, typed: &str) -> bool { Ask::Pin => !typed.trim().is_empty(), Ask::Passkey => typed.trim().parse::().is_ok_and(|passkey| passkey <= 999_999), Ask::Show(_) => false, - Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. } => true, + Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. } | Ask::Details(_) => true, } } @@ -392,7 +411,7 @@ pub fn answer(typed: &str) { Ask::Password => joining(request.target, Some(typed.to_string())), Ask::Pin | Ask::Passkey => send(&format!("{}\n", typed.trim())), Ask::Confirm(_) => send("yes\n"), - Ask::Show(_) | Ask::Saved(_) | Ask::Failed { .. } => {} + Ask::Show(_) | Ask::Saved(_) | Ask::Failed { .. } | Ask::Details(_) => {} } } @@ -408,7 +427,7 @@ pub fn refuse() { stop_session(); control::clear_pending(); } - Ask::Saved(_) | Ask::Failed { .. } => {} + Ask::Saved(_) | Ask::Failed { .. } | Ask::Details(_) => {} } } @@ -439,6 +458,7 @@ pub fn status() -> &'static str { Some(Ask::Show(_)) => "show", Some(Ask::Saved(_)) => "saved", Some(Ask::Failed { .. }) => "failed", + Some(Ask::Details(_)) => "details", } } @@ -491,6 +511,9 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let colors = theme.surfaces(); let name = &request.name; + let popup_w = if matches!(request.ask, Ask::Details(_)) { DETAILS_W } else { POPUP_W }; + let inner_w = popup_w - 2.0 * POPUP_PAD; + let (title, question) = match &request.ask { Ask::Password => (format!("Join {name}"), "Enter its Wi-Fi password"), Ask::Pin => (format!("Pair {name}"), "Enter its PIN"), @@ -500,10 +523,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow { Ask::Show(_) => (format!("Pair {name}"), "Type this code on it, then press Enter"), Ask::Saved(_) => (name.clone(), "Its saved Wi-Fi password"), Ask::Failed { title, .. } => (title.clone(), "What went wrong"), + Ask::Details(_) => (format!("Ethernet · {name}"), "Click an address to copy it"), }; let mark = match (&request.ask, request.wifi) { (Ask::Failed { .. }, _) => Glyph::Muted, + (Ask::Details(_), _) => Glyph::Ethernet, (_, true) => Glyph::Lock, (_, false) => Glyph::Bluetooth, }; @@ -515,7 +540,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { Text::new(question).size(11.0).color(theme.dim).elide(), ]) .gap(3.0) - .width(INNER_W - HEADER_H - 12.0), + .width(inner_w - HEADER_H - 12.0), ]) .gap(12.0) .align(Center); @@ -541,7 +566,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { rows.push(Box::new( Rectangle::new() - .width(INNER_W) + .width(inner_w) .height(FIELD_H) .radius(10.0) .fill(CHIP) @@ -562,15 +587,22 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let password = password.clone(); // a click on it copies it too - rows.push(Box::new(big_line(&password, 18.0, theme.text).cursor(Pointer).on_click(move |_| copy(&password)))); + rows.push(Box::new(big_line(&password, 18.0, theme.text).cursor(Pointer).on_click(move |_| copy(&password, 0)))); body_h = CODE_H; } Ask::Failed { reason, .. } => { - rows.push(Box::new(Rectangle::new().width(INNER_W).height(LINE_H).align_child(Start, Center).child(Text::new(reason.as_str()).size(11.5).color(theme.text).elide()))); + rows.push(Box::new(Rectangle::new().width(inner_w).height(LINE_H).align_child(Start, Center).child(Text::new(reason.as_str()).size(11.5).color(theme.text).elide()))); body_h = LINE_H; } + Ask::Details(details) => { + let lines: Vec> = details.iter().enumerate().map(|(index, (label, value, copy_text))| Box::new(detail(&prompt, inner_w, index, label, value, copy_text.clone())) as Box).collect(); + + body_h = details.len() as f32 * DETAIL_H; + + rows.push(Box::new(Column::new(lines).width(inner_w).height(body_h))); + } Ask::Confirm(_) | Ask::Show(_) => {} } @@ -581,17 +613,17 @@ pub fn view(monitor: &Monitor) -> LayerWindow { if let Ask::Saved(password) = &request.ask { let password = password.clone(); - let label = if prompt.copied { "Copied" } else { "Copy" }; + let label = if prompt.copied.is_some() { "Copied" } else { "Copy" }; - buttons.push(Box::new(button(label, Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password)))); - } else if !matches!(request.ask, Ask::Failed { .. }) { + buttons.push(Box::new(button(label, Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(move |_| copy(&password, 0)))); + } else if !matches!(request.ask, Ask::Failed { .. } | Ask::Details(_)) { buttons.push(Box::new(button("Cancel", Color::blend(CHIP_OPEN, CHIP_HOVER, lit(0)), theme.text, lit(0)).cursor(Pointer).on_hover(hovering(0)).on_click(|_| refuse()))); } let primary = match request.ask { Ask::Password => Some("Join"), Ask::Show(_) => None, - Ask::Saved(_) => Some("Close"), + Ask::Saved(_) | Ask::Details(_) => Some("Close"), Ask::Failed { .. } => Some("OK"), _ => Some("Pair"), }; @@ -616,28 +648,31 @@ pub fn view(monitor: &Monitor) -> LayerWindow { buttons.push(Box::new(primary)); } - rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box]).justify(End).width(INNER_W).height(BUTTON_H))); + rows.push(Box::new(Row::new(vec![Box::new(Row::new(buttons).gap(10.0)) as Box]).justify(End).width(inner_w).height(BUTTON_H))); let parts = if body_h > 0.0 { 3.0 } else { 2.0 }; let card_h = 2.0 * POPUP_PAD + HEADER_H + body_h + BUTTON_H + GAP * (parts - 1.0); let appear = prompt.appear.value(); - let content = Rectangle::new().width(POPUP_W).height(card_h).padding(POPUP_PAD).child(Column::new(rows).gap(GAP)); + let content = Rectangle::new().width(popup_w).height(card_h).padding(POPUP_PAD).child(Column::new(rows).gap(GAP)); let card = Rectangle::new() - .width(POPUP_W) + .width(popup_w) .height(card_h) .radius(22.0) .fill(backdrop(&colors, 255)) .border(1.0, Color::blend(theme.outline, theme.accent, 0.5)) .shadow(Shadow::drop(theme.accent).opacity(0.45).blur(22.0)) .translate(0.0, 8.0 * (1.0 - appear)) - .child(veiled(content, POPUP_W, card_h, 22.0, appear, &colors)); + .child(veiled(content, popup_w, card_h, 22.0, appear, &colors)); - // over the middle of the map menu, a little below the control centre's top - let right = RIGHT + (MAP_W - POPUP_W) / 2.0 - SHADOW; - let top = TOP_GAP + 110.0 - SHADOW; + // over the middle of the map menu, a little below the control centre's top; the details beside the panel, by the Ethernet tile + let (right, top) = if matches!(request.ask, Ask::Details(_)) { + (RIGHT + PANEL_W + 14.0 - SHADOW, TOP_GAP + 40.0 - SHADOW) + } else { + (RIGHT + (MAP_W - popup_w) / 2.0 - SHADOW, TOP_GAP + 110.0 - SHADOW) + }; /* * the keyboard only while there's something to type: the other popups come on their @@ -646,14 +681,14 @@ pub fn view(monitor: &Monitor) -> LayerWindow { */ let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey); - let window = window(POPUP_W + 2.0 * SHADOW, card_h + 2.0 * SHADOW) + let window = window(popup_w + 2.0 * SHADOW, card_h + 2.0 * SHADOW) .margin(Margin { top: top.max(0.0) as i32, right: right.max(0.0) as i32, bottom: 0, left: 0 }) .keyboard(if typing { Keyboard::Exclusive } else { Keyboard::OnDemand }) .on_key(|key| match key { Key::Escape => refuse(), // nothing to type: Enter is the yes, or the close Key::Enter => { - let answers = Prompt::read().request.as_ref().is_some_and(|request| matches!(request.ask, Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. })); + let answers = Prompt::read().request.as_ref().is_some_and(|request| matches!(request.ask, Ask::Confirm(_) | Ask::Saved(_) | Ask::Failed { .. } | Ask::Details(_))); if answers { answer(""); @@ -662,11 +697,53 @@ pub fn view(monitor: &Monitor) -> LayerWindow { _ => {} }); - let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: POPUP_W as i32, height: card_h.ceil() as i32 }; + let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: popup_w as i32, height: card_h.ceil() as i32 }; window.input_region(vec![region]).child(Rectangle::new().width(Parent).height(Parent).padding(SHADOW).align_child(Start, Start).child(card)) } +// one line of the details: label, value, and on a row with something to copy, a Copy chip (the whole row copies) +fn detail(prompt: &Prompt, inner_w: f32, index: usize, label: &str, value: &str, copy_text: Option) -> Rectangle { + let theme = theme::now(); + let spot = 10 + index as u8; + let lit = prompt.hover.get(&spot).map_or(0.0, Animation::value); + + let chip_w = 58.0; + let value_w = inner_w - LABEL_W - chip_w - 16.0; + + let chip: Box = match ©_text { + Some(_) => { + let copied = prompt.copied == Some(index); + let ink = if copied { theme.accent } else { Color::blend(theme.dim, theme.text, lit) }; + + Box::new(Rectangle::new().width(chip_w).height(20.0).radius(6.0).fill(Color::blend(CHIP, CHIP_HOVER, lit)).align_child(Center, Center).child(Text::new(if copied { "Copied" } else { "Copy" }).size(10.0).color(ink))) + } + None => Box::new(Rectangle::new().width(chip_w).height(20.0)), + }; + + let line = Rectangle::new() + .width(inner_w) + .height(DETAIL_H) + .radius(8.0) + .fill(Color::blend(CLEAR, CHIP_HOVER, 0.6 * lit)) + .padding(Padding { top: 0.0, right: 4.0, bottom: 0.0, left: 6.0 }) + .align_child(Start, Center) + .child( + Row::new(vec![ + Box::new(Rectangle::new().width(LABEL_W).height(DETAIL_H).align_child(Start, Center).child(Text::new(label).size(10.5).color(theme.dim))) as Box, + Box::new(Rectangle::new().width(value_w).height(DETAIL_H).align_child(Start, Center).child(Text::new(value).size(11.5).color(theme.text).elide())), + chip, + ]) + .gap(6.0) + .align(Center), + ); + + match copy_text { + Some(text) => line.cursor(Pointer).on_hover(hovering(spot)).on_click(move |_| copy(&text, index)), + None => line, + } +} + // a code spaced in threes, the way devices show it fn spaced(code: &str) -> String { code.chars().enumerate().flat_map(|(index, digit)| (index > 0 && index % 3 == 0).then_some(' ').into_iter().chain(std::iter::once(digit))).collect() diff --git a/src/system.rs b/src/system.rs index ed4790e..eacfa80 100644 --- a/src/system.rs +++ b/src/system.rs @@ -471,6 +471,67 @@ pub fn saved_password(ssid: &str) -> Result { Err(String::from("No saved password for it")) } +/* + * what the Ethernet popup lists about a wired card: (label, shown value, what Copy puts + * on the clipboard). `ip` for addresses and routes, sysfs for the rest; blocks, so off + * the drawing thread + */ +pub fn wired_details(card: &str) -> Vec<(String, String, Option)> { + let sys = Path::new("/sys/class/net").join(card); + let ip = |arguments: &[&str]| Command::new("ip").args(arguments).stdin(Stdio::null()).output().map(|output| String::from_utf8_lossy(&output.stdout).into_owned()).unwrap_or_default(); + + let mut rows: Vec<(String, String, Option)> = Vec::new(); + let mut row = |label: &str, value: String, copy: Option| rows.push((label.to_string(), value, copy)); + + let driver = fs::read_link(sys.join("device/driver")).ok().and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned())); + row("Interface", driver.map_or(card.to_string(), |driver| format!("{card} · {driver}")), None); + + let connected = read(&sys.join("carrier")) == "1"; + let speed = read(&sys.join("speed")).parse::().ok().filter(|speed| connected && *speed > 0); + let duplex = read(&sys.join("duplex")); + + let status = match speed { + Some(speed) if !duplex.is_empty() => format!("Connected · {} · {duplex} duplex", parse::speed_words(speed)), + Some(speed) => format!("Connected · {}", parse::speed_words(speed)), + None if connected => String::from("Connected"), + None => String::from("No cable"), + }; + row("Status", status, None); + + // addresses as shown with their prefix, copied without it; link-local IPv6 last + let mut addresses = parse::ip_addresses(&ip(&["-o", "addr", "show", "dev", card])); + addresses.sort_by_key(|(_, family, scope)| (*family != "IPv4", scope != "global")); + + for (address, family, scope) in addresses { + let label = if scope == "link" { "Link-local" } else { family }; + let bare = address.split('/').next().unwrap_or(&address).to_string(); + + row(label, address, Some(bare)); + } + + let gateways = parse::ip_gateways(&ip(&["-o", "route", "show", "default", "dev", card])); + + if !gateways.is_empty() { + row("Gateway", gateways.join(", "), Some(gateways.join(" "))); + } + + let servers = parse::nameservers(&read(Path::new("/etc/resolv.conf"))); + + if !servers.is_empty() { + row("DNS", servers.join(", "), Some(servers.join(" "))); + } + + let mac = read(&sys.join("address")); + row("MAC", mac.clone(), Some(mac)); + + row("MTU", read(&sys.join("mtu")), None); + + let bytes = |name: &str| read(&sys.join("statistics").join(name)).parse::().unwrap_or(0); + row("Traffic", format!("↓ {} ↑ {} since boot", parse::bytes_words(bytes("rx_bytes")), parse::bytes_words(bytes("tx_bytes"))), None); + + rows +} + // leaves the network through iwctl, like joining pub fn disconnect_wifi() { let device = Links::read().wifi.device_name.clone(); diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..102e58c --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,905 @@ +/* + * 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, Shadow, SpaceBetween, + Start, Text, Value, Vertical, Weight, Widget, Zone, +}; + +use crate::glyph::{Glyph, icon}; +use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, veiled}; +use crate::{edge, parse, png, 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 +pub const SLOT: f32 = 28.0; +const SLOT_GAP: f32 = 4.0; +const INSET: f32 = 5.0; +const ICON: f32 = 18.0; + +// the menu +const MENU_W: f32 = 260.0; +const MENU_PAD: f32 = 6.0; +const ROW_H: f32 = 30.0; +const SEPARATOR_H: f32 = 9.0; +const MAX_ROWS: usize = 20; + +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 + */ +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(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(180)); + 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(400)); + + 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 * INSET + count * SLOT + (count - 1.0) * 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(ICON).height(ICON).fill(Image::contain(path.clone())), + // no icon to find: its first letter + None => Rectangle::new() + .width(ICON) + .height(ICON) + .align_child(Center, Center) + .child(Text::new(item.title.chars().next().map_or(String::from("?"), |letter| letter.to_uppercase().collect())).size(12.0).weight(Weight::Bold).color(theme.text)), + }; + + let slot = Rectangle::new() + .width(SLOT) + .height(SLOT) + .radius(Full) + .fill(if open { faded(theme.accent, 90) } else { Color::blend(CLEAR, 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 + INSET + index as f32 * (SLOT + 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 pill = Rectangle::new() + .width(width) + .height(COMPACT_H) + .radius(Full) + .fill(backdrop(&theme.surfaces(), 255)) + .border(1.0, theme.outline) + .shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0)) + .padding(INSET) + .align_child(Start, Center) + .on_hover(edge::over_bar) + .child(Row::new(slots).gap(SLOT_GAP).align(Center)); + + 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_W - 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(ROW_H) + .radius(9.0) + .fill(Color::blend(CLEAR, 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(12.0).weight(Weight::Bold).color(theme.text).elide())]).gap(8.0).align(Center)), + ), + None => Box::new(Rectangle::new().width(inner_w).height(ROW_H).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(11.0).weight(Weight::Bold).color(theme.dim).elide())), + }; + + rows.push(heading); + height += ROW_H; + + for entry in open.entries.iter().take(MAX_ROWS) { + if entry.separator { + rows.push(Box::new(Rectangle::new().width(inner_w).height(SEPARATOR_H).align_child(Center, Center).child(Rectangle::new().width(inner_w - 16.0).height(1.0).fill(faded(theme.outline, 160))))); + height += SEPARATOR_H; + + 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(14.0).color(theme.dim)); + + let row = Rectangle::new() + .width(inner_w) + .height(ROW_H) + .radius(9.0) + .fill(Color::blend(CLEAR, 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(12.0).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 += ROW_H; + } + + let appear = ui.appear.value(); + + let content = Rectangle::new().width(MENU_W).height(height).padding(MENU_PAD).child(Column::new(rows)); + + let card = Rectangle::new() + .width(MENU_W) + .height(height) + .radius(16.0) + .fill(backdrop(&colors, 255)) + .border(1.0, theme.outline) + .shadow(Shadow::drop(theme.accent).opacity(0.35).blur(22.0)) + .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(); + } + }) + .child(veiled(content, MENU_W, height, 16.0, appear, &colors)); + + let left = (open.x - 8.0 - SHADOW).max(0.0); + let top = TOP_GAP + COMPACT_H + 6.0 - SHADOW; + + let region = InputArea { x: SHADOW as i32, y: SHADOW as i32, width: MENU_W as i32, height: height.ceil() as i32 }; + + window(MENU_W + 2.0 * SHADOW, height + 2.0 * 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(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"), + } +} diff --git a/src/workspaces.rs b/src/workspaces.rs index f7894b5..c085cba 100644 --- a/src/workspaces.rs +++ b/src/workspaces.rs @@ -5,12 +5,12 @@ use std::time::Duration; use amane::{ Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer, - Rectangle, Row, Service, Shadow, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone, + Rectangle, Row, Service, Shadow, Stack, Start, Text, Vertical, Weight, Widget, Workspace, Workspaces, Zone, }; -use crate::edge::{self, Edge, HIDE_H}; -use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded}; -use crate::theme; +use crate::edge::{self, Edge, HIDE_H, Part}; +use crate::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded, mix}; +use crate::{theme, tray}; // one round slot per workspace, inside the pill's inset; the pill sits LEFT px from the monitor's edge const SLOT: f32 = 28.0; @@ -18,6 +18,9 @@ const SLOT_GAP: f32 = 4.0; const INSET: f32 = 5.0; const LEFT: f32 = 12.0; +// between the workspaces' pill and the tray's +const TRAY_GAP: f32 = 8.0; + // each hovered workspace's highlight by workspace id, fading between 0 and 1 #[derive(Default)] struct Hover(HashMap); @@ -43,8 +46,12 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let workspaces = Workspaces::read(); let hover = Hover::read(); - // hidden until the pointer rests on the top edge (edge.rs) - let slide = edge::hidden_by(Edge::read().shown(&monitor.name), 0.0, HIDE_H); + // hidden on a fullscreen monitor until wanted, or for a moment on a workspace change (edge.rs) + let (slide, switched) = { + let edge = Edge::read(); + + (edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, HIDE_H), edge.switched(&monitor.name)) + }; // special workspaces (scratchpads) aren't places to switch to let here: Vec<&Workspace> = workspaces @@ -57,24 +64,59 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let count = here.len() as f32; let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP; - let window = LayerWindow::new() - .width(LEFT + width + SHADOW) - .height(TOP_GAP + COMPACT_H + SHADOW) - .anchor_vertical(Vertical::Top) - .anchor_horizontal(Horizontal::Left) - // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them - .layer(Layer::Overlay) - .space(Zone::Ignore) - .namespace("magic-workspaces"); + let window = |window_w: f32| { + LayerWindow::new() + .width(window_w) + .height(TOP_GAP + COMPACT_H + SHADOW) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Left) + // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-workspaces") + }; // empty and click-through rather than hidden: see the island's view for why if here.is_empty() || slide <= -HIDE_H { - return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + return window(LEFT + width + SHADOW).click_through().child(Rectangle::new().width(0.0).height(0.0)); } + // the tray's pill right of this one (tray.rs), when an app has an icon there + let tray = tray::pill(&monitor.name, LEFT + width + TRAY_GAP); + let tray_w = tray.as_ref().map_or(0.0, |(_, tray_w)| TRAY_GAP + tray_w); + + let window = window(LEFT + width + tray_w + SHADOW); + let mut slots: Vec> = Vec::new(); - for workspace in here { + /* + * the focused workspace's accent is one disc under the row, so a change slides it from + * the workspace before to this one (edge.rs times it); the slots themselves stay clear + */ + let theme = theme::now(); + let slot_x = |index: usize| index as f32 * (SLOT + SLOT_GAP); + + let focused_at = here.iter().position(|workspace| workspace.focused()); + let came_from = switched.and_then(|(from, t)| here.iter().position(|workspace| workspace.id() == from).map(|at| (at, t))); + + let highlight_x = match (focused_at, came_from) { + (Some(to), Some((from, t))) => { + // ease in and out, with a slight stretch in the middle like a drop of ink + let eased = t * t * (3.0 - 2.0 * t); + + Some(mix(slot_x(from), slot_x(to), eased)) + } + (Some(to), None) => Some(slot_x(to)), + _ => None, + }; + + let stretch = came_from.map_or(0.0, |(_, t)| (t * std::f32::consts::PI).sin() * 10.0); + + if switched.is_some() { + amane::request_frame(); + } + + for workspace in &here { let id = workspace.id(); let lit = hover.0.get(&id).map_or(0.0, Animation::value); @@ -84,10 +126,11 @@ pub fn view(monitor: &Monitor) -> LayerWindow { _ => workspace.index().to_string(), }; - let theme = theme::now(); - let (fill, color, weight) = if workspace.focused() { - (theme.accent, theme.on_accent, Weight::Bold) + // the label turns as the disc arrives + let arrived = switched.map_or(1.0, |(_, t)| t); + + (CLEAR, Color::blend(theme.text, theme.on_accent, arrived), Weight::Bold) } else if workspace.active() { (faded(theme.accent, 70), theme.accent, Weight::Bold) } else { @@ -111,7 +154,16 @@ pub fn view(monitor: &Monitor) -> LayerWindow { )); } - let theme = theme::now(); + let row_w = width - 2.0 * INSET; + + // the disc under the focused slot, a little wider while it's on its way + let mut layers: Vec> = Vec::new(); + + if let Some(x) = highlight_x { + layers.push(Box::new(Rectangle::new().width(SLOT + stretch).height(SLOT).radius(Full).fill(theme.accent).translate(x - stretch / 2.0, 0.0))); + } + + layers.push(Box::new(Row::new(slots).gap(SLOT_GAP).align(Center))); let pill = Rectangle::new() .width(width) @@ -123,24 +175,26 @@ pub fn view(monitor: &Monitor) -> LayerWindow { .padding(INSET) .align_child(Start, Center) .on_hover(edge::over_bar) - .child(Row::new(slots).gap(SLOT_GAP).align(Center)); + .child(Stack::new(layers).width(row_w).height(SLOT)); - // clicks next to the pill reach the windows underneath - let region = InputArea { - x: LEFT as i32, - y: TOP_GAP as i32, - width: width.ceil() as i32, - height: COMPACT_H as i32, - }; + // clicks next to the pills reach the windows underneath + let mut regions = vec![InputArea { x: LEFT as i32, y: TOP_GAP as i32, width: width.ceil() as i32, height: COMPACT_H as i32 }]; - window.input_region(vec![region]).child( + let mut pills: Vec> = vec![Box::new(pill)]; + + if let Some((tray, tray_pill_w)) = tray { + regions.push(InputArea { x: (LEFT + width + TRAY_GAP) as i32, y: TOP_GAP as i32, width: tray_pill_w.ceil() as i32, height: COMPACT_H as i32 }); + pills.push(Box::new(tray)); + } + + window.input_region(regions).child( Rectangle::new() .width(Parent) .height(Parent) .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT }) .align_child(Start, Start) .translate(0.0, slide) - .child(pill), + .child(Row::new(pills).gap(TRAY_GAP).align(Start)), ) } diff --git a/tools/fake_tray.py b/tools/fake_tray.py new file mode 100644 index 0000000..4a0f53a --- /dev/null +++ b/tools/fake_tray.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Fake StatusNotifierItem for tray tests. + +usage: fake_tray.py name|path [ICON_NAME] + name: owns org.kde.StatusNotifierItem--1, item at /StatusNotifierItem, IconName + path: registers its object path only (libappindicator style), IconPixmap pixels +Every call it receives is printed (Activate, ContextMenu, Scroll, menu Event...). +A menu with a checkmark, a separator, a submenu and a disabled entry. +""" +import os +import sys + +from gi.repository import Gio, GLib + +MODE = sys.argv[1] if len(sys.argv) > 1 else "name" +ICON = sys.argv[2] if len(sys.argv) > 2 else "audio-volume-high" +PATH = "/StatusNotifierItem" if MODE == "name" else "/org/ayatana/NotificationItem/fake_tray" +NAME = f"org.kde.StatusNotifierItem-{os.getpid()}-1" + +ITEM_XML = """ + + + + + + + + + + +""" + +MENU_XML = """ + + + + +""" + +TITLE = "Fake Tray " + MODE +muted = [True] + + +def entry(id, props, children=()): + return GLib.Variant("(ia{sv}av)", (id, props, [GLib.Variant("v", child) for child in children])) + + +def menu(parent): + sub = [ + entry(31, {"label": GLib.Variant("s", "Sub _A")}), + entry(32, {"label": GLib.Variant("s", "Sub B")}), + ] + top = [ + entry(1, {"label": GLib.Variant("s", "_Open Window")}), + entry(2, {"type": GLib.Variant("s", "separator")}), + entry(3, {"label": GLib.Variant("s", "Mute"), "toggle-type": GLib.Variant("s", "checkmark"), "toggle-state": GLib.Variant("i", 1 if muted[0] else 0)}), + entry(4, {"label": GLib.Variant("s", "More"), "children-display": GLib.Variant("s", "submenu")}, sub), + entry(5, {"label": GLib.Variant("s", "Disabled thing"), "enabled": GLib.Variant("b", False)}), + entry(6, {"label": GLib.Variant("s", "Hidden"), "visible": GLib.Variant("b", False)}), + entry(7, {"label": GLib.Variant("s", "Quit")}), + ] + children = sub if parent == 4 else top + return GLib.Variant("(u(ia{sv}av))", (1, (parent, {"children-display": GLib.Variant("s", "submenu")}, [GLib.Variant("v", child) for child in children]))) + + +def pixmap(): + size = 24 + data = bytearray() + for y in range(size): + for x in range(size): + inside = (x - 11.5) ** 2 + (y - 11.5) ** 2 < 100 + data += bytes([255 if inside else 0, 60, 200, 120]) if inside else bytes([0, 0, 0, 0]) + return [(size, size, bytes(data))] + + +def item_property(conn, sender, path, iface, name): + print("get", name, flush=True) + values = { + "Category": GLib.Variant("s", "ApplicationStatus"), + "Id": GLib.Variant("s", "fake_tray_" + MODE), + "Title": GLib.Variant("s", TITLE), + "Status": GLib.Variant("s", "Active"), + "IconName": GLib.Variant("s", ICON if MODE == "name" else ""), + "IconPixmap": GLib.Variant("a(iiay)", pixmap() if MODE == "path" else []), + "IconThemePath": GLib.Variant("s", ""), + "ItemIsMenu": GLib.Variant("b", False), + "Menu": GLib.Variant("o", "/MenuBar"), + } + return values.get(name) + + +def item_call(conn, sender, path, iface, method, params, invocation): + print("item", method, params.unpack(), flush=True) + invocation.return_value(None) + + +def menu_call(conn, sender, path, iface, method, params, invocation): + args = params.unpack() + print("menu", method, args, flush=True) + if method == "GetLayout": + invocation.return_value(menu(args[0])) + elif method == "AboutToShow": + invocation.return_value(GLib.Variant("(b)", (False,))) + else: + if method == "Event" and args[0] == 3: + muted[0] = not muted[0] + if method == "Event" and args[0] == 7: + GLib.idle_add(loop.quit) + invocation.return_value(None) + + +bus = Gio.bus_get_sync(Gio.BusType.SESSION) +bus.register_object(PATH, Gio.DBusNodeInfo.new_for_xml(ITEM_XML).interfaces[0], item_call, item_property, None) +bus.register_object("/MenuBar", Gio.DBusNodeInfo.new_for_xml(MENU_XML).interfaces[0], menu_call, None, None) + + +def register(*_): + service = NAME if MODE == "name" else PATH + try: + bus.call_sync("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", "org.kde.StatusNotifierWatcher", + "RegisterStatusNotifierItem", GLib.Variant("(s)", (service,)), None, 0, 3000, None) + print("registered", service, flush=True) + except GLib.Error as error: + print("no watcher:", error.message, flush=True) + + +if MODE == "name": + Gio.bus_own_name_on_connection(bus, NAME, 0, None, None) + +# like the real libraries: register again whenever a watcher appears +Gio.bus_watch_name_on_connection(bus, "org.kde.StatusNotifierWatcher", 0, register, None) + +loop = GLib.MainLoop() +loop.run()