Tray, fullscreen-only hiding, peeks, Ethernet details, wider maps

- tray.rs: the shell serves org.kde.StatusNotifierWatcher (only when no other
  watcher runs) and is its host; icons from the theme or from IconPixmap
  (png.rs, a stdlib png writer); left/middle/right click and scroll; dbusmenu
  menus drawn in the island's look; tray IPC verbs for testing
- edge.rs: the bar hides only on monitors showing a fullscreen window, driven
  by Hyprland's event socket; peeks of the workspaces (with the disc sliding)
  and the island (song change, new player), with a pop on the island
- prompt.rs: Ethernet details popup (addresses, gateway, DNS, MAC copyable)
- map.rs: 1100x560 maps, every other node a ring further in
- tools/fake_tray.py: a fake tray app, by bus name or by path
This commit is contained in:
server2
2026-10-13 04:58:08 +02:00
parent b14df3a435
commit 1e99d7264c
13 changed files with 1963 additions and 126 deletions
+15 -7
View File
@@ -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.<name> 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=<bool> menu=<name|none> wifi=<state>
amane ipc call control wifi join <SSID> # as a click on it in the map; also: wifi password <SSID>, wifi search
amane ipc call control bluetooth pair <ADDRESS> # also: bluetooth connect <ADDRESS>, bluetooth search
amane ipc call control prompt answer <text> # 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 <n> # the n-th icon's menu, as a right click; also: activate <n>
amane ipc call tray menu # the open menu's entries; then: choose <id>, enter <id>, 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.<name>` 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.<name>` 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
+19 -3
View File
@@ -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 <wifi|bluetooth|vpn|sound|close>|prompt <answer TEXT|cancel>|wifi <join|password> SSID|wifi search|bluetooth <pair|connect> ADDRESS|bluetooth search|status"),
_ => return String::from("usage: control open|close|toggle|menu <wifi|bluetooth|vpn|sound|close>|prompt <answer TEXT|cancel>|wifi <join|password> SSID|wifi search|bluetooth <pair|connect> 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)
+309 -39
View File
@@ -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<String, i64>,
focused: Option<String>,
fullscreen: HashSet<String>,
// how far the whole bar is in on each monitor, 0 to 1, and where the pointer brought it back
shown: HashMap<String, Animation>,
revealed: Option<String>,
// 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<String, (i64, Instant)>,
// the monitor whose top edge the pointer rests on, and whether it's over a part of the bar
at_edge: Option<String>,
@@ -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<PathBuf> {
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 <request>` 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<String> = 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<String> = 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>, String)> = None;
loop {
thread::sleep(Duration::from_millis(300));
let (players, song) = {
let media = Media::read();
let players: Vec<String> = 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())))
}
+15 -8
View File
@@ -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);
+4
View File
@@ -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();
+19 -8
View File
@@ -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<Node>) -> 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<Node>) -> 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;
+169
View File
@@ -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<i64> {
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::<u8>().is_ok_and(|mode| mode >= 2)
&& !found.contains(&workspace)
{
found.push(workspace);
}
}
found
}
// a card's addresses from `ip -o addr show dev <card>`: (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 <card>`, without repeats
pub fn ip_gateways(printed: &str) -> Vec<String> {
let mut gateways: Vec<String> = 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<String> {
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::<u32>().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
+121
View File
@@ -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<u8> {
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<u8> {
argb.chunks_exact(4).flat_map(|pixel| [pixel[1], pixel[2], pixel[3], pixel[0]]).collect()
}
fn chunk(png: &mut Vec<u8>, 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<u8> {
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]);
}
}
+107 -30
View File
@@ -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<String>)>),
}
#[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<usize>,
appear: Animation,
hover: HashMap<u8, Animation>,
@@ -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::<u32>().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<Box<dyn Widget>> = details.iter().enumerate().map(|(index, (label, value, copy_text))| Box::new(detail(&prompt, inner_w, index, label, value, copy_text.clone())) as Box<dyn Widget>).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<dyn Widget>]).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<dyn Widget>]).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<String>) -> 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<dyn Widget> = match &copy_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<dyn Widget>,
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()
+61
View File
@@ -471,6 +471,67 @@ pub fn saved_password(ssid: &str) -> Result<String, String> {
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<String>)> {
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<String>)> = Vec::new();
let mut row = |label: &str, value: String, copy: Option<String>| 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::<u32>().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::<u64>().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();
+905
View File
@@ -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#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="org.kde.StatusNotifierWatcher">
<method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
<method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
<signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
<signal name="StatusNotifierHostRegistered"/>
</interface>
</node>"#;
// one app in the tray
#[derive(Clone, PartialEq)]
pub struct Item {
// what RegisteredStatusNotifierItems lists: its bus name and object path, together
pub key: String,
bus: String,
path: String,
// its unique name (":1.42"), which its signals come from
owner: String,
pub title: String,
pub icon: Option<PathBuf>,
// NeedsAttention: a ring around it; Passive: not shown
pub attention: bool,
passive: bool,
// its dbusmenu's object path, and whether a click should open that rather than activate
menu: String,
only_menu: bool,
}
#[derive(Default)]
pub struct Tray {
pub items: Vec<Item>,
}
impl Service for Tray {
fn new() -> Self {
Self::default()
}
fn listen() {
let bus = Bus::session();
// the calls are watched before the name is taken, so none is missed
let calls = bus.methods(WATCHER_PATH, WATCHER);
let properties = bus.methods(WATCHER_PATH, PROPERTIES);
let introspection = bus.methods(WATCHER_PATH, "org.freedesktop.DBus.Introspectable");
// another watcher runs (another bar): leave it be, and show nothing
if !bus.own(WATCHER) {
return;
}
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierHostRegistered", &[]);
thread::spawn(move || {
for call in properties {
let value = |name: &str| match name {
"RegisteredStatusNotifierItems" => Some(Argument::TextList(Tray::read().items.iter().map(|item| item.key.clone()).collect())),
"IsStatusNotifierHostRegistered" => Some(Argument::Bool(true)),
"ProtocolVersion" => Some(Argument::Int(0)),
_ => None,
};
match call.name() {
"Get" => {
let name = call.arguments().get(1).map(|name| name.text().to_string()).unwrap_or_default();
match value(&name) {
Some(value) => call.reply(&[Argument::Variant(Box::new(value))]),
None => call.reply(&[]),
}
}
"GetAll" => {
let all = ["RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"]
.into_iter()
.filter_map(|name| Some((name.to_string(), value(name)?)))
.collect();
call.reply(&[Argument::Map(all)]);
}
_ => call.reply(&[]),
}
}
});
thread::spawn(move || {
for call in introspection {
call.reply(&[Argument::from(INTROSPECTION)]);
}
});
// an app that quits leaves the bus: its icon goes
thread::spawn(|| {
for signal in Bus::session().signals("org.freedesktop.DBus", "NameOwnerChanged") {
let arguments = signal.arguments();
let (name, gone) = (arguments.first().map_or("", Value::text), arguments.get(2).is_some_and(|owner| owner.text().is_empty()));
if gone && !name.is_empty() {
forget(name);
}
}
});
// an app that changed its icon, title or status says so
for change in ["NewIcon", "NewAttentionIcon", "NewStatus", "NewTitle", "NewToolTip", "NewMenu"] {
thread::spawn(move || {
for signal in Bus::session().signals(ITEM, change) {
let stale: Vec<Item> = Tray::read().items.iter().filter(|item| item.owner == signal.sender() && item.path == signal.path()).cloned().collect();
for item in stale {
refresh(&item.bus, &item.path, &item.owner);
}
}
});
}
for call in calls {
let name = call.name().to_string();
let service = call.arguments().first().map(|service| service.text().to_string()).unwrap_or_default();
// answered at once: the app waits for it
call.reply(&[]);
if name == "RegisterStatusNotifierItem" && !service.is_empty() {
thread::spawn(move || register(&service));
}
}
}
}
/*
* an app's registration: a bus name (its item at /StatusNotifierItem), or, from
* libappindicator, only the object path. amane doesn't say who called, so for a path
* the newest connections are asked which one has that item
*/
fn register(service: &str) {
let bus = Bus::session();
let (name, path) = if service.starts_with('/') {
let Some(owner) = find_owner(&bus, service) else {
return;
};
(owner, service.to_string())
} else {
match service.split_once('/') {
Some((name, path)) => (name.to_string(), format!("/{path}")),
None => (service.to_string(), String::from("/StatusNotifierItem")),
}
};
let owner = if name.starts_with(':') {
name.clone()
} else {
bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner", &[Argument::from(name.as_str())]).text().to_string()
};
if owner.is_empty() {
return;
}
refresh(&name, &path, &owner);
let key = format!("{name}{path}");
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemRegistered", &[Argument::from(key)]);
}
/*
* the connection that serves an item at this path. asked all at once, first answer
* wins: some connections never answer a call they don't know (the shell's own among
* them), and each of those would hold a one-by-one search for the bus's whole timeout
*/
fn find_owner(bus: &Bus, path: &str) -> Option<String> {
let names = bus.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames", &[]);
let (found, answers) = mpsc::channel();
for name in names.list().iter().map(Value::text).filter(|name| name.starts_with(':')) {
let (name, path, found) = (name.to_string(), path.to_string(), found.clone());
thread::spawn(move || {
let answer = Bus::session().call(&name, &path, PROPERTIES, "Get", &[Argument::from(ITEM), Argument::from("Id")]);
if !matches!(answer, Value::Nothing) {
let _ = found.send(name);
}
});
}
drop(found);
answers.recv_timeout(Duration::from_secs(5)).ok()
}
// reads an item again and puts it in the tray, or takes it out if it doesn't answer
fn refresh(name: &str, path: &str, owner: &str) {
let key = format!("{name}{path}");
let all = Bus::session().call(name, path, PROPERTIES, "GetAll", &[Argument::from(ITEM)]);
if !matches!(all, Value::Map(_)) {
Tray::write().items.retain(|item| item.key != key);
return;
}
let text = |property: &str| all.get(property).text().to_string();
let status = text("Status");
let attention = status == "NeedsAttention";
// the attention icon while it wants attention, else the icon; a name before pixels
let (name_property, pixmap_property) = if attention && !text("AttentionIconName").is_empty() { ("AttentionIconName", "AttentionIconPixmap") } else { ("IconName", "IconPixmap") };
let icon = icon_file(&text(name_property), &text("IconThemePath")).or_else(|| pixmap_file(all.get(pixmap_property)));
// the tooltip's title is often the clearest name
let tooltip = all.get("ToolTip").list().get(2).map(|title| title.text().to_string()).unwrap_or_default();
let title = [text("Title"), tooltip, text("Id")].into_iter().find(|title| !title.is_empty()).unwrap_or_default();
let item = Item {
key: key.clone(),
bus: name.to_string(),
path: path.to_string(),
owner: owner.to_string(),
title,
icon,
attention,
passive: status == "Passive",
menu: text("Menu"),
only_menu: all.get("ItemIsMenu").bool(),
};
let mut tray = Tray::write();
match tray.items.iter_mut().find(|known| known.key == key) {
Some(known) if *known == item => {}
Some(known) => *known = item,
None => tray.items.push(item),
}
}
fn forget(name: &str) {
let gone: Vec<String> = Tray::read().items.iter().filter(|item| item.owner == name || item.bus == name).map(|item| item.key.clone()).collect();
if gone.is_empty() {
return;
}
Tray::write().items.retain(|item| !gone.contains(&item.key));
let bus = Bus::session();
for key in gone {
bus.emit(WATCHER_PATH, WATCHER, "StatusNotifierItemUnregistered", &[Argument::from(key)]);
}
}
// icons
/*
* an icon name as a file: the app's own theme folder first, then the icon themes
* (gtk's choice, hicolor, Adwaita), then /usr/share/pixmaps. themes are walked once
* per name, the answer kept
*/
fn icon_file(name: &str, theme_path: &str) -> Option<PathBuf> {
static FOUND: Mutex<Option<HashMap<String, Option<PathBuf>>>> = Mutex::new(None);
if name.is_empty() {
return None;
}
if name.starts_with('/') {
return Path::new(name).exists().then(|| PathBuf::from(name));
}
let key = format!("{theme_path}\u{1f}{name}");
if let Some(found) = FOUND.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(HashMap::new).get(&key) {
return found.clone();
}
let home = env::var("HOME").unwrap_or_default();
let bases = [format!("{home}/.local/share/icons"), format!("{home}/.icons"), String::from("/usr/share/icons")];
let mut themes: Vec<String> = Vec::new();
let gtk = fs::read_to_string(format!("{home}/.config/gtk-3.0/settings.ini")).unwrap_or_default();
if let Some(chosen) = gtk.lines().find_map(|line| line.trim().strip_prefix("gtk-icon-theme-name")).map(|rest| rest.trim_start_matches([' ', '=']).trim().to_string()) {
themes.push(chosen);
}
themes.extend(["hicolor", "AdwaitaLegacy", "Adwaita"].map(String::from));
let mut roots: Vec<PathBuf> = Vec::new();
if !theme_path.is_empty() {
roots.push(PathBuf::from(theme_path));
}
for theme in &themes {
for base in &bases {
roots.push(Path::new(base).join(theme));
}
}
let mut found = roots.iter().find_map(|root| best_in(root, name));
if found.is_none() {
found = ["png", "svg", "xpm"].iter().map(|extension| PathBuf::from(format!("/usr/share/pixmaps/{name}.{extension}"))).find(|path| path.exists() && !path.ends_with(".xpm"));
}
FOUND.lock().unwrap_or_else(PoisonError::into_inner).get_or_insert_with(HashMap::new).insert(key, found.clone());
found
}
// the best-fitting file for this name anywhere under a theme folder (see parse::icon_score)
fn best_in(root: &Path, name: &str) -> Option<PathBuf> {
let mut best: Option<(u32, PathBuf)> = None;
let mut folders = vec![(root.to_path_buf(), 0)];
while let Some((folder, depth)) = folders.pop() {
let Ok(entries) = fs::read_dir(&folder) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if depth < 3 {
folders.push((path, depth + 1));
}
continue;
}
let stem = path.file_stem().and_then(|stem| stem.to_str());
let extension = path.extension().and_then(|extension| extension.to_str());
if stem == Some(name) && matches!(extension, Some("png" | "svg")) {
let score = parse::icon_score(&path.to_string_lossy());
if best.as_ref().is_none_or(|(known, _)| score < *known) {
best = Some((score, path));
}
}
}
}
best.map(|(_, path)| path)
}
/*
* IconPixmap as a png: the size nearest 32 px of those it sends, in the runtime folder,
* named after its pixels so a new icon is a new path (amane keeps images by path)
*/
fn pixmap_file(pixmaps: &Value) -> Option<PathBuf> {
let chosen = pixmaps.list().iter().filter(|pixmap| pixmap.list().len() == 3).min_by_key(|pixmap| (pixmap.list()[0].number() as i64 - 32).abs())?;
let (width, height) = (chosen.list()[0].number() as u32, chosen.list()[1].number() as u32);
let argb: Vec<u8> = chosen.list()[2].list().iter().map(|byte| byte.number() as u8).collect();
if width == 0 || height == 0 || argb.len() != (width * height * 4) as usize {
return None;
}
// fnv-1a over the pixels
let hash = argb.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3));
let folder = Path::new(&env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| String::from("/tmp"))).join("amane-tray");
let path = folder.join(format!("{hash:016x}.png"));
if !path.exists() {
fs::create_dir_all(&folder).ok()?;
fs::write(&path, png::encode(width, height, &png::argb_to_rgba(&argb))).ok()?;
}
Some(path)
}
// what a click, a middle click or a scroll asks of an app; all on threads
fn activate(item: &Item, method: &'static str) {
let item = item.clone();
thread::spawn(move || {
Bus::session().call(&item.bus, &item.path, ITEM, method, &[Argument::Int(0), Argument::Int(0)]);
});
}
fn scroll(item: &Item, delta: i32) {
let item = item.clone();
thread::spawn(move || {
Bus::session().call(&item.bus, &item.path, ITEM, "Scroll", &[Argument::Int(delta), Argument::from("vertical")]);
});
}
// the menu
// one entry of an app's menu
#[derive(Clone, PartialEq)]
struct Entry {
id: i32,
label: String,
enabled: bool,
separator: bool,
// a checkmark or radio, and whether it's on
toggle: Option<bool>,
submenu: bool,
}
#[derive(Clone)]
struct Open {
item: Item,
monitor: String,
// the menu's left edge, from the monitor's left
x: f32,
// the submenus entered, with their labels, for the way back
trail: Vec<(i32, String)>,
entries: Vec<Entry>,
}
pub struct TrayUi {
// icons and menu rows under the pointer
hover: HashMap<String, Animation>,
open: Option<Open>,
appear: Animation,
inside: bool,
// bumped when a menu opens or the pointer comes back, so an older close timer does nothing
moves: u32,
}
impl Service for TrayUi {
fn new() -> Self {
Self { hover: HashMap::new(), open: None, appear: Animation::new(0.0), inside: false, moves: 0 }
}
// changes only through input and the apps' answers
fn listen() {}
}
fn hovering(spot: String) -> impl Fn(bool) + 'static {
move |inside| {
TrayUi::write()
.hover
.entry(spot.clone())
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
.to(if inside { 1.0 } else { 0.0 });
}
}
/*
* the app's menu (or the submenu `parent`): AboutToShow lets it fill in what it fills
* in late, GetLayout gives the entries. (u revision, (i id, a{sv} properties, av children))
*/
fn layout(item: &Item, parent: i32) -> Vec<Entry> {
let bus = Bus::session();
bus.call(&item.bus, &item.menu, MENU, "AboutToShow", &[Argument::Int(parent)]);
let answer = bus.call(&item.bus, &item.menu, MENU, "GetLayout", &[Argument::Int(parent), Argument::Int(1), Argument::TextList(Vec::new())]);
let root = answer.list().get(1);
let children = root.and_then(|root| root.list().get(2)).map_or(&[][..], Value::list);
children
.iter()
.filter_map(|child| {
let fields = child.list();
let properties = fields.get(1)?;
// missing properties have their defaults: visible, enabled, a plain entry
let visible = !matches!(properties.get("visible"), Value::Bool(false));
if !visible {
return None;
}
let toggle_type = properties.get("toggle-type").text();
Some(Entry {
id: fields.first()?.number() as i32,
label: parse::strip_mnemonic(properties.get("label").text()),
enabled: !matches!(properties.get("enabled"), Value::Bool(false)),
separator: properties.get("type").text() == "separator",
toggle: (!toggle_type.is_empty()).then(|| properties.get("toggle-state").number() == 1.0),
submenu: properties.get("children-display").text() == "submenu",
})
})
.collect()
}
// opens the app's menu under its icon, once it has answered
fn open_menu(item: &Item, monitor: &str, x: f32) {
let (item, monitor) = (item.clone(), monitor.to_string());
thread::spawn(move || {
if item.menu.is_empty() {
Bus::session().call(&item.bus, &item.path, ITEM, "ContextMenu", &[Argument::Int(0), Argument::Int(0)]);
return;
}
let entries = layout(&item, 0);
if entries.is_empty() {
return;
}
let mut ui = TrayUi::write();
ui.open = Some(Open { item, monitor, x, trail: Vec::new(), entries });
ui.appear = Animation::new(0.0).duration(Duration::from_millis(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<Box<dyn Widget>> = Vec::new();
for (index, item) in shown.iter().enumerate() {
let spot = format!("icon:{}", item.key);
let lit = ui.hover.get(&spot).map_or(0.0, Animation::value);
let open = ui.open.as_ref().is_some_and(|open| open.item.key == item.key && open.monitor == monitor);
let picture = match &item.icon {
Some(path) => Rectangle::new().width(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<Box<dyn Widget>> = Vec::new();
let mut height = 2.0 * MENU_PAD;
// the app's name, or the submenu's with the way back
let lit_back = ui.hover.get("row:back").map_or(0.0, Animation::value);
let heading: Box<dyn Widget> = match open.trail.last() {
Some((_, label)) => Box::new(
Rectangle::new()
.width(inner_w)
.height(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<dyn Widget>, 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<dyn Widget> = match entry.toggle {
Some(true) => Box::new(Rectangle::new().width(14.0).height(14.0).align_child(Center, Center).child(Rectangle::new().width(7.0).height(7.0).radius(Full).fill(theme.accent))),
_ => Box::new(Rectangle::new().width(14.0).height(14.0)),
};
let more: Box<dyn Widget> = Box::new(Text::new(if entry.submenu { "›" } else { "" }).size(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<dyn Widget>,
more,
])
.justify(SpaceBetween)
.align(Center)
.width(inner_w - 14.0),
);
let row = if entry.enabled {
let (item, id, label, submenu) = (open.item.clone(), entry.id, entry.label.clone(), entry.submenu);
row.cursor(Pointer).on_hover(hovering(spot)).on_click(move |_| if submenu { enter(Some((id, label.clone()))) } else { choose(&item, id) })
} else {
row
};
rows.push(Box::new(row));
height += 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::<Vec<_>>().join("\n"),
["menu"] => TrayUi::read().open.as_ref().map_or(String::from("closed"), |open| open.entries.iter().map(|entry| if entry.separator { String::from("---") } else { format!("{} {}{}{}", entry.id, entry.label, if entry.toggle == Some(true) { " [on]" } else { "" }, if entry.submenu { " >" } else { "" }) }).collect::<Vec<_>>().join("\n")),
["open", index] | ["activate", index] => {
let Some(item) = index.parse::<usize>().ok().and_then(|index| Tray::read().items.iter().filter(|item| !item.passive).nth(index).cloned()) else {
return String::from("no such item");
};
if words[0] == "open" {
open_menu(&item, &crate::island::focused_monitor().unwrap_or_default(), 200.0);
} else {
activate(&item, "Activate");
}
String::from("ok")
}
["choose", id] | ["enter", id] => {
let Some((item, entry)) = TrayUi::read().open.as_ref().and_then(|open| Some((open.item.clone(), open.entries.iter().find(|entry| entry.id.to_string() == *id)?.clone()))) else {
return String::from("no such entry");
};
if entry.submenu { enter(Some((entry.id, entry.label))) } else { choose(&item, entry.id) }
String::from("ok")
}
["back"] => {
enter(None);
String::from("ok")
}
["close"] => {
close_menu();
String::from("ok")
}
_ => String::from("usage: tray list|menu|open N|activate N|choose ID|enter ID|back|close"),
}
}
+79 -25
View File
@@ -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<i64, Animation>);
@@ -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)
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");
.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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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<Box<dyn Widget>> = 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)),
)
}
+134
View File
@@ -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-<pid>-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 = """<node><interface name="org.kde.StatusNotifierItem">
<property name="Category" type="s" access="read"/><property name="Id" type="s" access="read"/>
<property name="Title" type="s" access="read"/><property name="Status" type="s" access="read"/>
<property name="IconName" type="s" access="read"/><property name="IconPixmap" type="a(iiay)" access="read"/>
<property name="IconThemePath" type="s" access="read"/><property name="ItemIsMenu" type="b" access="read"/>
<property name="Menu" type="o" access="read"/>
<method name="Activate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="SecondaryActivate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="ContextMenu"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="Scroll"><arg type="i" direction="in"/><arg type="s" direction="in"/></method>
<signal name="NewIcon"/><signal name="NewStatus"><arg type="s"/></signal>
</interface></node>"""
MENU_XML = """<node><interface name="com.canonical.dbusmenu">
<method name="GetLayout"><arg type="i" direction="in"/><arg type="i" direction="in"/><arg type="as" direction="in"/>
<arg type="u" direction="out"/><arg type="(ia{sv}av)" direction="out"/></method>
<method name="AboutToShow"><arg type="i" direction="in"/><arg type="b" direction="out"/></method>
<method name="Event"><arg type="i" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="in"/><arg type="u" direction="in"/></method>
</interface></node>"""
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()