Compare commits

10 Commits
Author SHA1 Message Date
server2 35386e84f1 Maps fit beside the media island
The map menus' width follows the monitor: from its right edge to the
island's compact pill, less a 16 px gap (the pill's pop is 15 px a side);
822 px on 1920. AREA_H 480, no alternating rings (simulated: no label
overlaps up to 8 nodes at this size). The password popup centres on it.
2026-10-13 06:09:06 +02:00
server2 a7c49a16c0 Ethernet details in the panel, smaller maps, no tray in workspace peeks
- control.rs: Menu::Ethernet; the panel morphs into the card's details
  (gathered off the drawing thread first) instead of a separate popup;
  prompt::copy now takes a callback so both use wl-copy the same way
- prompt.rs: the Details popup is gone
- map.rs: 920x460, at most 8 nodes, lighter nearness pull (simulated:
  no label overlaps up to 8 nodes while drifting)
- workspaces.rs: a peek on a workspace change shows the workspaces alone
2026-10-13 05:40:44 +02:00
server2 1e99d7264c 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
2026-10-13 04:58:08 +02:00
server2 b14df3a435 Control centre: popups via iwctl/bluetoothctl, one-shape menus, search rings, Ethernet and link speeds, auto-hiding bar
- prompt.rs: popup for Wi-Fi passwords, Bluetooth PINs/passkeys/codes, saved
  passwords (Copy through wl-copy on stdin) and failure reasons; joins through
  iwctl, pairs through a bluetoothctl session; only popups with a field take
  the keyboard (an exclusive grab let stray keystrokes close failure popups)
- menus: the panel is the only shape; the menu window fades its content in
  once the panel has taken the menu's size
- maps: clicking the bubble searches again (iwctl scan, bluetoothctl scan)
  with rings running out from the click
- Ethernet tile and pill icon from sysfs; link speed badges (Ethernet speed,
  Wi-Fi RxBitrate from iwd diagnostics, at most once a minute)
- edge.rs: bar hidden until the pointer rests on the top edge; nothing is
  read or drawn while hidden
- IPC: control wifi join|password|search, bluetooth pair|connect|search,
  prompt answer|cancel, status prompt=
2026-10-13 02:59:22 +02:00
server2 afe963dfea Revert "control centre: ask for Wi-Fi passwords and Bluetooth PINs in a popup"
This reverts commit 4d4166dadc.
2026-10-12 01:14:39 +02:00
server2 4d4166dadc control centre: ask for Wi-Fi passwords and Bluetooth PINs in a popup
The shell registers as iwd's agent and as BlueZ's default agent (KeyboardDisplay),
each only while its daemon runs (GetNameOwner, never a call that would start it), and
again after a restart. RequestPassphrase, RequestPinCode, RequestPasskey,
RequestConfirmation, RequestAuthorization, DisplayPinCode and DisplayPasskey show a
small popup over the control centre that takes the keyboard (Enter answers, Escape
cancels); the answer is the reply to the daemon's call. Amane can only reply with
success, so Cancel leaves the call unanswered and cancels the pairing or the joining.
New Wi-Fi networks with a password are clickable on the map now; IPC control prompt
answer|cancel.
2026-10-11 08:56:13 +02:00
server2 8fa951fe15 control centre: Wi-Fi and Bluetooth menus as a map around this desktop
The menus are 820 px wide (the panel resizes to them). This desktop is a bubble in
the middle, networks and devices float around it, nearer the stronger (or the more
connected) they are, rings ripple out while scanning. Each is tied to the bubble by a
line: grey and wobbling while idle, running out from the bubble after a click until
it's connected, solid with a travelling pulse once connected.

The map is one Canvas: node discs and icons are shapes in it (glyph_shapes), the node
widgets only take the pointer. Seven icon Canvas widgets had cost 4.6 ms of gpu a
frame and halved the frame rate; now 1.8 ms at 60 fps. The menu window is sized to
its menu.
2026-10-11 02:58:50 +02:00
server2 7bbb23d9ca control centre: pill shows only running services, struck tiles, menus close in place
The pill drops the icon of a service that isn't running (iwd, bluetoothd, the openvpn
service) and is as wide as what it shows. A tile whose service isn't running is grey
with a grey strike through its icon. Leaving a menu with the pointer fades its content
where it is and shrinks it straight into the pill; the panel no longer grows back to
its full size on the way.
2026-10-10 12:32:44 +02:00
server2 318b2a8443 control centre: menus for Wi-Fi, Bluetooth, VPN and Sound in their own window
The tile or block grows into its menu; the panel fades its content and takes the
menu's size underneath. Wi-Fi lists networks (join saved/open, disconnect, stop
iwd), Bluetooth lists devices, VPN has a switch per openvpn service, Sound has
every output with its own slider and a default picker. A second Sound slider for
the output named by extra_output in shell.conf (NR515). Screen and keyboard light
are written to sysfs directly: logind refuses a shell without a seat.
2026-10-10 12:13:34 +02:00
server2 1b571abef0 control centre: status pill top right that opens into a macOS-like panel
Wi-Fi and hotspot (iwd, never D-Bus-activated by a read), VPN (openrc openvpn.*),
Bluetooth (bluez), battery, volume, screen and keyboard light, performance mode
(platform profile + governor + EPP through doas -n), and the external GPU with the
programs using it (drm fdinfo). Icons move to glyph.rs.
2026-10-10 11:33:04 +02:00
17 changed files with 6807 additions and 155 deletions
+100 -3
View File
@@ -11,7 +11,91 @@ 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. - **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. - **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. - **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).
- **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 alone (without the tray) 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
The pill shows only what runs: the Ethernet socket while a cable is connected, Wi-Fi signal while iwd runs (dim while its radio is off; the hotspot sign instead while it's on), the VPN lock only while a VPN connects or is connected (accent once connected), Bluetooth while bluetoothd runs (dim while powered off, accent with a device connected), and the battery with its percentage (a bolt while charging, red below 15 %). The pill is as wide as what it shows.
The panel has:
- **Tiles:** Wi-Fi, Bluetooth, VPN, Hotspot, and Ethernet across the panel when the machine has a wired card. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it (`iwctl station <dev> connect`), the connected one to disconnect; a new network asks for its password in a popup first (802.1x networks still need `iwctl`). If joining fails, the popup says why, in iwctl's words. Right-click a saved network for its saved password (read with `doas -n cat /var/lib/iwd/<profile>.psk`), which Copy (or a click on it) puts on the clipboard through `wl-copy`. Click the bubble in the middle to search again: rings run out from it while iwd scans. The tile and the connected network show the receive link rate (iwd's diagnostics, asked at most once a minute). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
- *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). A click turns the panel into its details (it morphs like into any menu; the back button returns): 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 reach from the monitor's right edge to the media island's pill, leaving a 16 px gap, room for the pill's pop: 822 × 480 px on a 1920 px monitor; at most 8 networks or devices, the strongest first, so their labels never 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.
- **External GPU:** the GPU the machine didn't boot with (`boot_vga = 0`, here the Radeon RX 6600 in the Thunderbolt enclosure): load, temperature, power draw, video memory, and every program that has it open with its video memory (and its share of the GPU while the driver reports engine time). Found through `/proc/<pid>/fdinfo` (`drm-pdev`), only while the panel is open. Root's processes can't be read and don't show; the shell itself shows up, because Vulkan opens every GPU.
Changes that need root (starting services, the performance mode) run as `doas -n <fixed command>`, so they need a doas rule without a password for these commands (here `permit nopass fabian`). Nothing from other programs is passed to a shell.
```sh
amane ipc call control toggle # also: open, close
amane ipc call control menu wifi # also: bluetooth, vpn, sound, close
amane ipc call control status # expanded=<bool> menu=<name|none> wifi=<state> vpn=<state> profile=<p> battery=<n%> prompt=<what the popup shows|none>
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
iwd turns the Wi-Fi device into an access point, so the laptop isn't a Wi-Fi client while the hotspot is on (the internet then comes from `eth0`). Once, as root:
1. Let iwd hand out addresses, in `/etc/iwd/main.conf`. This also makes iwd configure IPv4 for Wi-Fi as a client:
```ini
[General]
EnableNetworkConfiguration=true
```
2. A profile `/var/lib/iwd/ap/<Name>.ap` (`man iwd.ap`):
```ini
[Security]
Passphrase=at-least-eight-characters
[IPv4]
Address=192.168.80.1
DNSList=<your router, or a public resolver>
```
3. `hotspot = <Name>` in `shell.conf` (applies live).
4. To share `eth0`'s internet with the hotspot's devices, forwarding and NAT (iwd doesn't do this), e.g. with nftables:
```sh
sysctl -w net.ipv4.ip_forward=1
nft add table ip hotspot
nft 'add chain ip hotspot post { type nat hook postrouting priority 100 ; }'
nft add rule ip hotspot post ip saddr 192.168.80.0/24 oifname eth0 masquerade
```
Clicking the tile switches the device to access-point mode and calls `StartProfile(<Name>)`; if that fails (no such profile) it switches back to a client. The tile shows the profile's name, then how many devices are connected.
### External monitor brightness
External monitors take brightness over DDC/CI (VCP code `10`), which `ddcutil` speaks. Not wired in yet; to try it:
1. `emerge --ask app-misc/ddcutil`
2. Load `i2c-dev`: `modprobe i2c-dev`, and to keep it, `modules="i2c-dev"` in `/etc/conf.d/modules`.
3. Access without root: ddcutil's udev rule gives the `/dev/i2c-*` buses to group `i2c`; `gpasswd -a fabian i2c`, then log in again.
4. Turn on DDC/CI in each monitor's on-screen menu, if it has the option.
5. `ddcutil detect` lists the monitors; `ddcutil --display 1 getvcp 10` reads one's brightness, `ddcutil --display 1 setvcp 10 60` sets it.
Each call takes 50–300 ms, so a slider has to send only where the drag ends. Monitors behind docks or MST hubs often don't pass DDC; the eGPU's own DP and HDMI ports usually do. Once `ddcutil detect` works, the Display block can get one slider per external monitor.
## Colours ## Colours
@@ -29,8 +113,12 @@ The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`).
``` ```
cover_colors = false # true: the open media card and equalizer take the song cover's colours as background cover_colors = false # true: the open media card and equalizer take the song cover's colours as background
transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly
hotspot = MyHotspot # the iwd access point profile the control centre's hotspot starts
extra_output = NR515 # a second Sound slider for the output whose name contains this
``` ```
Keep comments on lines of their own in the real file: a `#` after a value becomes part of it.
## Layout ## Layout
``` ```
@@ -40,12 +128,20 @@ src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli,
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava) src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
src/art.rs cover download (curl) and the palette file src/art.rs cover download (curl) and the palette file
src/workspaces.rs the workspace pill, top left on every monitor 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 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 src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
src/parse.rs pure text parsing with unit tests, no amane dependency src/parse.rs pure text parsing with unit tests, no amane dependency
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
shell.conf settings: cover_colors, transition_ms shell.conf settings: cover_colors, transition_ms, hotspot, extra_output
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) 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 ## Requirements
@@ -56,6 +152,7 @@ On the desktop that runs the shell:
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module) - PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
- `cava`, `curl` - `cava`, `curl`
- the Fira Code font - the Fira Code font
- 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 ## Install and run
+7
View File
@@ -7,3 +7,10 @@ cover_colors = false
# how long a colour change cross-fades, in milliseconds; 0 switches instantly # how long a colour change cross-fades, in milliseconds; 0 switches instantly
transition_ms = 400 transition_ms = 400
# the control centre's hotspot: the name of an iwd access point profile, /var/lib/iwd/ap/<name>.ap
# (see README "Control centre"); leave it out and the hotspot tile says so
# hotspot = MyHotspot
# a second slider under Sound in the control centre, for the output whose name contains this
extra_output = NR515
+2207
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -0,0 +1,412 @@
/*
* 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, 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, 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 {
// 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>,
over_bar: bool,
// bumped by every arrival and departure, so a timer an earlier one started does nothing
moves: u32,
}
impl Service for Edge {
fn new() -> Self {
Self::default()
}
// 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(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);
}
}
// the bar's parts call this from their on_hover
pub fn over_bar(inside: bool) {
let moves = {
let mut edge = Edge::write();
edge.over_bar = inside;
edge.moves += 1;
edge.moves
};
if !inside {
hide_soon(moves);
}
}
fn at_edge(monitor: String) -> impl Fn(bool) + 'static {
move |inside| {
let moves = {
let mut edge = Edge::write();
edge.at_edge = inside.then(|| monitor.clone());
edge.moves += 1;
edge.moves
};
if !inside {
hide_soon(moves);
return;
}
let monitor = monitor.clone();
// 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 {
edge.revealed = Some(monitor);
settle(&mut edge);
}
});
}
}
// 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));
let mut edge = Edge::write();
if edge.moves == moves && edge.at_edge.is_none() && !edge.over_bar {
edge.revealed = None;
for (peeking, _) in edge.peeks.values_mut() {
peeking.to(0.0);
}
settle(&mut edge);
}
});
}
fn sliding(at: f32) -> Animation {
Animation::new(at).duration(Duration::from_millis(SLIDE_MS))
}
/*
* how far a part of the bar slides up out of the monitor: all of `height` at 0, none
* at 1. `open` is the part's own card or panel, which keeps it in while it's open
*/
pub fn hidden_by(shown: f32, open: f32, height: f32) -> f32 {
-height * (1.0 - shown.max(open))
}
// 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 {
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())))
}
+224
View File
@@ -0,0 +1,224 @@
// icons drawn as shapes, not typed, so the shell looks the same whatever fonts are installed
use amane::{Arc, Canvas, Cap, Circle, Color, Line, Path, Shape, shapes};
#[derive(Clone, Copy, PartialEq)]
pub enum Glyph {
Play,
Pause,
Previous,
Next,
Speaker,
Muted,
Bluetooth,
Chevron,
// pointing left, for a menu's way back
Back,
Note,
// wi-fi with 0 to 3 of its arcs lit; the unlit ones are drawn faint
Wifi(u8),
Hotspot,
Lock,
Sun,
Keyboard,
Leaf,
Gauge,
Bolt,
Chip,
// this machine, in the middle of the Wi-Fi and Bluetooth maps
Laptop,
// a network socket, for a wired link
Ethernet,
}
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
bold_icon(glyph, size, color, 0.0)
}
// play, pause, previous and next thickened by an outline `weight` px wide in their own colour; 0 draws none
pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
Canvas::new().width(size).height(size).shapes(glyph_shapes(glyph, size, color, weight, (0.0, 0.0)))
}
/*
* the glyph's shapes with its top left at `origin`, for drawing many icons into one
* Canvas: every Canvas widget costs the gpu on its own (7 small icon canvases took a
* map from 1.8 to 6.4 ms a frame), shapes inside one canvas cost next to nothing
*/
pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: (f32, f32)) -> Vec<Box<dyn Shape>> {
let s = size;
let (ox, oy) = origin;
// a fraction of the glyph's box, as a point on the canvas
let x = move |k: f32| ox + k * s;
let y = move |k: f32| oy + k * s;
let triangle = |x0: f32, x1: f32| Path::new().move_to(x(x0), y(0.14)).line_to(x(x1), y(0.5)).line_to(x(x0), y(0.86)).close().fill(color).stroke(weight, color);
let bar = |x0: f32, x1: f32| {
Path::new()
.move_to(x(x0), y(0.14))
.line_to(x(x1), y(0.14))
.line_to(x(x1), y(0.86))
.line_to(x(x0), y(0.86))
.close()
.fill(color)
.stroke(weight, color)
};
let speaker = || {
Path::new()
.move_to(x(0.08), y(0.36))
.line_to(x(0.28), y(0.36))
.line_to(x(0.54), y(0.14))
.line_to(x(0.54), y(0.86))
.line_to(x(0.28), y(0.64))
.line_to(x(0.08), y(0.64))
.close()
.fill(color)
};
let thin = (0.12 * s).max(1.2);
let faint = Color::rgba(color.red(), color.green(), color.blue(), color.alpha() / 3);
let line = |x0: f32, y0: f32, x1: f32, y1: f32| Line::new().from(x(x0), y(y0)).to(x(x1), y(y1)).stroke(thin, color).cap(Cap::Round);
// a closed outline through the box's corners at these fractions
let frame = |x0: f32, y0: f32, x1: f32, y1: f32| Path::new().move_to(x(x0), y(y0)).line_to(x(x1), y(y0)).line_to(x(x1), y(y1)).line_to(x(x0), y(y1)).close();
match glyph {
Glyph::Play => shapes![triangle(0.2, 0.9)],
Glyph::Pause => shapes![bar(0.18, 0.42), bar(0.58, 0.82)],
Glyph::Next => shapes![triangle(0.08, 0.7), bar(0.74, 0.9)],
Glyph::Previous => shapes![triangle(0.92, 0.3), bar(0.1, 0.26)],
Glyph::Speaker => shapes![speaker(), Arc::new().center(x(0.56), y(0.5)).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round)],
Glyph::Muted => shapes![speaker(), line(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)],
Glyph::Bluetooth => shapes![
Path::new()
.move_to(x(0.28), y(0.3))
.line_to(x(0.72), y(0.68))
.line_to(x(0.5), y(0.9))
.line_to(x(0.5), y(0.1))
.line_to(x(0.72), y(0.32))
.line_to(x(0.28), y(0.7))
.stroke(thin, color)
.cap(Cap::Round)
],
Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
// an eighth note: round head, stem, a curved flag
Glyph::Note => shapes![
Circle::new().center(x(0.32), y(0.74)).radius(0.17 * s).fill(color),
line(0.47, 0.74, 0.47, 0.12),
Path::new().move_to(x(0.47), y(0.12)).quad_to(x(0.62), y(0.3), x(0.84), y(0.38)).stroke(thin, color).cap(Cap::Round),
],
// a dot under three arcs fanning upwards
Glyph::Wifi(lit) => {
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.8)).radius(0.08 * s).fill(color)];
for (arc, radius) in [0.26, 0.46, 0.66].into_iter().enumerate() {
let shade = if (arc as u8) < lit { color } else { faint };
parts.push(Box::new(Arc::new().center(x(0.5), y(0.84)).radius(radius * s).start(-45.0).sweep(90.0).stroke(thin, shade).cap(Cap::Round)));
}
parts
}
// a dot sending out waves to both sides
Glyph::Hotspot => {
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.09 * s).fill(color)];
for radius in [0.24, 0.42] {
for start in [45.0, 225.0] {
parts.push(Box::new(Arc::new().center(x(0.5), y(0.5)).radius(radius * s).start(start).sweep(90.0).stroke(thin, color).cap(Cap::Round)));
}
}
parts
}
Glyph::Lock => shapes![
Arc::new().center(x(0.5), y(0.42)).radius(0.19 * s).start(-90.0).sweep(180.0).stroke(thin, color),
line(0.31, 0.42, 0.31, 0.52),
line(0.69, 0.42, 0.69, 0.52),
frame(0.18, 0.5, 0.82, 0.9).fill(color),
],
Glyph::Sun => {
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.17 * s).fill(color)];
for ray in 0..8 {
let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_4).sin_cos();
parts.push(Box::new(line(0.5 + 0.3 * sin, 0.5 - 0.3 * cos, 0.5 + 0.43 * sin, 0.5 - 0.43 * cos)));
}
parts
}
Glyph::Keyboard => {
let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.06, 0.24, 0.94, 0.78).stroke(thin, color), line(0.32, 0.64, 0.68, 0.64)];
for (row, key_y) in [0.38, 0.5].into_iter().enumerate() {
for key in 0..4 {
let key_x = 0.23 + key as f32 * 0.18 + row as f32 * 0.04;
parts.push(Box::new(Circle::new().center(x(key_x), y(key_y)).radius(0.045 * s).fill(color)));
}
}
parts
}
Glyph::Leaf => shapes![
Path::new().move_to(x(0.18), y(0.84)).quad_to(x(0.14), y(0.2), x(0.84), y(0.16)).quad_to(x(0.84), y(0.82), x(0.18), y(0.84)).close().stroke(thin, color),
line(0.18, 0.84, 0.56, 0.46),
],
Glyph::Gauge => shapes![
Arc::new().center(x(0.5), y(0.68)).radius(0.38 * s).start(-90.0).sweep(180.0).stroke(thin, color).cap(Cap::Round),
line(0.5, 0.68, 0.68, 0.42),
Circle::new().center(x(0.5), y(0.68)).radius(0.07 * s).fill(color),
],
Glyph::Bolt => shapes![
Path::new()
.move_to(x(0.6), y(0.05))
.line_to(x(0.2), y(0.56))
.line_to(x(0.46), y(0.56))
.line_to(x(0.38), y(0.95))
.line_to(x(0.8), y(0.42))
.line_to(x(0.54), y(0.42))
.close()
.fill(color)
],
// a die with pins on every side
Glyph::Chip => {
let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.24, 0.24, 0.76, 0.76).stroke(thin, color), frame(0.4, 0.4, 0.6, 0.6).fill(color)];
for at in [0.4, 0.6] {
parts.push(Box::new(line(at, 0.08, at, 0.22)));
parts.push(Box::new(line(at, 0.78, at, 0.92)));
parts.push(Box::new(line(0.08, at, 0.22, at)));
parts.push(Box::new(line(0.78, at, 0.92, at)));
}
parts
}
// a screen on a base
Glyph::Laptop => shapes![frame(0.18, 0.2, 0.82, 0.66).stroke(thin, color), Path::new().move_to(x(0.06), y(0.78)).line_to(x(0.94), y(0.78)).stroke(thin * 1.4, color).cap(Cap::Round)],
// an rj45 socket seen from the front: the outline with its latch notch on top, three contacts inside
Glyph::Ethernet => shapes![
Path::new()
.move_to(x(0.12), y(0.86))
.line_to(x(0.12), y(0.3))
.line_to(x(0.3), y(0.3))
.line_to(x(0.3), y(0.14))
.line_to(x(0.7), y(0.14))
.line_to(x(0.7), y(0.3))
.line_to(x(0.88), y(0.3))
.line_to(x(0.88), y(0.86))
.close()
.stroke(thin, color),
line(0.36, 0.46, 0.36, 0.62),
line(0.5, 0.46, 0.5, 0.62),
line(0.64, 0.46, 0.64, 0.62),
],
}
}
+56 -121
View File
@@ -4,13 +4,15 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use amane::{ use amane::{
Animation, Arc, Argument, Blend, Bus, Canvas, Cap, Center, Circle, Color, Column, Easing, End, Full, Gradient, Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image,
Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer,
Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical,
Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, shapes, Weight, Widget, Workspaces, Zone, children, shapes,
}; };
use crate::art::Art; use crate::art::Art;
use crate::edge::{self, Edge, HIDE_H, Part};
use crate::glyph::{Glyph, bold_icon, icon};
use crate::parse::{self, BANDS, Sink}; use crate::parse::{self, BANDS, Sink};
use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound};
use crate::spectrum::Spectrum; use crate::spectrum::Spectrum;
@@ -22,8 +24,8 @@ use crate::theme;
* on the settled shape. closing runs the same timeline backwards * on the settled shape. closing runs the same timeline backwards
*/ */
const OPEN_MS: u64 = 560; const OPEN_MS: u64 = 560;
const PILL_GONE: f32 = 0.18; pub const PILL_GONE: f32 = 0.18;
const SHAPE_DONE: f32 = 0.6; pub const SHAPE_DONE: f32 = 0.6;
// hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW // hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW
pub const HOVER_MS: u64 = 220; pub const HOVER_MS: u64 = 220;
@@ -33,7 +35,7 @@ const PILL_HOVER_GROW: f32 = 10.0;
const BOLD: f32 = 2.0; const BOLD: f32 = 2.0;
// sizes, in pixels // sizes, in pixels
const COMPACT_W: f32 = 220.0; pub const COMPACT_W: f32 = 220.0;
pub const COMPACT_H: f32 = 38.0; pub const COMPACT_H: f32 = 38.0;
const WIDE_W: f32 = 560.0; const WIDE_W: f32 = 560.0;
const WIDE_RADIUS: f32 = 28.0; const WIDE_RADIUS: f32 = 28.0;
@@ -92,12 +94,12 @@ const THUMB: u32 = 256;
const LETTER: f32 = 0.66; const LETTER: f32 = 0.66;
// the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs // the white overlays below stay: they read on any dark surface. the theme colours are in theme.rs
const CHIP: Color = Color::rgba(255, 255, 255, 18); pub const CHIP: Color = Color::rgba(255, 255, 255, 18);
const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); pub const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34);
// a hovered chip or button; CLEAR is its starting point where there is no fill, white so the fade stays grey-free // a hovered chip or button; CLEAR is its starting point where there is no fill, white so the fade stays grey-free
pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46); pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46);
pub const CLEAR: Color = Color::rgba(255, 255, 255, 0); pub const CLEAR: Color = Color::rgba(255, 255, 255, 0);
const TRACK: Color = Color::rgba(255, 255, 255, 24); pub const TRACK: Color = Color::rgba(255, 255, 255, 24);
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Menu { pub enum Menu {
@@ -231,25 +233,22 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
// the wallpaper's colours for this frame; cover_colors: the open card takes the cover's background // the wallpaper's colours for this frame; cover_colors: the open card takes the cover's background
let cover_colors = theme::begin(); let cover_colors = theme::begin();
let media = Media::read();
let island = Island::read(); let island = Island::read();
let sound = Sound::read();
let art = Art::read();
let here = monitor.name.as_str(); let here = monitor.name.as_str();
let focused = focused_monitor();
// the other monitors keep their pill while the card is open here // 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); let opened_here = island.opened_on.as_deref().is_none_or(|name| name == here);
/* // hidden on a fullscreen monitor until wanted (edge.rs), an open card keeps it in; a new song makes it pop
* only the focused monitor (and the one with the open card) shows live levels: let (edge_in, pop) = {
* reading Spectrum is what redraws a window for every cava frame, so the other let edge = Edge::read();
* windows skip it, rest, and show still bars
*/ (edge.part(Part::Island, here), edge.pop(Part::Island, here))
let live = focused.as_deref().is_none_or(|name| name == here) || (opened_here && island.expanded); };
let spectrum = live.then(Spectrum::read);
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice()); let open = if opened_here { island.open.value() } else { 0.0 };
let slide = edge::hidden_by(edge_in, open, HIDE_H);
let window_w = WIDE_W + 2.0 * SHADOW; let window_w = WIDE_W + 2.0 * SHADOW;
@@ -262,6 +261,26 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.space(Zone::Ignore) .space(Zone::Ignore)
.namespace("magic-island"); .namespace("magic-island");
// out of sight: empty and click-through (never hidden, see below), and nothing else read, so a playing player doesn't redraw it
if slide <= -HIDE_H {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0));
}
let media = Media::read();
let sound = Sound::read();
let art = Art::read();
let focused = focused_monitor();
/*
* only the focused monitor (and the one with the open card) shows live levels:
* reading Spectrum is what redraws a window for every cava frame, so the other
* windows skip it, rest, and show still bars
*/
let live = focused.as_deref().is_none_or(|name| name == here) || (opened_here && island.expanded);
let spectrum = live.then(Spectrum::read);
let levels: &[f32] = spectrum.as_deref().map_or(&[], |spectrum| spectrum.levels.as_slice());
let Some(player) = shown(&media, island.selected.as_deref()) else { let Some(player) = shown(&media, island.selected.as_deref()) else {
/* /*
* nothing playing: a quiet pill, so the island is always there. never * nothing playing: a quiet pill, so the island is always there. never
@@ -275,25 +294,28 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent) .height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start) .align_child(Center, Start)
.translate(0.0, slide)
.child(idle(&theme::now().surfaces())), .child(idle(&theme::now().surfaces())),
); );
}; };
let (grow, pill, reveal) = phases(if opened_here { island.open.value() } else { 0.0 }); 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 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 * 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(); 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 height = mix(COMPACT_H, WIDE_H, grow);
let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow); let radius = mix(COMPACT_H / 2.0, WIDE_RADIUS, grow);
@@ -357,6 +379,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
Island::write().pointer_on = Some(hovered.clone()); Island::write().pointer_on = Some(hovered.clone());
} }
edge::over_bar(inside);
hovering(Spot::Island)(inside); hovering(Spot::Island)(inside);
// leaving a pill on another monitor leaves the open card alone // leaving a pill on another monitor leaves the open card alone
@@ -384,6 +407,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.height(Parent) .height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 }) .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: 0.0 })
.align_child(Center, Start) .align_child(Center, Start)
.translate(0.0, slide)
.child(shape), .child(shape),
) )
} }
@@ -421,7 +445,7 @@ fn opening() -> Animation {
} }
// the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list // the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list
fn focused_monitor() -> Option<String> { pub fn focused_monitor() -> Option<String> {
let workspaces = Workspaces::read(); let workspaces = Workspaces::read();
workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from) workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from)
@@ -533,12 +557,12 @@ fn next_player() {
Island::write().selected = next; Island::write().selected = next;
} }
fn mix(from: f32, to: f32, amount: f32) -> f32 { pub fn mix(from: f32, to: f32, amount: f32) -> f32 {
from + (to - from) * amount from + (to - from) * amount
} }
// one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1 // one point on the open timeline as (shape grown, pill shown, card shown), each 0 to 1
fn phases(timeline: f32) -> (f32, f32, f32) { pub fn phases(timeline: f32) -> (f32, f32, f32) {
let grow = (timeline / SHAPE_DONE).clamp(0.0, 1.0); let grow = (timeline / SHAPE_DONE).clamp(0.0, 1.0);
let pill = 1.0 - (timeline / PILL_GONE).clamp(0.0, 1.0); let pill = 1.0 - (timeline / PILL_GONE).clamp(0.0, 1.0);
let reveal = ((timeline - SHAPE_DONE) / (1.0 - SHAPE_DONE)).clamp(0.0, 1.0); let reveal = ((timeline - SHAPE_DONE) / (1.0 - SHAPE_DONE)).clamp(0.0, 1.0);
@@ -553,7 +577,7 @@ fn phases(timeline: f32) -> (f32, f32, f32) {
* the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with * the veil spans the whole shape (minus the 1 px rim), so its gradient lines up with
* the backdrop's exactly; amane places gradients by each rectangle's own bounds * the backdrop's exactly; amane places gradients by each rectangle's own bounds
*/ */
fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle { pub fn veiled(face: Rectangle, width: f32, height: f32, radius: f32, shown: f32, colors: &[Color; 3]) -> Rectangle {
if shown >= 1.0 { if shown >= 1.0 {
return face; return face;
} }
@@ -1421,92 +1445,3 @@ fn band(levels: &[f32], group: usize, groups: usize) -> f32 {
levels[start..end].iter().copied().fold(0.0, f32::max) levels[start..end].iter().copied().fold(0.0, f32::max)
} }
#[derive(Clone, Copy)]
enum Glyph {
Play,
Pause,
Previous,
Next,
Speaker,
Muted,
Bluetooth,
Chevron,
Note,
}
// drawn, not typed, so the island looks the same whatever fonts are installed
fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
bold_icon(glyph, size, color, 0.0)
}
// play, pause, previous and next thickened by an outline `weight` px wide in their own colour; 0 draws none
fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
let s = size;
let triangle = |x0: f32, x1: f32| {
Path::new().move_to(x0, 0.14 * s).line_to(x1, 0.5 * s).line_to(x0, 0.86 * s).close().fill(color).stroke(weight, color)
};
let bar = |x0: f32, x1: f32| {
Path::new()
.move_to(x0, 0.14 * s)
.line_to(x1, 0.14 * s)
.line_to(x1, 0.86 * s)
.line_to(x0, 0.86 * s)
.close()
.fill(color)
.stroke(weight, color)
};
let speaker = || {
Path::new()
.move_to(0.08 * s, 0.36 * s)
.line_to(0.28 * s, 0.36 * s)
.line_to(0.54 * s, 0.14 * s)
.line_to(0.54 * s, 0.86 * s)
.line_to(0.28 * s, 0.64 * s)
.line_to(0.08 * s, 0.64 * s)
.close()
.fill(color)
};
let thin = (0.12 * s).max(1.2);
let shapes = match glyph {
Glyph::Play => shapes![triangle(0.2 * s, 0.9 * s)],
Glyph::Pause => shapes![bar(0.18 * s, 0.42 * s), bar(0.58 * s, 0.82 * s)],
Glyph::Next => shapes![triangle(0.08 * s, 0.7 * s), bar(0.74 * s, 0.9 * s)],
Glyph::Previous => shapes![triangle(0.92 * s, 0.3 * s), bar(0.1 * s, 0.26 * s)],
Glyph::Speaker => shapes![
speaker(),
Arc::new().center(0.56 * s, 0.5 * s).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round),
],
Glyph::Muted => shapes![
speaker(),
Line::new().from(0.68 * s, 0.36 * s).to(0.94 * s, 0.64 * s).stroke(thin, color).cap(Cap::Round),
Line::new().from(0.94 * s, 0.36 * s).to(0.68 * s, 0.64 * s).stroke(thin, color).cap(Cap::Round),
],
Glyph::Bluetooth => shapes![
Path::new()
.move_to(0.28 * s, 0.3 * s)
.line_to(0.72 * s, 0.68 * s)
.line_to(0.5 * s, 0.9 * s)
.line_to(0.5 * s, 0.1 * s)
.line_to(0.72 * s, 0.32 * s)
.line_to(0.28 * s, 0.7 * s)
.stroke(thin, color)
.cap(Cap::Round)
],
Glyph::Chevron => shapes![
Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round)
],
// an eighth note: round head, stem, a curved flag
Glyph::Note => shapes![
Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color),
Line::new().from(0.47 * s, 0.74 * s).to(0.47 * s, 0.12 * s).stroke(thin, color).cap(Cap::Round),
Path::new().move_to(0.47 * s, 0.12 * s).quad_to(0.62 * s, 0.3 * s, 0.84 * s, 0.38 * s).stroke(thin, color).cap(Cap::Round),
],
};
Canvas::new().width(size).height(size).shapes(shapes)
}
+19 -1
View File
@@ -1,9 +1,17 @@
mod art; mod art;
mod control;
mod edge;
mod glyph;
mod island; mod island;
mod map;
mod parse; mod parse;
mod prompt;
mod sound; mod sound;
mod spectrum; mod spectrum;
mod system;
mod png;
mod theme; mod theme;
mod tray;
mod workspaces; mod workspaces;
use amane::{App, Palette, Service}; use amane::{App, Palette, Service};
@@ -12,11 +20,21 @@ fn main() {
// the card's glow follows the cover art, which art::Art copies to this one path // the card's glow follows the cover art, which art::Art copies to this one path
Palette::write().open(&art::palette_file(), 8); Palette::write().open(&art::palette_file(), 8);
// one island on every monitor (the card opens only on one of them), and a workspace pill top left on each // one island on every monitor (the card opens only on one of them), a workspace pill top left
// and the control centre top right on each; all three hidden until the pointer rests on the
// top edge (edge.rs), whose strip comes first so the bar lies over it
App::new() App::new()
.font("Fira Code") .font("Fira Code")
.ipc("island", island::command) .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(island::view)
.window_per_monitor(workspaces::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(); .run();
} }
+294
View File
@@ -0,0 +1,294 @@
/*
* the Wi-Fi and Bluetooth menus as a map: this desktop as a bubble in the middle and
* every network or device floating around it, nearer the stronger it is. a line ties
* each one to the bubble: grey and wobbling while it isn't connected, running out from
* the bubble towards it while it connects, solid (with a pulse travelling along it)
* once connected. it moves all the time, so the menu asks for frames while it shows
*/
use std::f32::consts::{PI, TAU};
use std::sync::OnceLock;
use std::time::Instant;
use amane::{Blend, Button, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes};
use crate::glyph::{Glyph, glyph_shapes};
use crate::island::{CHIP_OPEN, faded};
use crate::theme;
// the map's own height, under the menu's header
pub const AREA_H: f32 = 480.0;
const BUBBLE: f32 = 40.0;
const NODE_R: f32 = 22.0;
const ICON: f32 = 18.0;
const LABEL_W: f32 = 140.0;
const LABEL_H: f32 = 30.0;
// 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 (the strongest come first): more don't fit at this size without labels meeting
pub const NODES: usize = 8;
// the wobbling line of what isn't connected
const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85);
// a click on the bubble shows its rings at least until all three have run out once; a ring takes RING_S
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 = 230.0;
#[derive(Clone, Copy, PartialEq)]
pub enum Link {
Idle,
Connecting,
Connected,
}
pub struct Node {
pub glyph: Glyph,
pub name: String,
pub detail: String,
// 0 far out, 1 close to the bubble
pub nearness: f32,
pub link: Link,
pub lit: f32,
pub on_hover: Box<dyn Fn(bool)>,
// left and right clicks, told apart by the button
pub on_click: Option<Box<dyn Fn(Button)>>,
}
// this desktop's bubble in the middle: its name, what to know, and a click that searches again
pub struct Centre<'a> {
pub host: &'a str,
pub note: Option<&'a str>,
// seconds since a search began (see Control::searching): rings run out from the bubble
pub searching: Option<f32>,
pub lit: f32,
pub on_hover: Box<dyn Fn(bool)>,
pub on_click: Box<dyn Fn()>,
}
// seconds since the shell first drew a map: the clock every float and wobble runs on
pub fn clock() -> f32 {
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_secs_f32()
}
pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec<Node>) -> Rectangle {
let theme = theme::now();
let t = clock();
let (cx, cy) = (width / 2.0, height / 2.0);
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 - 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 + 150.0, BUBBLE + NODE_R + 80.0);
let count = nodes.len().max(1) as f32;
let mut idle = Path::new();
let mut running = Path::new();
let mut solid = Path::new();
let mut glow = Path::new();
let mut dots: Vec<Box<dyn Shape>> = Vec::new();
let mut discs: Vec<Box<dyn Shape>> = Vec::new();
let mut floating: Vec<Box<dyn Widget>> = Vec::new();
for (index, node) in nodes.into_iter().enumerate() {
let phase = index as f32;
// 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;
// the stronger the nearer, but never far in, so neighbours' labels don't meet
let reach = 1.0 - 0.1 * node.nearness.clamp(0.0, 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, 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;
let (dx, dy) = (x - cx, y - cy);
let length = (dx * dx + dy * dy).sqrt().max(1.0);
let (ux, uy) = (dx / length, dy / length);
let start = (cx + ux * bubble, cy + uy * bubble);
let span = (length - bubble - NODE_R).max(0.0);
let point = |along: f32, swing: f32| (start.0 + ux * span * along - uy * swing, start.1 + uy * span * along + ux * swing);
match node.link {
Link::Idle | Link::Connecting => {
// still at both ends, swinging in between, the swing running outwards
let amplitude = if node.link == Link::Idle { 5.0 } else { 2.5 };
let mut path = idle.move_to(start.0, start.1);
for step in 1..=28 {
let along = step as f32 / 28.0;
let swing = amplitude * (along * PI).sin() * (along * 3.0 * TAU - t * 2.4 + phase).sin();
let (px, py) = point(along, swing);
path = path.line_to(px, py);
}
idle = path;
if node.link == Link::Connecting {
// the line runs out from the bubble to the device, over and over until it's connected
let reached = (t * 0.7 + phase * 0.13).fract();
let reached = 1.0 - (1.0 - reached).powi(2);
let tip = point(reached, 0.0);
running = running.move_to(start.0, start.1).line_to(tip.0, tip.1);
dots.push(Box::new(Circle::new().center(tip.0, tip.1).radius(3.5).fill(theme.flame)));
}
}
Link::Connected => {
let end = point(1.0, 0.0);
solid = solid.move_to(start.0, start.1).line_to(end.0, end.1);
glow = glow.move_to(start.0, start.1).line_to(end.0, end.1);
// a pulse travelling out along it
let pulse = point((t * 0.45 + phase * 0.2).fract(), 0.0);
dots.push(Box::new(Circle::new().center(pulse.0, pulse.1).radius(2.5).fill(theme.flame)));
}
}
let (fill, mark) = match node.link {
Link::Connected => (theme.accent, theme.on_accent),
Link::Connecting => (Color::blend(CHIP_OPEN, theme.accent, 0.45), theme.text),
Link::Idle => (CHIP_OPEN, theme.text),
};
let lit = node.lit;
// its disc and icon go into the map's one canvas (see glyph_shapes), on top of every line
let radius = NODE_R * (1.0 + 0.12 * lit);
discs.push(Box::new(Circle::new().center(x, y).radius(radius).fill(Color::blend(fill, theme.flame, 0.2 * lit))));
discs.push(Box::new(Circle::new().center(x, y).radius(radius).stroke(1.0, Color::blend(theme.outline, theme.accent, lit))));
discs.extend(glyph_shapes(node.glyph, ICON, mark, 0.0, (x - ICON / 2.0, y - ICON / 2.0)));
// what's left as a widget is only where the pointer finds it
let target = Rectangle::new().width(2.0 * NODE_R).height(2.0 * NODE_R).radius(Full).translate(x - NODE_R, y - NODE_R).on_hover(node.on_hover);
let target = match node.on_click {
Some(click) => target.cursor(Pointer).on_click(click),
None => target,
};
floating.push(Box::new(target));
let bold = node.link == Link::Connected;
// away from the bubble: above the ones above it, so a label never lies on its own line
let label_y = if y < cy - NODE_R { y - NODE_R - 5.0 - LABEL_H } else { y + NODE_R + 5.0 };
floating.push(Box::new(
Rectangle::new().width(LABEL_W).height(LABEL_H).align_child(Center, Start).translate(x - LABEL_W / 2.0, label_y).child(
Column::new(vec![
Box::new(centred(Text::new(node.name).size(11.5).weight(if bold { Weight::Bold } else { Weight::Regular }).color(Color::blend(theme.text, theme.flame, 0.3 * lit)).elide(), 15.0)),
Box::new(centred(Text::new(node.detail).size(10.0).color(theme.dim).elide(), 13.0)),
])
.gap(2.0),
),
));
}
let mut lines: Vec<Box<dyn Shape>> = shapes![
idle.stroke(1.5, IDLE_LINE).cap(Cap::Round),
glow.stroke(7.0, faded(theme.accent, 45)).cap(Cap::Round),
solid.stroke(2.4, theme.accent).cap(Cap::Round),
running.stroke(2.2, faded(theme.accent, 220)).cap(Cap::Round),
];
// while it searches, rings run out from the bubble and fade, the first one from the moment of the click
if let Some(since) = centre.searching {
for ring in 0..3 {
let age = since - ring as f32 * RING_GAP_S;
if age < 0.0 {
continue;
}
let grown = (age / RING_S).fract();
let eased = 1.0 - (1.0 - grown).powi(2);
lines.push(Box::new(
Circle::new().center(cx, cy).radius(bubble + 4.0 + eased * RING_REACH).stroke(2.0, faded(theme.accent, (150.0 * (1.0 - grown)) as u8)),
));
}
}
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble + 9.0).fill(faded(theme.accent, 38))));
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble).fill(Color::blend(theme.accent, theme.flame, 0.3 * centre.lit))));
lines.extend(glyph_shapes(Glyph::Laptop, 34.0, theme.on_accent, 0.0, (cx - 17.0, cy - 17.0)));
lines.extend(dots);
lines.extend(discs);
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Canvas::new().width(width).height(height).shapes(lines))];
// this desktop's name under its bubble, and what to know under that (on hover: what a click does)
layers.push(Box::new(
Rectangle::new()
.width(LABEL_W * 2.0)
.height(16.0)
.align_child(Center, Center)
.translate(cx - LABEL_W, cy + BUBBLE + 12.0)
.child(Text::new(centre.host).size(12.0).weight(Weight::Bold).color(theme.text)),
));
let note = if centre.lit > 0.5 { Some("Click to search again") } else { centre.note };
if let Some(note) = note {
layers.push(Box::new(
Rectangle::new().width(LABEL_W * 3.0).height(16.0).align_child(Center, Center).translate(cx - LABEL_W * 1.5, cy + BUBBLE + 32.0).child(Text::new(note).size(11.0).color(theme.dim)),
));
}
// the bubble's own target, under the nodes'
let on_click = centre.on_click;
layers.push(Box::new(
Rectangle::new()
.width(2.0 * BUBBLE)
.height(2.0 * BUBBLE)
.radius(Full)
.translate(cx - BUBBLE, cy - BUBBLE)
.cursor(Pointer)
.on_hover(centre.on_hover)
.on_click(move |_| on_click()),
));
layers.extend(floating);
Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height))
}
fn centred(text: Text, height: f32) -> Rectangle {
Rectangle::new().width(LABEL_W).height(height).align_child(Center, Center).child(text)
}
+520
View File
@@ -37,6 +37,10 @@ pub struct Sink {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub bluetooth: bool, pub bluetooth: bool,
// first channel, in percent
pub volume: u8,
pub muted: bool,
} }
pub fn sink_inputs(text: &str) -> Vec<Stream> { pub fn sink_inputs(text: &str) -> Vec<Stream> {
@@ -97,6 +101,10 @@ pub fn sinks(text: &str) -> Vec<Sink> {
sink.name = name.to_string(); sink.name = name.to_string();
} else if let Some(description) = line.strip_prefix("Description: ") { } else if let Some(description) = line.strip_prefix("Description: ") {
sink.description = description.to_string(); sink.description = description.to_string();
} else if let Some(mute) = line.strip_prefix("Mute: ") {
sink.muted = mute == "yes";
} else if let Some(volume) = line.strip_prefix("Volume: ") {
sink.volume = first_percent(volume);
} else if line == "device.api = \"bluez5\"" { } else if line == "device.api = \"bluez5\"" {
sink.bluetooth = true; sink.bluetooth = true;
} }
@@ -105,6 +113,17 @@ pub fn sinks(text: &str) -> Vec<Sink> {
sinks sinks
} }
/*
* what a person calls an output: the monitor or receiver an hdmi port names in brackets,
* "... DisplayPort 1 Output [TX-NR515] (Stereo)" gives "TX-NR515", else the description
*/
pub fn short_sink_name(description: &str) -> &str {
match (description.find('['), description.find(']')) {
(Some(open), Some(close)) if open < close => &description[open + 1..close],
_ => description,
}
}
// "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80 // "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80
fn first_percent(volume: &str) -> u8 { fn first_percent(volume: &str) -> u8 {
let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%')); let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%'));
@@ -327,6 +346,392 @@ pub fn rgb(hex: &str) -> Option<[u8; 3]> {
Some([channel(0)?, channel(2)?, channel(4)?]) Some([channel(0)?, channel(2)?, channel(4)?])
} }
// a wi-fi signal in dBm as 0 to 3 lit arcs; a connection, however weak, keeps one
pub fn wifi_bars(dbm: f64) -> u8 {
match dbm {
level if level >= -60.0 => 3,
level if level >= -70.0 => 2,
_ => 1,
}
}
// names that go to rc-service: openrc's own service names, never anything with spaces or quotes
pub fn safe_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|letter| letter.is_ascii_alphanumeric() || "._-".contains(letter))
}
// one gpu client from /proc/<pid>/fdinfo/<fd>, see the kernel's drm-usage-stats
#[derive(Debug, Clone, PartialEq, Default)]
pub struct DrmClient {
pub id: u64,
pub pdev: String,
pub vram_kib: u64,
// nanoseconds spent on every engine together; none when the driver reports no engine time
pub engine_ns: Option<u64>,
}
pub fn drm_client(fdinfo: &str) -> Option<DrmClient> {
let mut client = DrmClient::default();
let mut id = None;
let mut resident = None;
for line in fdinfo.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
match key {
"drm-client-id" => id = value.parse().ok(),
"drm-pdev" => client.pdev = value.to_string(),
// the older name of resident vram
"drm-memory-vram" => client.vram_kib = kib(value),
"drm-resident-vram" => resident = Some(kib(value)),
_ if key.starts_with("drm-engine-") && !key.starts_with("drm-engine-capacity-") => {
let nanoseconds = value.trim_end_matches("ns").trim().parse::<u64>().unwrap_or(0);
client.engine_ns = Some(client.engine_ns.unwrap_or(0) + nanoseconds);
}
_ => {}
}
}
client.id = id?;
if client.vram_kib == 0 {
client.vram_kib = resident.unwrap_or(0);
}
Some(client)
}
// "12 KiB", "2 MiB", or plain bytes, as KiB
fn kib(value: &str) -> u64 {
let (number, unit) = value.split_once(' ').unwrap_or((value, ""));
let number: u64 = number.trim().parse().unwrap_or(0);
match unit.trim() {
"KiB" => number,
"MiB" => number * 1024,
"GiB" => number * 1024 * 1024,
_ => number / 1024,
}
}
// `lspci -mm -s <address>`: the device field, its marketing name if bracketed, the first of several
// "Navi 23 [Radeon RX 6600/6600 XT/6600M]" gives "Radeon RX 6600"
pub fn gpu_name(lspci: &str) -> String {
let fields: Vec<&str> = lspci.split('"').skip(1).step_by(2).collect();
let device = fields.get(2).copied().unwrap_or("");
let name = match (device.find('['), device.rfind(']')) {
(Some(open), Some(close)) if open < close => &device[open + 1..close],
_ => device,
};
name.split('/').next().unwrap_or("").trim().to_string()
}
// minutes until empty (discharging) or full (charging), from energy or charge readings and their rate
pub fn battery_minutes(status: &str, now: u64, full: u64, rate: u64) -> Option<u32> {
if rate == 0 {
return None;
}
let left = match status {
"Discharging" => now,
"Charging" => full.saturating_sub(now),
_ => return None,
};
Some((left as f64 / rate as f64 * 60.0).round() as u32)
}
/*
* what iwctl and bluetoothctl print, as plain lines: they write for people, with
* colours, carriage returns, backspaces and bluetoothctl's own "[bluetoothctl]>" prompt
*/
pub fn plain_lines(text: &str) -> Vec<String> {
let mut plain = String::new();
let mut letters = text.chars().peekable();
while let Some(letter) = letters.next() {
match letter {
// an escape sequence, ESC [ ... up to its final letter
'\u{1b}' => {
if letters.peek() == Some(&'[') {
letters.next();
for inner in letters.by_ref() {
if ('@'..='~').contains(&inner) {
break;
}
}
}
}
'\r' => plain.push('\n'),
'\u{8}' | '\u{1}' | '\u{2}' => {}
_ => plain.push(letter),
}
}
plain
.lines()
.map(|line| line.trim().trim_start_matches("[bluetoothctl]>").trim().to_string())
.filter(|line| !line.is_empty())
.collect()
}
// what bluetoothctl's own agent asks, and how a pairing or connecting ends
#[derive(Debug, Clone, PartialEq)]
pub enum Pairing {
AskPin,
AskPasskey,
Confirm(String),
Accept,
Authorize,
Show(String),
Paired,
Connected,
Failed(String),
}
pub fn pairing_event(line: &str) -> Option<Pairing> {
let line = line.trim_start_matches("[agent]").trim();
// the agent's questions first: "Enter passkey" also holds "passkey"
if line.starts_with("Enter PIN code") {
return Some(Pairing::AskPin);
}
if line.starts_with("Enter passkey") {
return Some(Pairing::AskPasskey);
}
if let Some(rest) = line.strip_prefix("Confirm passkey ") {
return Some(Pairing::Confirm(rest.split_whitespace().next().unwrap_or("").to_string()));
}
if line.starts_with("Accept pairing") {
return Some(Pairing::Accept);
}
if line.starts_with("Authorize service") {
return Some(Pairing::Authorize);
}
// a code to type on the device itself
if let Some(code) = line.strip_prefix("Passkey: ").or_else(|| line.strip_prefix("PIN code: ")) {
return Some(Pairing::Show(code.split_whitespace().next().unwrap_or("").to_string()));
}
if line.contains("Pairing successful") {
return Some(Pairing::Paired);
}
if line.contains("Connection successful") {
return Some(Pairing::Connected);
}
if let Some(error) = line.strip_prefix("Failed to pair: ").or_else(|| line.strip_prefix("Failed to connect: ")) {
return Some(Pairing::Failed(error.trim().to_string()));
}
if line.starts_with("Device ") && line.ends_with("not available") {
return Some(Pairing::Failed(String::from("not available")));
}
None
}
// a bluez error, the way a person would say it; anything unknown as bluez wrote it
pub fn bluez_reason(error: &str) -> String {
if error == "not available" {
return String::from("Not in range: let it be found, then retry");
}
let name = error.split_whitespace().next().unwrap_or("");
let detail = error[name.len()..].trim();
let reason = match name.trim_start_matches("org.bluez.Error.") {
"AuthenticationFailed" => "Wrong PIN or passkey, or the device said no",
"AuthenticationCanceled" | "AuthenticationRejected" => "The pairing was turned down",
"AuthenticationTimeout" => "No answer in time",
"ConnectionAttemptFailed" => "Out of range, or switched off",
"AlreadyExists" => "It's paired already",
"InProgress" => "It's already pairing or connecting",
"NotReady" => "Bluetooth isn't ready",
"Failed" if detail.contains("profile-unavailable") => "It offers nothing this computer can use",
_ => return error.to_string(),
};
reason.to_string()
}
/*
* the files iwd may keep a network's profile in, under /var/lib/iwd: its name if
* that's only letters, digits, '-' and '_', else '=' and the name in hex
*/
pub fn iwd_profiles(ssid: &str) -> Vec<String> {
let hex: String = ssid.bytes().map(|byte| format!("{byte:02x}")).collect();
let plain = !ssid.is_empty() && ssid.chars().all(|letter| letter.is_ascii_alphanumeric() || letter == '-' || letter == '_');
if plain { vec![format!("{ssid}.psk"), format!("={hex}.psk")] } else { vec![format!("={hex}.psk")] }
}
// the password in an iwd profile, or the pre-shared key when only that is kept
pub fn psk_passphrase(profile: &str) -> Option<String> {
let value = |key: &str| profile.lines().map(str::trim).find_map(|line| line.strip_prefix(key)).map(|value| value.trim().to_string());
value("Passphrase=").or_else(|| value("PreSharedKey="))
}
// a link speed in Mbit/s the way a person reads it: 1000 is "1 Gb/s", 2500 "2.5 Gb/s", 325 "325 Mb/s"
pub fn speed_words(mbit: u32) -> String {
if mbit >= 1000 {
let gbit = f64::from(mbit) / 1000.0;
if (gbit - gbit.round()).abs() < 0.05 { format!("{} Gb/s", gbit.round()) } else { format!("{gbit:.1} Gb/s") }
} else {
format!("{mbit} Mb/s")
}
}
/*
* `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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -358,6 +763,9 @@ Sink Input #57
\tState: RUNNING \tState: RUNNING
\tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo \tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo
\tDescription: Yamaha AG03MK2 Analog Stereo \tDescription: Yamaha AG03MK2 Analog Stereo
\tMute: no
\tVolume: front-left: 52428 / 80% / -5.81 dB, front-right: 52428 / 80% / -5.81 dB
\tBase Volume: 65536 / 100% / 0.00 dB
\tProperties: \tProperties:
\t\tdevice.api = \"alsa\" \t\tdevice.api = \"alsa\"
\tPorts: \tPorts:
@@ -366,6 +774,7 @@ Sink Input #57
Sink #80 Sink #80
\tName: bluez_output.00_1B_66_AA_BB_CC.1 \tName: bluez_output.00_1B_66_AA_BB_CC.1
\tDescription: JBL Tune 720BT \tDescription: JBL Tune 720BT
\tMute: yes
\tProperties: \tProperties:
\t\tdevice.api = \"bluez5\" \t\tdevice.api = \"bluez5\"
"; ";
@@ -388,7 +797,11 @@ Sink #80
assert_eq!(sinks.len(), 2); assert_eq!(sinks.len(), 2);
assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false)); assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false));
assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo"); assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo");
assert_eq!((sinks[0].volume, sinks[0].muted, sinks[1].muted), (80, false, true));
assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true)); assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true));
assert_eq!(short_sink_name("700 Series Chipset Family HD Audio HDMI / DisplayPort 1 Output [TX-NR515] (Stereo)"), "TX-NR515");
assert_eq!(short_sink_name("Yamaha AG03MK2 Analog Stereo"), "Yamaha AG03MK2 Analog Stereo");
} }
#[test] #[test]
@@ -463,4 +876,111 @@ Sink #80
assert_eq!(rgb("ffb3af"), None); assert_eq!(rgb("ffb3af"), None);
assert_eq!(rgb("#gg0000"), None); assert_eq!(rgb("#gg0000"), None);
} }
#[test]
fn reads_gpu_clients_and_names() {
let idle = "drm-driver:\tamdgpu\ndrm-client-id:\t593\ndrm-pdev:\t0000:27:00.0\ndrm-total-vram:\t12 KiB\ndrm-resident-vram:\t12 KiB\ndrm-memory-vram:\t12 KiB\ndrm-memory-gtt: \t2048 KiB\n";
assert_eq!(drm_client(idle), Some(DrmClient { id: 593, pdev: String::from("0000:27:00.0"), vram_kib: 12, engine_ns: None }));
let busy = "drm-client-id:\t7\ndrm-pdev:\t0000:27:00.0\ndrm-resident-vram:\t3 MiB\ndrm-engine-gfx:\t1500 ns\ndrm-engine-compute:\t500 ns\ndrm-engine-capacity-gfx:\t2\n";
assert_eq!(drm_client(busy).map(|client| (client.vram_kib, client.engine_ns)), Some((3072, Some(2000))));
assert_eq!(drm_client("drm-pdev:\t0000:27:00.0\n"), None);
let lspci = "27:00.0 \"VGA compatible controller\" \"Advanced Micro Devices, Inc. [AMD/ATI]\" \"Navi 23 [Radeon RX 6600/6600 XT/6600M]\" -rc7 \"Sapphire\" \"Device e448\"";
assert_eq!(gpu_name(lspci), "Radeon RX 6600");
assert_eq!(gpu_name("00:02.0 \"VGA\" \"Intel Corporation\" \"Raptor Lake-S UHD Graphics\""), "Raptor Lake-S UHD Graphics");
}
#[test]
fn reads_bluetoothctl_iwctl_and_iwd_profiles() {
// as bluetoothctl printed it on the desktop, through a pipe
let printed = "\u{1b}[0;94m[bluetoothctl]> \u{1b}[0m\r \rAgent registered\n\u{1b}[0;94m[bluetoothctl]> \u{1b}[0mpair 00:11:22:33:44:55\nDevice 00:11:22:33:44:55 not available\n\u{8}\u{8}[\u{1b}[0;93mCHG\u{1b}[0m] Controller 90:65:84:3C:6D:B4 Pairable: yes\n";
let lines = plain_lines(printed);
assert_eq!(lines, ["Agent registered", "pair 00:11:22:33:44:55", "Device 00:11:22:33:44:55 not available", "[CHG] Controller 90:65:84:3C:6D:B4 Pairable: yes"]);
assert_eq!(pairing_event(&lines[2]), Some(Pairing::Failed(String::from("not available"))));
assert_eq!(pairing_event(&lines[0]), None);
// the agent's questions and the endings, as bluez's client words them
assert_eq!(pairing_event("[agent] Enter PIN code:"), Some(Pairing::AskPin));
assert_eq!(pairing_event("[agent] Enter passkey (number in 0-999999):"), Some(Pairing::AskPasskey));
assert_eq!(pairing_event("[agent] Confirm passkey 123456 (yes/no):"), Some(Pairing::Confirm(String::from("123456"))));
assert_eq!(pairing_event("[agent] Accept pairing (yes/no):"), Some(Pairing::Accept));
assert_eq!(pairing_event("[agent] Passkey: 654321"), Some(Pairing::Show(String::from("654321"))));
assert_eq!(pairing_event("Pairing successful"), Some(Pairing::Paired));
assert_eq!(pairing_event("Failed to pair: org.bluez.Error.AuthenticationFailed"), Some(Pairing::Failed(String::from("org.bluez.Error.AuthenticationFailed"))));
assert_eq!(bluez_reason("org.bluez.Error.AuthenticationFailed"), "Wrong PIN or passkey, or the device said no");
assert_eq!(bluez_reason("org.bluez.Error.Failed br-connection-profile-unavailable"), "It offers nothing this computer can use");
assert_eq!(bluez_reason("org.bluez.Error.Odd"), "org.bluez.Error.Odd");
assert_eq!(plain_lines("Invalid network name 'NoSuchNetwork'\n"), ["Invalid network name 'NoSuchNetwork'"]);
assert_eq!(iwd_profiles("WLAN-752780"), ["WLAN-752780.psk", "=574c414e2d373532373830.psk"]);
assert_eq!(iwd_profiles("FRITZ!Box 7590"), ["=465249545a21426f782037353930.psk"]);
assert_eq!(psk_passphrase("[Security]\nPreSharedKey=abcd\nPassphrase=hunter22\n"), Some(String::from("hunter22")));
assert_eq!(psk_passphrase("[Security]\nPreSharedKey=abcd\n"), Some(String::from("abcd")));
assert_eq!(psk_passphrase("[Settings]\nAutoConnect=true\n"), None);
assert_eq!(speed_words(1000), "1 Gb/s");
assert_eq!(speed_words(2500), "2.5 Gb/s");
assert_eq!(speed_words(325), "325 Mb/s");
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
assert_eq!(battery_minutes("Discharging", 40_000_000, 80_000_000, 20_000_000), Some(120));
assert_eq!(battery_minutes("Charging", 70_000_000, 80_000_000, 30_000_000), Some(20));
assert_eq!(battery_minutes("Not charging", 70_000_000, 80_000_000, 0), None);
assert_eq!((wifi_bars(-55.0), wifi_bars(-66.0), wifi_bars(-91.0)), (3, 2, 1));
assert!(safe_name("openvpn.swiss"));
assert!(!safe_name("openvpn.x; reboot"));
assert!(!safe_name(""));
}
} }
+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]);
}
}
+679
View File
@@ -0,0 +1,679 @@
/*
* the small popup over the control centre: it asks what joining a Wi-Fi network or
* pairing a Bluetooth device needs, shows a known network's saved password, and says
* why a connection failed. the shell serves nothing on D-Bus for this: joining goes
* through iwctl and pairing through a bluetoothctl session, which bring their own
* agents. bluetoothctl's agent asks on its output and takes the answer on its input
*/
use std::collections::HashMap;
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::thread;
use std::time::Duration;
use amane::{
Animation, Blend, Center, Color, Column, End, Full, Horizontal, InputArea, Key, Keyboard, Layer, LayerWindow, Margin,
Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, Start, Text, TextInput, Vertical, Weight, Widget,
Zone, children,
};
use crate::control::{self, Control, RIGHT};
use crate::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, HOVER_MS, SHADOW, TOP_GAP, backdrop, veiled};
use crate::parse::{self, Pairing};
use crate::system;
use crate::theme;
const POPUP_W: f32 = 360.0;
const POPUP_PAD: f32 = 16.0;
const INNER_W: f32 = POPUP_W - 2.0 * POPUP_PAD;
const HEADER_H: f32 = 36.0;
const FIELD_H: f32 = 36.0;
const CODE_H: f32 = 40.0;
const LINE_H: f32 = 18.0;
const BUTTON_W: f32 = 92.0;
const BUTTON_H: f32 = 32.0;
const GAP: f32 = 14.0;
// a bluetoothctl session that hasn't ended by then is stopped
const SESSION_S: u64 = 90;
#[derive(Clone, PartialEq)]
pub enum Ask {
// something to type and send: a new network's password, a Bluetooth PIN or passkey
Password,
Pin,
Passkey,
// yes or no: does this passkey show on the device too (empty: may it pair at all)
Confirm(String),
// type this on the device's own keyboard
Show(String),
// a known network's saved password
Saved(String),
// why joining, pairing or connecting didn't work
Failed { title: String, reason: String },
}
#[derive(Clone, PartialEq)]
pub struct Request {
pub ask: Ask,
pub wifi: bool,
// the network's or device's name, and what iwctl joins: the SSID
pub name: String,
target: String,
// a text input of its own, so nothing typed for an earlier question shows up
input: &'static str,
}
pub struct Prompt {
request: Option<Request>,
// what's typed so far, for the button that sends it
typed: String,
// what went to the clipboard: the saved password (0) or that row of the details
copied: Option<usize>,
appear: Animation,
hover: HashMap<u8, Animation>,
}
impl Service for Prompt {
fn new() -> Self {
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
fn listen() {}
}
// the bluetoothctl that's pairing or connecting now: its input for the agent's answers, the process to stop it
struct Session {
id: u32,
child: Child,
input: ChildStdin,
}
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
// how many sessions and questions there have been, for their ids
static COUNT: Mutex<u32> = Mutex::new(0);
fn session() -> MutexGuard<'static, Option<Session>> {
SESSION.lock().unwrap_or_else(PoisonError::into_inner)
}
fn next() -> u32 {
let mut count = COUNT.lock().unwrap_or_else(PoisonError::into_inner);
*count += 1;
*count
}
// ponytail: one leaked input id per question (a few bytes); fine for questions asked by hand
fn new_input() -> &'static str {
Box::leak(format!("magic-prompt-{}", next()).into_boxed_str())
}
fn open(ask: Ask, wifi: bool, name: &str, target: &str) {
let mut prompt = Prompt::write();
prompt.request = Some(Request { ask, wifi, name: name.to_string(), target: target.to_string(), input: new_input() });
prompt.typed.clear();
prompt.copied = None;
prompt.appear = Animation::new(0.0).duration(Duration::from_millis(220));
prompt.appear.to(1.0);
}
fn close() {
Prompt::write().request = None;
}
fn failed(wifi: bool, name: &str, what: &str, reason: String) {
control::clear_pending();
open(Ask::Failed { title: format!("Couldn't {what} {name}"), reason }, wifi, name, "");
}
// Wi-Fi
// the Wi-Fi map: a saved or open network is joined at once, a new one asks for its password first
pub fn join(ssid: &str, needs_password: bool) {
if needs_password {
open(Ask::Password, true, ssid, ssid);
} else {
joining(ssid.to_string(), None);
}
}
fn joining(ssid: String, passphrase: Option<String>) {
thread::spawn(move || {
if let Err(reason) = system::join_wifi(&ssid, passphrase.as_deref()) {
// iwd says "Operation failed" when the handshake fails, which a wrong password is the usual cause of
let reason = if passphrase.is_some() && reason == "Operation failed" { String::from("Operation failed. Is the password right?") } else { reason };
failed(true, &ssid, "join", reason);
}
system::refresh();
});
}
// a right-click on a saved network: its password, read from iwd's profile
pub fn reveal(ssid: &str) {
let ssid = ssid.to_string();
thread::spawn(move || match system::saved_password(&ssid) {
Ok(password) => open(Ask::Saved(password), true, &ssid, &ssid),
Err(reason) => open(Ask::Failed { title: format!("No password for {ssid}"), reason }, true, &ssid, ""),
});
}
// a password or an address to the clipboard through wl-copy, on its input so it never shows in the process list; `done` once it's there
pub fn copy(password: &str, done: impl FnOnce() + Send + 'static) {
let password = password.to_string();
thread::spawn(move || {
let Ok(mut child) = Command::new("wl-copy").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
return;
};
let written = child.stdin.take().is_some_and(|mut input| input.write_all(password.as_bytes()).is_ok());
// 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 {
done();
}
});
}
// Bluetooth
/*
* pairs (then trusts and connects) or connects a device through bluetoothctl. its
* agent's questions open the popup, whose answers go to its input; how it ends shows
* as a reason when it fails. one session at a time: a new one stops the last
*/
pub fn bluetooth(address: &str, name: &str, pair: bool) {
stop_session();
let (address, name) = (address.to_string(), name.to_string());
thread::spawn(move || run_session(&address, &name, pair));
}
fn run_session(address: &str, name: &str, pair: bool) {
let spawned = Command::new("bluetoothctl").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
let Ok(mut child) = spawned else {
failed(false, name, if pair { "pair" } else { "connect" }, String::from("bluetoothctl couldn't start"));
return;
};
let (Some(mut input), Some(mut output)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return;
};
// its own agent, the default one while it runs, so the device's questions come to it
let first = if pair { format!("pair {address}\n") } else { format!("connect {address}\n") };
let _ = input.write_all(format!("agent KeyboardDisplay\ndefault-agent\n{first}").as_bytes());
let id = next();
*session() = Some(Session { id, child, input });
// stopped if it hasn't ended in time, which ends the reading below too
thread::spawn(move || {
thread::sleep(Duration::from_secs(SESSION_S));
let late = session().as_ref().is_some_and(|session| session.id == id);
if late {
stop_session();
close();
}
});
let mut printed = Vec::new();
let mut chunk = [0_u8; 512];
// whole lines already looked at, and a question on the last, unfinished line already asked
let mut seen = 0;
let mut asked: Option<String> = None;
let mut paired = !pair;
while let Ok(read) = output.read(&mut chunk) {
if read == 0 {
break;
}
printed.extend_from_slice(&chunk[..read]);
let text = String::from_utf8_lossy(&printed).into_owned();
let finished = text.ends_with('\n') || text.ends_with('\r');
let lines = parse::plain_lines(&text);
// the agent's questions end without a newline: the last line counts once it's a question
let whole = if finished { lines.len() } else { lines.len().saturating_sub(1) };
let mut events: Vec<(String, Pairing)> = Vec::new();
for line in &lines[seen.min(whole)..whole] {
if asked.as_deref() != Some(line.as_str())
&& let Some(event) = parse::pairing_event(line)
{
events.push((line.clone(), event));
}
}
seen = whole;
if !finished
&& let Some(last) = lines.last()
&& asked.as_deref() != Some(last.as_str())
&& let Some(event) = parse::pairing_event(last)
{
asked = Some(last.clone());
events.push((last.clone(), event));
}
for (_, event) in events {
match event {
Pairing::AskPin => open(Ask::Pin, false, name, address),
Pairing::AskPasskey => open(Ask::Passkey, false, name, address),
Pairing::Confirm(code) => open(Ask::Confirm(code), false, name, address),
Pairing::Accept => open(Ask::Confirm(String::new()), false, name, address),
// a device asking to use one of its services: pairing it was the yes
Pairing::Authorize => send("yes\n"),
Pairing::Show(code) => open(Ask::Show(code), false, name, address),
Pairing::Paired => {
paired = true;
// a code it showed is done with; trusted, it reconnects by itself later
close_unless_failed();
send(&format!("trust {address}\nconnect {address}\n"));
}
Pairing::Connected => {
close_unless_failed();
send("quit\n");
}
Pairing::Failed(error) => {
send("quit\n");
failed(false, name, if paired { "connect" } else { "pair" }, parse::bluez_reason(&error));
}
}
}
}
// ended: reap it, unless a newer session took its place
let ended = {
let mut current = session();
if current.as_ref().is_some_and(|session| session.id == id) { current.take() } else { None }
};
if let Some(mut ended) = ended {
let _ = ended.child.wait();
}
control::clear_pending();
system::refresh();
}
// an answer for bluetoothctl's agent, or a command for bluetoothctl
fn send(text: &str) {
if let Some(session) = session().as_mut() {
let _ = session.input.write_all(text.as_bytes());
}
}
fn stop_session() {
let Some(mut stopped) = session().take() else {
return;
};
// its agent goes with it, so bluez gives up on the pairing
let _ = stopped.child.kill();
thread::spawn(move || {
let _ = stopped.child.wait();
});
}
// a question or code still showing goes; a reason already showing stays
fn close_unless_failed() {
let mut prompt = Prompt::write();
if !matches!(prompt.request.as_ref().map(|request| &request.ask), Some(Ask::Failed { .. })) {
prompt.request = None;
}
}
// the popup's answers
// whether what's typed can be sent
fn ready(ask: &Ask, typed: &str) -> bool {
match ask {
// wpa passphrases are 8 to 63 characters
Ask::Password => (8..=63).contains(&typed.chars().count()),
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,
}
}
// the user's answer: typed text, or yes, or just closing what was shown
pub fn answer(typed: &str) {
let Some(request) = Prompt::read().request.clone() else {
return;
};
if !ready(&request.ask, typed) {
return;
}
close();
match request.ask {
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 { .. } => {}
}
}
// no: a pairing stops (bluetoothctl and its agent go), a password not given joins nothing
pub fn refuse() {
let Some(request) = Prompt::write().request.take() else {
return;
};
match request.ask {
Ask::Password => control::clear_pending(),
Ask::Pin | Ask::Passkey | Ask::Confirm(_) | Ask::Show(_) => {
stop_session();
control::clear_pending();
}
Ask::Saved(_) | Ask::Failed { .. } => {}
}
}
// `amane ipc call control prompt answer <text>|cancel`, for scripts and tests
pub fn command(words: &[&str]) -> String {
match words {
["answer", text] => answer(text),
["answer"] => answer(""),
["cancel"] => refuse(),
_ => return String::from("usage: control prompt answer <text>|cancel"),
}
String::from("ok")
}
pub fn showing() -> bool {
Prompt::read().request.is_some()
}
// for `control status`: what the popup shows, as one word
pub fn status() -> &'static str {
match Prompt::read().request.as_ref().map(|request| &request.ask) {
None => "none",
Some(Ask::Password) => "password",
Some(Ask::Pin) => "pin",
Some(Ask::Passkey) => "passkey",
Some(Ask::Confirm(_)) => "confirm",
Some(Ask::Show(_)) => "show",
Some(Ask::Saved(_)) => "saved",
Some(Ask::Failed { .. }) => "failed",
}
}
fn hovering(spot: u8) -> impl Fn(bool) + 'static {
move |inside| {
Prompt::write()
.hover
.entry(spot)
.or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS)))
.to(if inside { 1.0 } else { 0.0 });
}
}
/*
* the popup: a small card over the middle of the control centre's map, with the
* question, a field, a code or a reason, and its buttons. it takes the keyboard
* while it shows: Enter answers, Escape cancels. on one monitor only, the control
* centre's or the focused one
*/
pub fn view(monitor: &Monitor) -> LayerWindow {
theme::begin();
let prompt = Prompt::read();
let here = {
let control = Control::read();
let on = if control.expanded() { control.opened_on().map(String::from) } else { island::focused_monitor() };
on.is_none_or(|name| name == monitor.name)
};
let window = |width: f32, height: f32| {
LayerWindow::new()
.width(width)
.height(height)
.anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Right)
.layer(Layer::Overlay)
.space(Zone::Ignore)
.namespace("magic-prompt")
};
// never hidden (see the island's view): a tiny, empty click-through window instead
let Some(request) = prompt.request.as_ref().filter(|_| here) 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 name = &request.name;
let (title, question) = match &request.ask {
Ask::Password => (format!("Join {name}"), "Enter its Wi-Fi password"),
Ask::Pin => (format!("Pair {name}"), "Enter its PIN"),
Ask::Passkey => (format!("Pair {name}"), "Enter the six-digit passkey it shows"),
Ask::Confirm(code) if code.is_empty() => (format!("Pair {name}"), "Allow it to pair with this computer?"),
Ask::Confirm(_) => (format!("Pair {name}"), "Does the device show this code too?"),
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"),
};
let mark = match (&request.ask, request.wifi) {
(Ask::Failed { .. }, _) => Glyph::Muted,
(_, true) => Glyph::Lock,
(_, false) => Glyph::Bluetooth,
};
let header = Row::new(children![
Rectangle::new().width(HEADER_H).height(HEADER_H).radius(Full).fill(theme.accent).align_child(Center, Center).child(icon(mark, 17.0, theme.on_accent)),
Column::new(children![
Text::new(title).size(13.0).weight(Weight::Bold).color(theme.text).elide(),
Text::new(question).size(11.0).color(theme.dim).elide(),
])
.gap(3.0)
.width(INNER_W - HEADER_H - 12.0),
])
.gap(12.0)
.align(Center);
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(header)];
let mut body_h = 0.0;
match &request.ask {
Ask::Password | Ask::Pin | Ask::Passkey => {
let field = TextInput::new(request.input)
.size(13.0)
.color(theme.text)
.placeholder(match request.ask {
Ask::Password => "password",
Ask::Pin => "PIN",
_ => "000000",
})
.focused()
.on_change(|typed| Prompt::write().typed = typed)
.on_submit(|typed| answer(&typed));
let field = if request.ask == Ask::Password { field.password() } else { field };
rows.push(Box::new(
Rectangle::new()
.width(INNER_W)
.height(FIELD_H)
.radius(10.0)
.fill(CHIP)
.border(1.0, theme.outline)
.padding(Padding { top: 0.0, right: 12.0, bottom: 0.0, left: 12.0 })
.align_child(Start, Center)
.child(field),
));
body_h = FIELD_H;
}
Ask::Confirm(code) | Ask::Show(code) if !code.is_empty() => {
rows.push(Box::new(big_line(&spaced(code), 26.0, theme.accent)));
body_h = CODE_H;
}
Ask::Saved(password) => {
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, || Prompt::write().copied = Some(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())));
body_h = LINE_H;
}
Ask::Confirm(_) | Ask::Show(_) => {}
}
// the buttons: Cancel while something is asked (Copy for a password), and what answers (just a close for what's only shown)
let lit = |spot: u8| prompt.hover.get(&spot).map_or(0.0, Animation::value);
let mut buttons: Vec<Box<dyn Widget>> = Vec::new();
if let Ask::Saved(password) = &request.ask {
let password = password.clone();
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, || Prompt::write().copied = Some(0)))));
} else if !matches!(request.ask, Ask::Failed { .. }) {
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::Failed { .. } => Some("OK"),
_ => Some("Pair"),
};
if let Some(label) = primary {
let ok = ready(&request.ask, &prompt.typed);
let fill = if ok { Color::blend(theme.accent, theme.flame, 0.35 * lit(1)) } else { CHIP };
let ink = if ok { theme.on_accent } else { theme.dim };
let primary = button(label, fill, ink, if ok { lit(1) } else { 0.0 }).on_hover(hovering(1));
// a click answers with what's typed, as Enter does
let primary = if ok {
let typed = prompt.typed.clone();
primary.cursor(Pointer).on_click(move |_| answer(&typed))
} else {
primary
};
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)));
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 card = Rectangle::new()
.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));
// over the middle of the map menu, a little below the control centre's top
let (right, top) = (RIGHT + (control::map_width(monitor.width as f32) - 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
* own (a reason, a code) and mustn't take keys meant for another window; a click
* gives them the keyboard for Enter and Escape
*/
let typing = matches!(request.ask, Ask::Password | Ask::Pin | Ask::Passkey);
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 { .. }));
if answers {
answer("");
}
}
_ => {}
});
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))
}
// 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()
}
fn big_line(text: &str, size: f32, color: Color) -> Rectangle {
Rectangle::new().width(INNER_W).height(CODE_H).align_child(Center, Center).child(Text::new(text).size(size).weight(Weight::Bold).color(color).elide())
}
fn button(label: &str, fill: Color, ink: Color, lit: f32) -> Rectangle {
Rectangle::new().width(BUTTON_W).height(BUTTON_H).radius(10.0).fill(fill).align_child(Center, Center).scale(1.0 + 0.04 * lit).child(Text::new(label).size(12.0).weight(Weight::Bold).color(ink))
}
+64 -1
View File
@@ -69,6 +69,9 @@ pub struct Sound {
outputs: Vec<Sink>, outputs: Vec<Sink>,
channels: Vec<Channel>, channels: Vec<Channel>,
// the sink new sound goes to (`pactl get-default-sink`)
default_output: String,
// by player identity, so "Cider" keeps its curve across restarts // by player identity, so "Cider" keeps its curve across restarts
eqs: BTreeMap<String, Eq>, eqs: BTreeMap<String, Eq>,
unsaved: bool, unsaved: bool,
@@ -108,6 +111,63 @@ impl Sound {
&self.outputs &self.outputs
} }
pub fn default_output(&self) -> &str {
&self.default_output
}
// the first output whose pactl name or description contains `wanted`; none for an empty `wanted`
pub fn output_named(&self, wanted: &str) -> Option<&Sink> {
let wanted = wanted.to_lowercase();
(!wanted.is_empty())
.then(|| self.outputs.iter().find(|sink| sink.description.to_lowercase().contains(&wanted) || sink.name.to_lowercase().contains(&wanted)))
.flatten()
}
// a whole output's volume, unmuting it, as the control centre's sliders set it
pub fn set_output_volume(name: &str, volume: u8) {
let mut sound = Self::write();
sound.hold();
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
return;
};
if sink.muted {
run(&["pactl", "set-sink-mute", name, "0"]);
}
run(&["pactl", "set-sink-volume", name, &format!("{volume}%")]);
sink.volume = volume;
sink.muted = false;
}
pub fn toggle_output_mute(name: &str) {
let mut sound = Self::write();
sound.hold();
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
return;
};
sink.muted = !sink.muted;
run(&["pactl", "set-sink-mute", name, if sink.muted { "1" } else { "0" }]);
}
// where new sound goes; players without an output of their own follow it
pub fn set_default_output(name: &str) {
let mut sound = Self::write();
sound.hold();
sound.default_output = name.to_string();
run(&["pactl", "set-default-sink", name]);
}
pub fn channel(&self, player: &str) -> Option<&Channel> { pub fn channel(&self, player: &str) -> Option<&Channel> {
self.channels.iter().find(|channel| channel.player == player) self.channels.iter().find(|channel| channel.player == player)
} }
@@ -351,10 +411,12 @@ fn poll(known: &mut BTreeSet<String>) {
let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect(); let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect();
let default_output = amane::output("pactl get-default-sink");
let stale = { let stale = {
let sound = Sound::read(); let sound = Sound::read();
!sound.held() && (sound.channels != channels || sound.outputs != outputs) !sound.held() && (sound.channels != channels || sound.outputs != outputs || sound.default_output != default_output)
}; };
if stale { if stale {
@@ -362,6 +424,7 @@ fn poll(known: &mut BTreeSet<String>) {
sound.channels = channels; sound.channels = channels;
sound.outputs = outputs; sound.outputs = outputs;
sound.default_output = default_output;
} }
} }
+966
View File
@@ -0,0 +1,966 @@
/*
* what the control centre shows about the machine: wi-fi and the hotspot through iwd,
* openvpn through openrc, power (battery, firmware profile, keyboard light) through
* sysfs, and the external gpu with the programs that have it open. changes that need
* root go through the user's `doas -n` rule with fixed command lines; nothing that
* comes from another program ever reaches a shell
*/
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};
use std::{fs, thread};
use amane::{Argument, Bluetooth, Bus, Service, Value};
use crate::control::Control;
use crate::parse;
use crate::theme::Theme;
const IWD: &str = "net.connman.iwd";
const DEVICE: &str = "net.connman.iwd.Device";
const STATION: &str = "net.connman.iwd.Station";
const ACCESS_POINT: &str = "net.connman.iwd.AccessPoint";
// wi-fi, hotspot and vpn
#[derive(Clone, PartialEq, Default)]
pub struct Wifi {
// the iwd daemon runs; without it the tile is grey and a click starts its service
pub service: bool,
// the device's object path, like /net/connman/iwd/0/4, and its interface name for iwctl, like wlan0; empty without a device
pub device: String,
pub device_name: String,
pub powered: bool,
// the station's state: connected, connecting, disconnected, ...
pub state: String,
pub network: String,
// 0 to 3 arcs
pub bars: u8,
// the device is an access point, whether its hotspot is up, and who is on it
pub access_point: bool,
pub hotspot: bool,
pub hotspot_name: String,
pub clients: usize,
// the networks in range, the connected one first, then by signal
pub networks: Vec<Network>,
// iwd is scanning for networks
pub scanning: bool,
// the connected network's receive link rate in Mbit/s, looked up at most once a minute
pub rate: Option<u32>,
}
// a wired network card (not wi-fi, not virtual), from sysfs
#[derive(Clone, PartialEq)]
pub struct Wired {
pub name: String,
// a cable is in and the link is up
pub connected: bool,
// the link speed it negotiated, in Mbit/s
pub speed: Option<u32>,
}
#[derive(Clone, PartialEq)]
pub struct Network {
// iwd's object path, which Connect is called on
pub path: String,
pub name: String,
pub secured: bool,
// 802.1x: a username and password, which the popup doesn't ask for
pub enterprise: bool,
pub known: bool,
pub connected: bool,
// iwd is joining it right now
pub connecting: bool,
pub bars: u8,
}
#[derive(Clone, Copy, PartialEq)]
pub enum VpnState {
Stopped,
Connecting,
Connected,
}
#[derive(Clone, PartialEq)]
pub struct Vpn {
// the openrc service, like openvpn.swiss
pub service: String,
pub state: VpnState,
}
impl Vpn {
pub fn name(&self) -> &str {
self.service.trim_start_matches("openvpn.")
}
}
#[derive(Default)]
pub struct Links {
pub wifi: Wifi,
pub wired: Vec<Wired>,
pub vpns: Vec<Vpn>,
// bluez's openrc service is installed, so a click can start it
pub bluetooth_service: bool,
// this machine's name, for the bubble in the middle of the maps
pub host: String,
}
impl Service for Links {
fn new() -> Self {
Self::default()
}
fn listen() {
loop {
refresh();
thread::sleep(Duration::from_secs(2));
}
}
}
// reads everything again outside the lock, and writes only what changed
pub fn refresh() {
let wifi = wifi();
let wired = wired();
let vpns = vpns();
let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists();
let host = read(Path::new("/proc/sys/kernel/hostname"));
let stale = {
let links = Links::read();
links.wifi != wifi || links.wired != wired || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host
};
if stale {
*Links::write() = Links { wifi, wired, vpns, bluetooth_service, host };
}
}
// every real wired card: one with a device behind it, that isn't wi-fi
fn wired() -> Vec<Wired> {
let Ok(entries) = fs::read_dir("/sys/class/net") else {
return Vec::new();
};
let mut wired: Vec<Wired> = entries
.flatten()
.map(|entry| entry.path())
.filter(|card| read(&card.join("type")) == "1" && card.join("device").exists() && !card.join("wireless").exists() && !card.join("phy80211").exists())
.map(|card| {
// carrier and speed can't be read while the card is down
let connected = read(&card.join("carrier")) == "1";
let speed = read(&card.join("speed")).parse().ok().filter(|speed| connected && *speed > 0);
Wired { name: card.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(), connected, speed }
})
.collect();
wired.sort_by(|one, other| one.name.cmp(&other.name));
wired
}
/*
* the connected network's receive rate from iwd's diagnostics (in 100 kbit/s), asked at
* most once a minute, and again when the network changes: it shifts with every packet
*/
fn wifi_rate(bus: &Bus, device: &str, network: &str) -> Option<u32> {
static LAST: Mutex<Option<(Instant, String, Option<u32>)>> = Mutex::new(None);
let mut last = LAST.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((asked, on, rate)) = last.as_ref()
&& on == network
&& asked.elapsed() < Duration::from_secs(60)
{
return *rate;
}
let diagnostics = bus.call(IWD, device, "net.connman.iwd.StationDiagnostic", "GetDiagnostics", &[]);
let rate = Some(diagnostics.get("RxBitrate").number()).filter(|rate| *rate > 0.0).map(|rate| (rate / 10.0).round() as u32);
*last = Some((Instant::now(), network.to_string(), rate));
rate
}
fn wifi() -> Wifi {
let bus = Bus::system();
// anything sent to iwd starts it (d-bus activation), so talk to it only while it runs
let running = bus
.call("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", &[Argument::from(IWD)])
.bool();
if !running {
return Wifi::default();
}
let Value::Map(objects) = bus.call(IWD, "/", "org.freedesktop.DBus.ObjectManager", "GetManagedObjects", &[]) else {
return Wifi { service: true, ..Wifi::default() };
};
// the first wi-fi device; a laptop has one
let Some((path, interfaces)) = objects.iter().find(|(_, interfaces)| matches!(interfaces.get(DEVICE), Value::Map(_))) else {
return Wifi { service: true, ..Wifi::default() };
};
let device = interfaces.get(DEVICE);
let station = interfaces.get(STATION);
let access_point = interfaces.get(ACCESS_POINT);
let connected = station.get("ConnectedNetwork").text();
let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text());
// iwd names the network it's joining as connected already; the state tells the two apart
let state = station.get("State").text();
let joined = state == "connected" || state == "roaming";
// every network in range as [path, signal in hundredths of a dBm], strongest first
let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing };
let mut networks: Vec<Network> = ordered
.list()
.iter()
.filter_map(|entry| {
let path = entry.list().first()?.text();
let found = objects.get(path)?.get("net.connman.iwd.Network");
let signal = entry.list().get(1).map_or(-10_000.0, |signal| signal.number());
Some(Network {
path: path.to_string(),
name: found.get("Name").text().to_string(),
secured: found.get("Type").text() != "open",
enterprise: found.get("Type").text() == "8021x",
known: !found.get("KnownNetwork").text().is_empty(),
connected: path == connected && joined,
connecting: path == connected && !joined,
bars: parse::wifi_bars(signal / 100.0),
})
})
.collect();
// a stable sort keeps the signal order within each group
networks.sort_by_key(|network| !network.connected);
let bars = networks.iter().find(|network| network.connected).map_or(0, |network| network.bars);
let hotspot = access_point.get("Started").bool();
let clients = if hotspot {
bus.call(IWD, path, "net.connman.iwd.AccessPointDiagnostic", "GetDiagnostics", &[]).list().len()
} else {
0
};
let rate = if joined { wifi_rate(&bus, path, network) } else { None };
Wifi {
service: true,
device: path.clone(),
device_name: device.get("Name").text().to_string(),
powered: device.get("Powered").bool(),
state: station.get("State").text().to_string(),
network: network.to_string(),
bars,
access_point: device.get("Mode").text() == "ap",
hotspot,
hotspot_name: access_point.get("Name").text().to_string(),
clients,
networks,
scanning: station.get("Scanning").bool(),
rate,
}
}
fn vpns() -> Vec<Vpn> {
let Ok(entries) = fs::read_dir("/etc/init.d") else {
return Vec::new();
};
let mut vpns: Vec<Vpn> = entries
.flatten()
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| name.starts_with("openvpn.") && parse::safe_name(name))
.map(|service| Vpn { state: openrc_state(&service), service })
.collect();
vpns.sort_by(|one, other| one.service.cmp(&other.service));
vpns
}
// gentoo's openvpn service stays inactive until the tunnel is up; then its up.sh marks it started
fn openrc_state(service: &str) -> VpnState {
let marked = |state: &str| Path::new("/run/openrc").join(state).join(service).exists();
if marked("started") {
VpnState::Connected
} else if marked("inactive") || marked("starting") {
VpnState::Connecting
} else {
VpnState::Stopped
}
}
// a root command through the user's doas rule; false when doas wants a password or the command fails
fn root(arguments: &[&str]) -> bool {
Command::new("doas")
.arg("-n")
.args(arguments)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
/*
* the wi-fi tile: starts iwd's service while it's stopped; while it runs, turns the
* device on or off. a running hotspot is stopped instead, which makes it a client again
*/
pub fn toggle_wifi() {
let wifi = Links::read().wifi.clone();
if !wifi.service {
root(&["rc-service", "iwd", "start"]);
return;
}
if wifi.device.is_empty() {
return;
}
if wifi.access_point {
stop_hotspot(&wifi.device);
return;
}
Bus::system().set_property(IWD, &wifi.device, DEVICE, "Powered", Argument::from(!wifi.powered));
}
/*
* one radio: while the device is an access point it isn't a wi-fi client. the hotspot
* starts from iwd's profile /var/lib/iwd/ap/<name>.ap, named by `hotspot` in shell.conf
*/
pub fn toggle_hotspot() {
let wifi = Links::read().wifi.clone();
let name = Theme::read().hotspot.clone();
if !wifi.service {
root(&["rc-service", "iwd", "start"]);
return;
}
if wifi.device.is_empty() {
return;
}
if wifi.access_point {
stop_hotspot(&wifi.device);
return;
}
if name.is_empty() {
return;
}
let bus = Bus::system();
if !wifi.powered {
bus.set_property(IWD, &wifi.device, DEVICE, "Powered", Argument::from(true));
}
bus.set_property(IWD, &wifi.device, DEVICE, "Mode", Argument::from("ap"));
// the access point's interface shows up once the mode has switched
for _ in 0..20 {
if matches!(bus.property(IWD, &wifi.device, ACCESS_POINT, "Started"), Value::Bool(_)) {
break;
}
thread::sleep(Duration::from_millis(150));
}
bus.call(IWD, &wifi.device, ACCESS_POINT, "StartProfile", &[Argument::from(name.as_str())]);
// no profile by that name: back to being a wi-fi client rather than an access point doing nothing
if !bus.property(IWD, &wifi.device, ACCESS_POINT, "Started").bool() {
bus.set_property(IWD, &wifi.device, DEVICE, "Mode", Argument::from("station"));
}
}
fn stop_hotspot(device: &str) {
let bus = Bus::system();
bus.call(IWD, device, ACCESS_POINT, "Stop", &[]);
bus.set_property(IWD, device, DEVICE, "Mode", Argument::from("station"));
}
/*
* joins a network with iwctl, which brings its own agent (the password goes on its
* command line, --dont-ask without one) and says why when it fails. blocks until
* iwd has joined or given up, so call it off the drawing thread
*/
pub fn join_wifi(ssid: &str, passphrase: Option<&str>) -> Result<(), String> {
let device = Links::read().wifi.device_name.clone();
if device.is_empty() {
return Err(String::from("iwd sees no Wi-Fi device"));
}
let mut iwctl = Command::new("iwctl");
match passphrase {
Some(passphrase) => iwctl.arg("--passphrase").arg(passphrase),
None => iwctl.arg("--dont-ask"),
};
let output = iwctl.args(["station", &device, "connect", ssid]).stdin(Stdio::null()).output().map_err(|error| format!("iwctl couldn't start: {error}"))?;
if output.status.success() {
return Ok(());
}
let printed = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
Err(parse::plain_lines(&printed).pop().unwrap_or_else(|| String::from("iwctl gave no reason")))
}
/*
* a known network's password, from iwd's profile: /var/lib/iwd is root's, so through
* doas. the file name comes from parse::iwd_profiles, never straight from the SSID
*/
pub fn saved_password(ssid: &str) -> Result<String, String> {
for profile in parse::iwd_profiles(ssid) {
let Ok(output) = Command::new("doas").args(["-n", "cat"]).arg(Path::new("/var/lib/iwd").join(&profile)).stdin(Stdio::null()).output() else {
return Err(String::from("doas couldn't start"));
};
if output.status.success() {
return parse::psk_passphrase(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| String::from("Its profile holds no password"));
}
// doas says so when it wants a password; a missing file is the next name to try
if String::from_utf8_lossy(&output.stderr).contains("doas:") && !String::from_utf8_lossy(&output.stderr).contains("cat:") {
return Err(String::from("doas wants a password, so it can't be read"));
}
}
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();
if !device.is_empty() {
let _ = Command::new("iwctl").args(["station", &device, "disconnect"]).stdin(Stdio::null()).output();
}
}
// a fresh list for the Wi-Fi map, through iwctl; iwd's Scanning shows while it runs
pub fn scan_wifi() {
let wifi = Links::read().wifi.clone();
if wifi.service && wifi.powered && !wifi.access_point && !wifi.device_name.is_empty() {
let _ = Command::new("iwctl").args(["station", &wifi.device_name, "scan"]).stdin(Stdio::null()).output();
}
}
// looks for devices for a while through bluetoothctl, whose discovery ends when it does
pub fn scan_bluetooth() {
let (available, powered) = {
let bluetooth = Bluetooth::read();
(bluetooth.available(), bluetooth.powered())
};
if available && powered {
let _ = Command::new("bluetoothctl").args(["--timeout", "15", "scan", "on"]).stdin(Stdio::null()).output();
}
}
// back to how the desktop runs without Wi-Fi: iwd not running at all
pub fn stop_wifi_service() {
root(&["rc-service", "iwd", "stop"]);
}
pub fn toggle_vpn(service: &str) {
if !parse::safe_name(service) {
return;
}
let running = Links::read().vpns.iter().any(|vpn| vpn.service == service && vpn.state != VpnState::Stopped);
root(&["rc-service", service, if running { "stop" } else { "start" }]);
}
// bluez running: power the adapter on or off; installed but stopped: start its service
pub fn toggle_bluetooth() {
let (available, powered) = {
let bluetooth = Bluetooth::read();
(bluetooth.available(), bluetooth.powered())
};
if available {
Bluetooth::set_powered(!powered);
} else if Links::read().bluetooth_service {
root(&["rc-service", "bluetooth", "start"]);
}
}
// power
#[derive(Clone, PartialEq, Default)]
pub struct Charge {
pub percent: u8,
// Charging, Discharging, Not charging, Full
pub status: String,
// until empty or full
pub minutes: Option<u32>,
}
#[derive(Clone, PartialEq, Default)]
pub struct Light {
pub name: String,
pub level: u32,
pub max: u32,
}
#[derive(Clone, PartialEq, Default)]
pub struct Power {
// none without a laptop battery; a mouse's doesn't count
pub battery: Option<Charge>,
// the firmware's profile and the ones it offers, like low-power balanced performance
pub profile: String,
pub profiles: Vec<String>,
pub keyboard: Option<Light>,
}
impl Service for Power {
fn new() -> Self {
Self::default()
}
fn listen() {
loop {
refresh_power();
thread::sleep(Duration::from_secs(2));
}
}
}
pub fn refresh_power() {
let power = Power {
battery: charge(),
profile: read(Path::new("/sys/firmware/acpi/platform_profile")),
profiles: read(Path::new("/sys/firmware/acpi/platform_profile_choices")).split_whitespace().map(String::from).collect(),
keyboard: keyboard(),
};
let stale = *Power::read() != power;
if stale {
*Power::write() = power;
}
}
// when the time left was last worked out: the power draw jumps from poll to poll, so it's kept for half a minute
static ESTIMATE: Mutex<Option<(Instant, String, Option<u32>)>> = Mutex::new(None);
fn charge() -> Option<Charge> {
let folder = fs::read_dir("/sys/class/power_supply").ok()?.flatten().map(|entry| entry.path()).find(|path| {
read(&path.join("type")) == "Battery" && read(&path.join("scope")) != "Device"
})?;
let number = |name: &str| read(&folder.join(name)).parse::<u64>().unwrap_or(0);
let status = read(&folder.join("status"));
// energy (µWh and µW) on most laptops, charge (µAh and µA) on some
let (now, full, rate) = if folder.join("energy_now").exists() {
(number("energy_now"), number("energy_full"), number("power_now"))
} else {
(number("charge_now"), number("charge_full"), number("current_now"))
};
let mut estimate = ESTIMATE.lock().unwrap_or_else(PoisonError::into_inner);
let minutes = match &*estimate {
Some((at, was, minutes)) if *was == status && at.elapsed() < Duration::from_secs(30) => *minutes,
_ => {
let minutes = parse::battery_minutes(&status, now, full, rate);
*estimate = Some((Instant::now(), status.clone(), minutes));
minutes
}
};
Some(Charge { percent: number("capacity").min(100) as u8, status, minutes })
}
fn keyboard() -> Option<Light> {
let path = fs::read_dir("/sys/class/leds").ok()?.flatten().map(|entry| entry.path()).find(|path| {
path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.ends_with("kbd_backlight"))
})?;
let number = |name: &str| read(&path.join(name)).parse::<u32>().unwrap_or(0);
Some(Light {
name: path.file_name()?.to_string_lossy().into_owned(),
level: number("brightness"),
max: number("max_brightness"),
})
}
/*
* the firmware profile (fans, power limits) and the cpu's governor and energy preference
* with it, so a mode means the same everywhere. fixed scripts only, run as root
*/
pub fn set_profile(profile: &str) {
let script = match profile {
"low-power" => {
"echo low-power > /sys/firmware/acpi/platform_profile; for p in /sys/devices/system/cpu/cpufreq/policy*; do echo powersave > $p/scaling_governor; echo power > $p/energy_performance_preference; done"
}
"balanced" => {
"echo balanced > /sys/firmware/acpi/platform_profile; for p in /sys/devices/system/cpu/cpufreq/policy*; do echo powersave > $p/scaling_governor; echo balance_performance > $p/energy_performance_preference; done"
}
"performance" => {
"echo performance > /sys/firmware/acpi/platform_profile; for p in /sys/devices/system/cpu/cpufreq/policy*; do echo performance > $p/scaling_governor; done"
}
_ => return,
};
root(&["sh", "-c", script]);
refresh_power();
}
/*
* the light's file directly when the user may write it (a udev rule makes it 0666 on the
* desktop), else logind, which only does it for a session with a seat: a shell started
* over ssh has none, which is why logind alone failed
*/
pub fn set_keyboard(level: u32) {
let Some(light) = Power::read().keyboard.clone() else {
return;
};
let level = level.min(light.max);
let file = Path::new("/sys/class/leds").join(&light.name).join("brightness");
if fs::write(&file, level.to_string()).is_err() {
logind_brightness("leds", &light.name, level);
}
refresh_power();
}
// the laptop screen the same way, at 1% or more; amane's Brightness reads it back within half a second
pub fn set_screen(percent: u8) {
let Some(folder) = fs::read_dir("/sys/class/backlight").ok().and_then(|mut entries| entries.next()).and_then(Result::ok).map(|entry| entry.path()) else {
return;
};
let highest: u64 = read(&folder.join("max_brightness")).parse().unwrap_or(0);
let level = highest * u64::from(percent.clamp(1, 100)) / 100;
if fs::write(folder.join("brightness"), level.to_string()).is_err() {
let name = folder.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
logind_brightness("backlight", &name, level as u32);
}
}
fn logind_brightness(subsystem: &str, name: &str, level: u32) {
let arguments = [Argument::from(subsystem), Argument::from(name), Argument::from(level)];
Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments);
}
// the external gpu
#[derive(Clone, PartialEq)]
pub struct Program {
pub name: String,
pub vram_kib: u64,
// its share of the gpu's time since the last look; none when the driver doesn't tell
pub busy: Option<u8>,
}
#[derive(Clone, PartialEq, Default)]
pub struct Gpu {
// empty: no external gpu
pub name: String,
pub busy: u8,
pub vram_used: u64,
pub vram_total: u64,
pub celsius: Option<u32>,
pub watts: Option<f32>,
// the most video memory first
pub programs: Vec<Program>,
}
impl Service for Gpu {
fn new() -> Self {
Self::default()
}
// ponytail: scans every process's open files every 2 s, so only while the control centre is open
fn listen() {
let mut names: HashMap<String, String> = HashMap::new();
let mut engines: HashMap<u32, (u64, Instant)> = HashMap::new();
loop {
if !Control::read().expanded() {
thread::sleep(Duration::from_millis(400));
continue;
}
let gpu = external(&mut names, &mut engines);
let stale = *Self::read() != gpu;
if stale {
*Self::write() = gpu;
}
thread::sleep(Duration::from_secs(2));
}
}
}
// the first gpu the machine didn't boot with: on this laptop the one in the thunderbolt enclosure
fn external(names: &mut HashMap<String, String>, engines: &mut HashMap<u32, (u64, Instant)>) -> Gpu {
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
return Gpu::default();
};
let mut cards: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.strip_prefix("card")).is_some_and(|number| number.parse::<u32>().is_ok()))
.collect();
cards.sort();
let Some(device) = cards.into_iter().map(|card| card.join("device")).find(|device| read(&device.join("boot_vga")) == "0") else {
return Gpu::default();
};
// the pci address, like 0000:27:00.0
let address = fs::read_link(&device).ok().and_then(|link| link.file_name().map(|name| name.to_string_lossy().into_owned())).unwrap_or_default();
let name = names
.entry(address.clone())
.or_insert_with(|| {
let lspci = Command::new("lspci").args(["-mm", "-s", &address]).output().map(|output| String::from_utf8_lossy(&output.stdout).into_owned()).unwrap_or_default();
let name = parse::gpu_name(&lspci);
if name.is_empty() { String::from("External GPU") } else { name }
})
.clone();
let number = |file: &str| read(&device.join(file)).parse::<u64>().ok();
let hwmon = fs::read_dir(device.join("hwmon")).ok().and_then(|mut entries| entries.next()).and_then(Result::ok).map(|entry| entry.path());
let sensor = |file: &str| hwmon.as_ref().and_then(|folder| read(&folder.join(file)).parse::<u64>().ok());
Gpu {
name,
busy: number("gpu_busy_percent").unwrap_or(0).min(100) as u8,
vram_used: number("mem_info_vram_used").unwrap_or(0),
vram_total: number("mem_info_vram_total").unwrap_or(0),
celsius: sensor("temp1_input").map(|millidegrees| (millidegrees / 1000) as u32),
watts: sensor("power1_average").or_else(|| sensor("power1_input")).map(|microwatts| microwatts as f32 / 1_000_000.0),
programs: programs(&address, engines),
}
}
/*
* every program with the gpu open, by name: its video memory, and its share of engine
* time since the last look. other users' processes can't be read, and needn't be
*/
fn programs(address: &str, engines: &mut HashMap<u32, (u64, Instant)>) -> Vec<Program> {
let Ok(processes) = fs::read_dir("/proc") else {
return Vec::new();
};
let mut found: BTreeMap<String, (u64, Option<f64>)> = BTreeMap::new();
let mut alive = BTreeSet::new();
for process in processes.flatten() {
let Some(pid) = process.file_name().to_str().and_then(|name| name.parse::<u32>().ok()) else {
continue;
};
let Ok(files) = fs::read_dir(process.path().join("fd")) else {
continue;
};
let mut clients = BTreeSet::new();
let mut vram = 0;
let mut engine: Option<u64> = None;
for file in files.flatten() {
let Ok(target) = fs::read_link(file.path()) else {
continue;
};
if !target.starts_with("/dev/dri") {
continue;
}
let Ok(info) = fs::read_to_string(process.path().join("fdinfo").join(file.file_name())) else {
continue;
};
// one client shows up under every file that shares it
let Some(client) = parse::drm_client(&info).filter(|client| client.pdev == address) else {
continue;
};
if !clients.insert(client.id) {
continue;
}
vram += client.vram_kib;
if let Some(nanoseconds) = client.engine_ns {
engine = Some(engine.unwrap_or(0) + nanoseconds);
}
}
if clients.is_empty() {
continue;
}
alive.insert(pid);
let busy = engine.and_then(|nanoseconds| {
let before = engines.insert(pid, (nanoseconds, Instant::now()));
before.map(|(was, at)| nanoseconds.saturating_sub(was) as f64 / at.elapsed().as_nanos().max(1) as f64 * 100.0)
});
let entry = found.entry(read(&process.path().join("comm"))).or_insert((0, None));
entry.0 += vram;
if let Some(busy) = busy {
entry.1 = Some(entry.1.unwrap_or(0.0) + busy);
}
}
engines.retain(|pid, _| alive.contains(pid));
let mut programs: Vec<Program> = found
.into_iter()
.map(|(name, (vram_kib, busy))| Program { name, vram_kib, busy: busy.map(|busy| busy.round().clamp(0.0, 100.0) as u8) })
.collect();
programs.sort_by(|one, other| other.vram_kib.cmp(&one.vram_kib).then_with(|| one.name.cmp(&other.name)));
programs
}
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap_or_default().trim().to_string()
}
+10
View File
@@ -73,6 +73,12 @@ pub struct Theme {
// the open media card and the equalizer take the cover's colours as their background // the open media card and the equalizer take the cover's colours as their background
pub cover_colors: bool, pub cover_colors: bool,
// the iwd access point profile the control centre's hotspot starts (/var/lib/iwd/ap/<name>.ap); empty: none
pub hotspot: String,
// a second Sound slider in the control centre, for the output whose name contains this; empty: none
pub extra_output: String,
} }
impl Service for Theme { impl Service for Theme {
@@ -80,6 +86,8 @@ impl Service for Theme {
Self { Self {
colors: Animation::new(PINK_ON_PURPLE), colors: Animation::new(PINK_ON_PURPLE),
cover_colors: false, cover_colors: false,
hotspot: String::new(),
extra_output: String::new(),
} }
} }
@@ -174,6 +182,8 @@ fn apply(theme: &str, config: &str, startup: bool) {
let mut theme = Theme::write(); let mut theme = Theme::write();
theme.cover_colors = setting("cover_colors") == Some("true"); theme.cover_colors = setting("cover_colors") == Some("true");
theme.hotspot = setting("hotspot").unwrap_or("").to_string();
theme.extra_output = setting("extra_output").unwrap_or("").to_string();
// a change in the middle of a fade starts from the colours on screen now // a change in the middle of a fade starts from the colours on screen now
let now = theme.colors.value(); let now = theme.colors.value();
+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"),
}
}
+89 -29
View File
@@ -5,11 +5,12 @@ use std::time::Duration;
use amane::{ use amane::{
Animation, Blend, Center, Color, Full, Horizontal, InputArea, Layer, LayerWindow, Monitor, Padding, Parent, Pointer, 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::island::{CHIP_HOVER, CLEAR, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, backdrop, faded}; use crate::edge::{self, Edge, HIDE_H, Part};
use crate::theme; 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 // 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; const SLOT: f32 = 28.0;
@@ -17,6 +18,9 @@ const SLOT_GAP: f32 = 4.0;
const INSET: f32 = 5.0; const INSET: f32 = 5.0;
const LEFT: f32 = 12.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 // each hovered workspace's highlight by workspace id, fading between 0 and 1
#[derive(Default)] #[derive(Default)]
struct Hover(HashMap<i64, Animation>); struct Hover(HashMap<i64, Animation>);
@@ -42,6 +46,13 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let workspaces = Workspaces::read(); let workspaces = Workspaces::read();
let hover = Hover::read(); let hover = Hover::read();
// hidden on a fullscreen monitor until wanted, or for a moment on a workspace change (edge.rs)
let (slide, switched, peeking) = {
let edge = Edge::read();
(edge::hidden_by(edge.part(Part::Workspaces, &monitor.name), 0.0, HIDE_H), edge.switched(&monitor.name), edge.shown(&monitor.name) == 0.0)
};
// special workspaces (scratchpads) aren't places to switch to // special workspaces (scratchpads) aren't places to switch to
let here: Vec<&Workspace> = workspaces let here: Vec<&Workspace> = workspaces
.list() .list()
@@ -53,24 +64,59 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let count = here.len() as f32; let count = here.len() as f32;
let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP; let width = 2.0 * INSET + count * SLOT + (count - 1.0).max(0.0) * SLOT_GAP;
let window = LayerWindow::new() let window = |window_w: f32| {
.width(LEFT + width + SHADOW) LayerWindow::new()
.height(TOP_GAP + COMPACT_H + SHADOW) .width(window_w)
.anchor_vertical(Vertical::Top) .height(TOP_GAP + COMPACT_H + SHADOW)
.anchor_horizontal(Horizontal::Left) .anchor_vertical(Vertical::Top)
// Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them .anchor_horizontal(Horizontal::Left)
.layer(Layer::Overlay) // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them
.space(Zone::Ignore) .layer(Layer::Overlay)
.namespace("magic-workspaces"); .space(Zone::Ignore)
.namespace("magic-workspaces")
};
// empty and click-through rather than hidden: see the island's view for why // empty and click-through rather than hidden: see the island's view for why
if here.is_empty() { 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; a peek at a workspace change shows the workspaces alone
let tray = if peeking { None } else { 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(); 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 id = workspace.id();
let lit = hover.0.get(&id).map_or(0.0, Animation::value); let lit = hover.0.get(&id).map_or(0.0, Animation::value);
@@ -80,10 +126,11 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
_ => workspace.index().to_string(), _ => workspace.index().to_string(),
}; };
let theme = theme::now();
let (fill, color, weight) = if workspace.focused() { 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() { } else if workspace.active() {
(faded(theme.accent, 70), theme.accent, Weight::Bold) (faded(theme.accent, 70), theme.accent, Weight::Bold)
} else { } else {
@@ -107,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() let pill = Rectangle::new()
.width(width) .width(width)
@@ -118,23 +174,27 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
.shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0)) .shadow(Shadow::drop(theme.accent).opacity(0.3).blur(22.0))
.padding(INSET) .padding(INSET)
.align_child(Start, Center) .align_child(Start, Center)
.child(Row::new(slots).gap(SLOT_GAP).align(Center)); .on_hover(edge::over_bar)
.child(Stack::new(layers).width(row_w).height(SLOT));
// clicks next to the pill reach the windows underneath // clicks next to the pills reach the windows underneath
let region = InputArea { let mut regions = vec![InputArea { x: LEFT as i32, y: TOP_GAP as i32, width: width.ceil() as i32, height: COMPACT_H as i32 }];
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() Rectangle::new()
.width(Parent) .width(Parent)
.height(Parent) .height(Parent)
.padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT }) .padding(Padding { top: TOP_GAP, right: 0.0, bottom: 0.0, left: LEFT })
.align_child(Start, Start) .align_child(Start, Start)
.child(pill), .translate(0.0, slide)
.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()