Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35386e84f1 | ||
|
|
a7c49a16c0 | ||
|
|
1e99d7264c | ||
|
|
b14df3a435 | ||
|
|
afe963dfea | ||
|
|
4d4166dadc | ||
|
|
8fa951fe15 | ||
|
|
7bbb23d9ca | ||
|
|
318b2a8443 | ||
|
|
1b571abef0 |
@@ -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,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
File diff suppressed because it is too large
Load Diff
+412
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user