diff --git a/README.md b/README.md index b3200bf..5002fdd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,76 @@ A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), runn - **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it. - **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's. - **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace takes the accent colour, the one shown on another monitor a softer accent, empty ones dim. Click one to switch to it. +- **Control centre:** top right on every monitor, a pill with 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). + +## Control centre + +The pill shows Wi-Fi signal (the hotspot sign while it's on), the VPN lock (accent when connected), Bluetooth (accent with a device connected) and the battery with its percentage (a bolt while charging, red below 15 %). Dim means off or not running. + +The panel has: + +- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on, grey = the service behind it isn't running. A grey tile is clickable when it can start that service. + - *Wi-Fi* (iwd): iwd stopped → grey, a click runs `rc-service iwd start`. iwd running → a click turns the device (station) on or off. 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`): on/off. With bluez installed but stopped, a click runs `rc-service bluetooth start`; without bluez the tile says so. + - *VPN*: the openrc services `/etc/init.d/openvpn.*` (here `openvpn.swiss`). Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A click runs `rc-service openvpn. start|stop`. + - *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup). +- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. +- **Display:** the laptop screen's brightness (through logind, like Amane's `Brightness`) and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High). 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//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 `, 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 status # expanded= wifi= vpn= profile=

battery= +``` + +### 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/.ap` (`man iwd.ap`): + + ```ini + [Security] + Passphrase=at-least-eight-characters + + [IPv4] + Address=192.168.80.1 + DNSList= + ``` + +3. `hotspot = ` 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()`; 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 @@ -29,8 +99,11 @@ 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 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 ``` +Keep comments on lines of their own in the real file: a `#` after a value becomes part of it. + ## Layout ``` @@ -40,10 +113,13 @@ 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/art.rs cover download (curl) and the palette file src/workspaces.rs the workspace pill, top left on every monitor +src/control.rs the control centre: status pill top right, its panel, `control` IPC +src/system.rs what the control centre shows: iwd, openvpn, power, the external GPU +src/glyph.rs every drawn icon 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 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 island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) tools/ remote test driver and fake MPRIS players, see AGENTS.md ``` @@ -56,6 +132,7 @@ On the desktop that runs the shell: - PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module) - `cava`, `curl` - the Fira Code font +- for the control centre: `iwd`, openrc `openvpn.` services, `pciutils` (`lspci`), optionally `bluez`, and a doas rule without a password for `rc-service` and `sh` (see [Control centre](#control-centre)) ## Install and run diff --git a/shell.conf b/shell.conf index 60fb354..61ceacc 100644 --- a/shell.conf +++ b/shell.conf @@ -7,3 +7,7 @@ cover_colors = false # how long a colour change cross-fades, in milliseconds; 0 switches instantly transition_ms = 400 + +# the control centre's hotspot: the name of an iwd access point profile, /var/lib/iwd/ap/.ap +# (see README "Control centre"); leave it out and the hotspot tile says so +# hotspot = MyHotspot diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..91d1131 --- /dev/null +++ b/src/control.rs @@ -0,0 +1,1052 @@ +/* + * the control centre: a status pill in the top right corner of every monitor (wi-fi, + * vpn, bluetooth, battery) that opens, on the monitor it was clicked on, into a panel + * like macOS's: connection tiles, sound, the screen and keyboard light, the performance + * mode with the battery, and the external gpu with the programs using it. it opens on + * the island's timeline (island::phases): pill out, the empty shape grows, panel in + */ +use std::collections::HashMap; +use std::thread; +use std::time::{Duration, Instant}; + +use amane::{ + Animation, Audio, Blend, Bluetooth, Brightness, Center, Color, Column, Easing, End, Full, Horizontal, InputArea, + Layer, LayerWindow, Monitor, Padding, Parent, Pointer, Rectangle, Row, Service, Shadow, SpaceBetween, Stack, Start, + Text, Vertical, Weight, Widget, Zone, children, +}; + +use crate::glyph::{Glyph, icon}; +use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled}; +use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; +use crate::theme::{self, Theme}; + +const OPEN_MS: u64 = 520; + +// the pill and the panel sit this far from the monitor's right edge +const RIGHT: f32 = 12.0; + +const PILL_W: f32 = 176.0; +const PILL_HOVER_GROW: f32 = 6.0; + +const PANEL_W: f32 = 360.0; +const PANEL_RADIUS: f32 = 26.0; +const PAD: f32 = 12.0; +const INNER_W: f32 = PANEL_W - 2.0 * PAD; +const GAP: f32 = 10.0; + +// the four connection tiles, two by two, each with a round button +const TILE_W: f32 = (INNER_W - GAP) / 2.0; +const TILE_H: f32 = 60.0; +const ROUND: f32 = 34.0; + +// the rounded blocks below the tiles, and what's inside them +const MODULE_RADIUS: f32 = 18.0; +const MODULE_PAD: f32 = 12.0; +const MODULE_W: f32 = INNER_W - 2.0 * MODULE_PAD; +const TITLE_H: f32 = 18.0; +const LINE_GAP: f32 = 8.0; +const SLIDER_H: f32 = 28.0; +const KEYS_H: f32 = 26.0; +const PROFILE_H: f32 = 48.0; +const VRAM_H: f32 = 14.0; +const ROW_H: f32 = 22.0; +const MAX_ROWS: usize = 5; + +// Fira Code is about 0.64 em per letter, as in the island +const LETTER: f32 = 0.66; + +const LOW_BATTERY: Color = Color::rgb(255, 105, 97); + +// a dragged slider shows where it was let go until the system has caught up +const HELD_MS: u64 = 1200; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum Tile { + Wifi, + Bluetooth, + Vpn, + Hotspot, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Slider { + Volume, + Screen, +} + +// everything that lights up under the pointer +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Spot { + Pill, + Tile(Tile), + Slider(Slider), + Mute, + Light(u32), + Profile(usize), +} + +pub struct Control { + // 0 is the pill, 1 the open panel + open: Animation, + expanded: bool, + + // the monitor the panel opened on (none: all, without a workspace list), and the one whose pill the pointer is on + opened_on: Option, + pointer_on: Option, + + hover: HashMap, + held: HashMap, + + // a clicked tile, with what it said then: it shows "One moment" until that changes or the action is done + busy: HashMap, +} + +impl Service for Control { + fn new() -> Self { + Self { + open: opening(), + expanded: false, + opened_on: None, + pointer_on: None, + hover: HashMap::new(), + held: HashMap::new(), + busy: HashMap::new(), + } + } + + // changes only through input + fn listen() {} +} + +impl Control { + pub fn expanded(&self) -> bool { + self.expanded + } + + fn lit(&self, spot: Spot) -> f32 { + self.hover.get(&spot).map_or(0.0, Animation::value) + } + + fn slider(&self, which: Slider, actual: f32) -> f32 { + match self.held.get(&which) { + Some((value, at)) if at.elapsed() < Duration::from_millis(HELD_MS) => *value, + _ => actual, + } + } + + fn busy(&self, tile: Tile, subtitle: &str) -> bool { + self.busy.get(&tile).is_some_and(|(at, before)| before == subtitle && at.elapsed() < Duration::from_secs(10)) + } +} + +// one spot lit at a time: amane matches hover targets by their place in the frame, so a vanished one never hears the pointer leave +fn hovering(spot: Spot) -> impl Fn(bool) + 'static { + move |inside| { + let mut control = Control::write(); + + if inside && spot != Spot::Pill { + for (other, highlight) in &mut control.hover { + if *other != spot && *other != Spot::Pill { + highlight.to(0.0); + } + } + } + + control + .hover + .entry(spot) + .or_insert_with(|| Animation::new(0.0).duration(Duration::from_millis(HOVER_MS))) + .to(if inside { 1.0 } else { 0.0 }); + } +} + +// what decides the panel's height: the blocks this machine has +#[derive(Clone, Copy)] +struct Layout { + screen: bool, + keyboard: bool, + profiles: bool, + battery: bool, + gpu_rows: Option, +} + +impl Layout { + const LARGEST: Layout = Layout { screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(MAX_ROWS) }; + + fn display(&self) -> Option { + let parts: Vec = [self.screen.then_some(TITLE_H), self.screen.then_some(SLIDER_H), self.keyboard.then_some(KEYS_H)].into_iter().flatten().collect(); + + module_height(&parts) + } + + fn performance(&self) -> Option { + let parts: Vec = [(self.profiles || self.battery).then_some(TITLE_H), self.profiles.then_some(PROFILE_H)].into_iter().flatten().collect(); + + module_height(&parts) + } + + fn gpu(&self) -> Option { + self.gpu_rows.map(|rows| 2.0 * MODULE_PAD + TITLE_H + VRAM_H + rows.max(1) as f32 * ROW_H + 2.0 * LINE_GAP) + } + + fn height(&self) -> f32 { + let modules: f32 = [Some(sound_height()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| GAP + height).sum(); + + 2.0 * PAD + 2.0 * TILE_H + GAP + modules + } +} + +fn module_height(parts: &[f32]) -> Option { + if parts.is_empty() { + return None; + } + + Some(2.0 * MODULE_PAD + parts.iter().sum::() + LINE_GAP * (parts.len() - 1) as f32) +} + +// everything one frame of the open panel reads +struct Scene<'a> { + control: &'a Control, + links: &'a Links, + power: &'a Power, + bluetooth: &'a Bluetooth, + audio: &'a Audio, + screen: &'a Brightness, + gpu: &'a Gpu, + hotspot: &'a str, + layout: Layout, +} + +pub fn view(monitor: &Monitor) -> LayerWindow { + theme::begin(); + + let control = Control::read(); + let links = Links::read(); + let power = Power::read(); + let bluetooth = Bluetooth::read(); + + let here = monitor.name.as_str(); + + // the other monitors keep their pill while the panel is open here + let opened_here = control.opened_on.as_deref().is_none_or(|name| name == here); + + let (grow, pill, reveal) = phases(if opened_here { control.open.value() } else { 0.0 }); + + let hovered_here = control.pointer_on.as_deref() == Some(here); + let lift = if hovered_here { control.lit(Spot::Pill) } else { 0.0 } * (1.0 - grow); + + let window_w = RIGHT + PANEL_W + SHADOW; + + let window = LayerWindow::new() + .width(window_w) + .height(TOP_GAP + Layout::LARGEST.height() + SHADOW) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Right) + // Overlay like the island: on a desktop of fullscreen windows, Top is hidden behind every one of them + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-control"); + + // what only the panel shows is read only while it shows, so a volume change doesn't redraw every pill + let open = grow > 0.0; + + let audio = open.then(Audio::read); + let screen = open.then(Brightness::read); + let gpu = open.then(Gpu::read); + let theme_settings = open.then(Theme::read); + + let layout = Layout { + screen: screen.as_deref().is_some_and(Brightness::present), + keyboard: power.keyboard.is_some(), + profiles: !power.profiles.is_empty(), + battery: power.battery.is_some(), + gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(MAX_ROWS)), + }; + + let width = mix(PILL_W + PILL_HOVER_GROW * lift, PANEL_W, grow); + let height = mix(COMPACT_H, layout.height(), grow); + let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow); + + let colors = theme::now().surfaces(); + + // at most one face at a time, each faded by a veil instead of a gpu layer + let face = match (&audio, &screen, &gpu, &theme_settings) { + (Some(audio), Some(screen), Some(gpu), Some(settings)) if reveal > 0.0 => { + let scene = Scene { + control: &control, + links: &links, + power: &power, + bluetooth: &bluetooth, + audio, + screen, + gpu, + hotspot: &settings.hotspot, + layout, + }; + + veiled(panel(&scene), width, height, radius, reveal, &colors) + } + _ if pill > 0.0 => veiled(compact(&links, &bluetooth, &power), width, height, radius, pill, &colors), + // mid-grow: the empty shape alone + _ => Rectangle::new().width(0.0).height(0.0), + }; + + let tapped = monitor.name.clone(); + let hovered = monitor.name.clone(); + + let shape = Rectangle::new() + .width(width) + .height(height) + .radius(radius) + .fill(backdrop(&colors, 255)) + .border(1.0, Color::blend(theme::now().outline, faded(theme::now().accent, 110), lift)) + .shadow(Shadow::drop(theme::now().accent).opacity(mix(0.3, 0.7, lift)).blur(22.0)) + .align_child(Center, Start) + .on_click(move |_| tap(&tapped)) + .on_hover(move |inside| { + if inside { + Control::write().pointer_on = Some(hovered.clone()); + } + + hovering(Spot::Pill)(inside); + + // leaving a pill on another monitor leaves the open panel alone + let panel_here = Control::read().opened_on.as_deref().is_none_or(|name| name == hovered); + + if !inside && panel_here { + collapse(); + } + }) + .child(face); + + let shape = if control.expanded && opened_here { shape } else { shape.cursor(Pointer) }; + + // clicks beside the shape reach the windows underneath + let region = InputArea { + x: (window_w - RIGHT - width).floor() as i32, + y: TOP_GAP as i32, + width: width.ceil() as i32, + height: height.ceil() as i32, + }; + + window.input_region(vec![region]).child( + Rectangle::new() + .width(Parent) + .height(Parent) + .padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 }) + .align_child(End, Start) + .child(shape), + ) +} + +fn opening() -> Animation { + Animation::new(0.0).duration(Duration::from_millis(OPEN_MS)).easing(Easing::Linear) +} + +fn tap(monitor: &str) { + open_on(&mut Control::write(), Some(monitor.to_string())); +} + +// keybinds open the panel on the focused monitor +fn expand() { + let focused = island::focused_monitor(); + + open_on(&mut Control::write(), focused); +} + +fn open_on(control: &mut Control, monitor: Option) { + // on another monitor: that one grows from its pill, this one shows its pill again + if control.opened_on != monitor { + control.open = opening(); + control.opened_on = monitor; + } + + control.expanded = true; + control.open.to(1.0); +} + +fn collapse() { + let mut control = Control::write(); + + control.expanded = false; + control.open.to(0.0); + + // the panel's buttons are about to go, see hovering + control.hover.retain(|spot, _| *spot == Spot::Pill); +} + +/* + * `amane ipc call control ...` for keybinds and tests: open | close | toggle | status + */ +pub fn command(arguments: &[String]) -> String { + let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); + + match words.as_slice() { + ["open"] => expand(), + ["close"] => collapse(), + ["toggle"] => { + let expanded = Control::read().expanded; + + if expanded { collapse() } else { expand() } + } + ["status"] => return status(), + _ => return String::from("usage: control open|close|toggle|status"), + } + + String::from("ok") +} + +fn status() -> String { + let expanded = Control::read().expanded; + + let (wifi, vpn) = { + let links = Links::read(); + let wifi = &links.wifi; + + let wifi = if !wifi.service { + String::from("stopped") + } else if wifi.hotspot { + String::from("hotspot") + } else if !wifi.powered { + String::from("off") + } else { + wifi.state.clone() + }; + + let vpn = match links.vpns.iter().map(|vpn| vpn.state).find(|state| *state != VpnState::Stopped) { + Some(VpnState::Connected) => "connected", + Some(_) => "connecting", + None => "off", + }; + + (wifi, vpn) + }; + + let power = Power::read(); + let battery = power.battery.as_ref().map_or(String::from("none"), |charge| format!("{}%", charge.percent)); + + format!("expanded={expanded} wifi={wifi} vpn={vpn} profile={} battery={battery}", power.profile) +} + +// the pill: wi-fi (or the hotspot), vpn, bluetooth, battery + +fn compact(links: &Links, bluetooth: &Bluetooth, power: &Power) -> Rectangle { + let theme = theme::now(); + let wifi = &links.wifi; + + let wifi_icon = if wifi.hotspot { + icon(Glyph::Hotspot, 16.0, theme.accent) + } else if wifi.service && wifi.powered && !wifi.access_point { + icon(Glyph::Wifi(if wifi.state == "connected" { wifi.bars } else { 0 }), 16.0, theme.text) + } else { + icon(Glyph::Wifi(0), 16.0, theme.dim) + }; + + let vpn = if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connected) { + theme.accent + } else if links.vpns.iter().any(|vpn| vpn.state == VpnState::Connecting) { + theme.text + } else { + theme.dim + }; + + let paired = bluetooth.available() && bluetooth.powered(); + + let bluetooth = if paired && bluetooth.devices().iter().any(|device| device.connected()) { + theme.accent + } else if paired { + theme.text + } else { + theme.dim + }; + + let mut items: Vec> = children![wifi_icon, icon(Glyph::Lock, 14.0, vpn), icon(Glyph::Bluetooth, 15.0, bluetooth)]; + + if let Some(charge) = &power.battery { + items.push(Box::new(battery(charge))); + } + + Rectangle::new().width(PILL_W).height(COMPACT_H).align_child(Center, Center).child(Row::new(items).gap(12.0).align(Center)) +} + +// a battery drawn from rectangles, filled to its charge, with a bolt while it charges +fn battery(charge: &Charge) -> Row { + let theme = theme::now(); + + let low = charge.percent <= 15 && charge.status == "Discharging"; + let color = if low { LOW_BATTERY } else { theme.text }; + + let level = f32::from(charge.percent) / 100.0; + + let body = Rectangle::new() + .width(24.0) + .height(12.0) + .radius(3.5) + .border(1.2, faded(color, 140)) + .padding(2.2) + .align_child(Start, Center) + .child(Rectangle::new().width((19.6 * level).max(2.0)).height(7.6).radius(1.6).fill(color)); + + let nub = Rectangle::new().width(1.8).height(4.5).radius(1.0).fill(faded(color, 140)); + + let mut parts: Vec> = Vec::new(); + + if charge.status == "Charging" { + parts.push(Box::new(icon(Glyph::Bolt, 11.0, theme.accent))); + } + + parts.push(Box::new(Row::new(children![body, nub]).gap(1.0).align(Center))); + parts.push(Box::new(Text::new(format!("{}%", charge.percent)).size(11.0).color(theme.text))); + + Row::new(parts).gap(5.0).align(Center) +} + +// the panel + +fn panel(scene: &Scene) -> Rectangle { + let mut blocks: Vec> = vec![Box::new(tiles(scene)), Box::new(sound(scene))]; + + if let Some(height) = scene.layout.display() { + blocks.push(Box::new(display(scene, height))); + } + + if let Some(height) = scene.layout.performance() { + blocks.push(Box::new(performance(scene, height))); + } + + if let Some(height) = scene.layout.gpu() { + blocks.push(Box::new(gpu(scene, height))); + } + + Rectangle::new().width(PANEL_W).height(scene.layout.height()).padding(PAD).child(Column::new(blocks).gap(GAP)) +} + +fn module(height: f32, content: Column) -> Rectangle { + Rectangle::new().width(INNER_W).height(height).radius(MODULE_RADIUS).fill(CHIP).padding(MODULE_PAD).child(content) +} + +fn header(title: &str, right: Box) -> Row { + Row::new(vec![Box::new(Text::new(title).size(12.0).weight(Weight::Bold).color(theme::now().text)), right]) + .justify(SpaceBetween) + .align(Center) + .width(MODULE_W) + .height(TITLE_H) +} + +fn note(text: impl Into) -> Text { + Text::new(text.into()).size(11.0).color(theme::now().dim) +} + +// the connection tiles + +#[derive(Clone, Copy, PartialEq)] +enum Look { + On, + Off, + // the service behind it isn't running, or can't be + Grey, +} + +#[derive(Clone)] +enum Action { + Wifi, + Bluetooth, + Vpn(String), + Hotspot, +} + +struct State { + look: Look, + glyph: Glyph, + subtitle: String, + action: Option, +} + +fn tiles(scene: &Scene) -> Column { + let row = |left: Rectangle, right: Rectangle| Row::new(children![left, right]).gap(GAP); + + Column::new(children![ + row(tile(scene.control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links)), tile(scene.control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene))), + row(tile(scene.control, Tile::Vpn, "VPN", vpn_state(scene.links)), tile(scene.control, Tile::Hotspot, "Hotspot", hotspot_state(scene))), + ]) + .gap(GAP) +} + +fn wifi_state(links: &Links) -> State { + let wifi = &links.wifi; + + let (look, subtitle) = if !wifi.service { + (Look::Grey, String::from("Click to start")) + } else if wifi.device.is_empty() { + (Look::Grey, String::from("No device")) + } else if wifi.access_point { + (Look::Off, String::from("Hotspot on")) + } else if !wifi.powered { + (Look::Off, String::from("Off")) + } else { + let subtitle = match wifi.state.as_str() { + "connected" => wifi.network.clone(), + "connecting" => String::from("Connecting…"), + _ => String::from("Not connected"), + }; + + (Look::On, subtitle) + }; + + // grey without a device: there's nothing to start or switch + let action = (!wifi.service || !wifi.device.is_empty()).then_some(Action::Wifi); + + State { look, glyph: Glyph::Wifi(3), subtitle, action } +} + +fn bluetooth_state(scene: &Scene) -> State { + let bluetooth = scene.bluetooth; + + let (look, subtitle, action) = if bluetooth.available() && bluetooth.powered() { + let connected: Vec<&str> = bluetooth.devices().iter().filter(|device| device.connected()).map(|device| device.name()).collect(); + + let subtitle = if connected.is_empty() { String::from("On") } else { connected.join(", ") }; + + (Look::On, subtitle, Some(Action::Bluetooth)) + } else if bluetooth.available() { + (Look::Off, String::from("Off"), Some(Action::Bluetooth)) + } else if scene.links.bluetooth_service { + (Look::Grey, String::from("Click to start"), Some(Action::Bluetooth)) + } else { + (Look::Grey, String::from("Not installed"), None) + }; + + State { look, glyph: Glyph::Bluetooth, subtitle, action } +} + +fn vpn_state(links: &Links) -> State { + // the one that's up, else the first + let Some(vpn) = links.vpns.iter().find(|vpn| vpn.state != VpnState::Stopped).or(links.vpns.first()) else { + return State { look: Look::Grey, glyph: Glyph::Lock, subtitle: String::from("None set up"), action: None }; + }; + + let (look, subtitle) = match vpn.state { + VpnState::Connected => (Look::On, vpn.name().to_string()), + VpnState::Connecting => (Look::On, String::from("Connecting…")), + VpnState::Stopped => (Look::Off, vpn.name().to_string()), + }; + + State { look, glyph: Glyph::Lock, subtitle, action: Some(Action::Vpn(vpn.service.clone())) } +} + +fn hotspot_state(scene: &Scene) -> State { + let wifi = &scene.links.wifi; + + let (look, subtitle, action) = if !wifi.service { + (Look::Grey, String::from("Start Wi-Fi"), Some(Action::Hotspot)) + } else if wifi.device.is_empty() { + (Look::Grey, String::from("No device"), None) + } else if wifi.hotspot { + // the name until someone joins, then how many are on it + let subtitle = if wifi.clients == 0 { wifi.hotspot_name.clone() } else { format!("{} connected", wifi.clients) }; + + (Look::On, subtitle, Some(Action::Hotspot)) + } else if wifi.access_point { + (Look::On, String::from("Starting…"), Some(Action::Hotspot)) + } else if scene.hotspot.is_empty() { + // `hotspot = ` in shell.conf, see README "Hotspot setup" + (Look::Grey, String::from("Not set up"), None) + } else { + (Look::Off, scene.hotspot.to_string(), Some(Action::Hotspot)) + }; + + State { look, glyph: Glyph::Hotspot, subtitle, action } +} + +fn tile(control: &Control, which: Tile, title: &'static str, state: State) -> Rectangle { + let theme = theme::now(); + + let lit = control.lit(Spot::Tile(which)); + let busy = control.busy(which, &state.subtitle); + + let (round, mark) = match state.look { + Look::On => (theme.accent, theme.on_accent), + Look::Off => (CHIP_OPEN, theme.text), + Look::Grey => (CHIP, theme.dim), + }; + + let subtitle = if busy { String::from("One moment…") } else { state.subtitle.clone() }; + + let words_w = TILE_W - 12.0 - 10.0 - ROUND - 10.0; + + let tile = Rectangle::new() + .width(TILE_W) + .height(TILE_H) + .radius(MODULE_RADIUS) + .fill(Color::blend(CHIP, CHIP_HOVER, lit)) + .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 12.0 }) + .align_child(Start, Center) + .scale(1.0 + 0.03 * lit) + .on_hover(hovering(Spot::Tile(which))) + .child( + Row::new(children![ + Rectangle::new().width(ROUND).height(ROUND).radius(Full).fill(Color::blend(round, theme.flame, 0.25 * lit)).align_child(Center, Center).child(icon(state.glyph, 16.0, mark)), + Column::new(children![ + Text::new(title).size(12.0).weight(Weight::Bold).color(if state.look == Look::Grey { theme.dim } else { theme.text }).elide(), + Text::new(subtitle).size(10.5).color(theme.dim).elide(), + ]) + .gap(3.0) + .width(words_w), + ]) + .gap(10.0) + .align(Center), + ); + + match state.action { + Some(action) if !busy => { + let before = state.subtitle; + + tile.cursor(Pointer).on_click(move |_| act(which, before.clone(), action.clone())) + } + _ => tile, + } +} + +// runs a tile's action off the drawing thread, then looks again a few times while the daemons catch up +fn act(tile: Tile, before: String, action: Action) { + Control::write().busy.insert(tile, (Instant::now(), before)); + + thread::spawn(move || { + match action { + Action::Wifi => system::toggle_wifi(), + Action::Bluetooth => system::toggle_bluetooth(), + Action::Vpn(service) => system::toggle_vpn(&service), + Action::Hotspot => system::toggle_hotspot(), + } + + for wait in [200, 500, 800, 1500, 2500] { + thread::sleep(Duration::from_millis(wait)); + + system::refresh(); + } + + Control::write().busy.remove(&tile); + }); +} + +// sound, screen and keyboard + +fn sound(scene: &Scene) -> Rectangle { + let theme = theme::now(); + let audio = scene.audio; + + let muted = audio.muted(); + let value = scene.control.slider(Slider::Volume, f32::from(audio.volume().min(100)) / 100.0); + + let label = if muted { String::from("Muted") } else { format!("{}%", (value * 100.0).round()) }; + + // the value doubles as the mute button, as in the island's volume chip + let lit = scene.control.lit(Spot::Mute); + + let mute = Rectangle::new() + .width(label_width(&label, 11.0, 30.0)) + .height(TITLE_H + 2.0) + .radius(6.0) + .fill(Color::blend(if muted { CHIP_OPEN } else { island::CLEAR }, CHIP_HOVER, lit)) + .align_child(Center, Center) + .cursor(Pointer) + .on_hover(hovering(Spot::Mute)) + .on_click(|_| Audio::toggle_mute()) + .child( + Row::new(children![icon(if muted { Glyph::Muted } else { Glyph::Speaker }, 11.0, theme.dim), Text::new(label).size(11.0).color(theme.text)]) + .gap(5.0) + .align(Center), + ); + + let glyph = if muted { Glyph::Muted } else { Glyph::Speaker }; + + module( + sound_height(), + Column::new(children![header("Sound", Box::new(mute)), slider(scene.control, Slider::Volume, if muted { 0.0 } else { value }, glyph, set_volume)]).gap(LINE_GAP), + ) +} + +fn sound_height() -> f32 { + module_height(&[TITLE_H, SLIDER_H]).unwrap_or(0.0) +} + +fn display(scene: &Scene, height: f32) -> Rectangle { + let theme = theme::now(); + + let mut rows: Vec> = Vec::new(); + + if scene.layout.screen { + let value = scene.control.slider(Slider::Screen, f32::from(scene.screen.percent().min(100)) / 100.0); + + rows.push(Box::new(header("Display", Box::new(note(format!("{}%", (value * 100.0).round())))))); + rows.push(Box::new(slider(scene.control, Slider::Screen, value, Glyph::Sun, set_screen))); + } + + if let Some(light) = &scene.power.keyboard { + // off, then evenly up to the brightest; most keyboards have two or three steps + let steps = light.max.min(3) + 1; + + let names: &[&str] = match steps { + 2 => &["Off", "On"], + 3 => &["Off", "Low", "High"], + _ => &["Off", "Low", "Mid", "High"], + }; + + let keys_w = 54.0; + + let mut keys: Vec> = Vec::new(); + + for (step, name) in names.iter().enumerate() { + let level = step as u32 * light.max / (steps - 1).max(1); + let chosen = light.level == level; + let lit = scene.control.lit(Spot::Light(level)); + + keys.push(Box::new( + segment(Row::new(children![label(name, 11.0, chosen)]), chosen, lit, keys_w, KEYS_H) + .on_hover(hovering(Spot::Light(level))) + .on_click(move |_| { + thread::spawn(move || system::set_keyboard(level)); + }), + )); + } + + rows.push(Box::new( + Row::new(children![ + Row::new(children![icon(Glyph::Keyboard, 15.0, theme.dim), Text::new("Keyboard").size(12.0).color(theme.text)]).gap(8.0).align(Center), + Row::new(keys).gap(6.0), + ]) + .justify(SpaceBetween) + .align(Center) + .width(MODULE_W) + .height(KEYS_H), + )); + } + + module(height, Column::new(rows).gap(LINE_GAP)) +} + +/* + * a wide slider in the macOS manner: a rounded track, filled in the accent up to the + * value, with its icon at the left end. drag or scroll it; the shown value is held + * while the system catches up + */ +fn slider(control: &Control, which: Slider, value: f32, glyph: Glyph, set: fn(f32)) -> Rectangle { + let theme = theme::now(); + + let lit = control.lit(Spot::Slider(which)); + + let fill_w = SLIDER_H + (MODULE_W - SLIDER_H) * value.clamp(0.0, 1.0); + + Rectangle::new() + .width(MODULE_W) + .height(SLIDER_H) + .radius(Full) + .fill(Color::blend(TRACK, CHIP_HOVER, lit)) + .cursor(Pointer) + .on_hover(hovering(Spot::Slider(which))) + .on_drag(move |point| slide(which, (point.x - SLIDER_H / 2.0) / (MODULE_W - SLIDER_H), set)) + .on_scroll(move |scroll| slide(which, value + if scroll.y < 0.0 { 0.05 } else { -0.05 }, set)) + .child( + Stack::new(children![ + Rectangle::new().width(fill_w).height(SLIDER_H).radius(Full).fill(Color::blend(theme.accent, theme.flame, 0.35 * lit)), + Rectangle::new().width(SLIDER_H).height(SLIDER_H).align_child(Center, Center).child(icon(glyph, 14.0, theme.on_accent)), + ]) + .width(MODULE_W) + .height(SLIDER_H), + ) +} + +fn slide(which: Slider, value: f32, set: fn(f32)) { + let value = value.clamp(0.0, 1.0); + + let unchanged = { + let mut control = Control::write(); + + let unchanged = control.held.get(&which).is_some_and(|(held, at)| (held * 100.0).round() == (value * 100.0).round() && at.elapsed() < Duration::from_millis(HELD_MS)); + + control.held.insert(which, (value, Instant::now())); + + unchanged + }; + + if !unchanged { + set(value); + } +} + +fn set_volume(value: f32) { + if Audio::read().muted() { + Audio::toggle_mute(); + } + + Audio::set_volume((value * 100.0).round() as u8); +} + +// amane's Brightness never goes below 1%, so the screen never goes black +fn set_screen(value: f32) { + Brightness::set(((value * 100.0).round() as u8).max(1)); +} + +// a choice in a row of choices: the chosen one in the accent +fn segment(content: Row, chosen: bool, lit: f32, width: f32, height: f32) -> Rectangle { + let theme = theme::now(); + + let fill = if chosen { Color::blend(theme.accent, theme.flame, 0.5 * lit) } else { Color::blend(CHIP, CHIP_HOVER, lit) }; + + Rectangle::new().width(width).height(height).radius(10.0).fill(fill).align_child(Center, Center).cursor(Pointer).scale(1.0 + 0.04 * lit).child(content) +} + +fn label(text: &str, size: f32, chosen: bool) -> Text { + let theme = theme::now(); + + Text::new(text).size(size).weight(if chosen { Weight::Bold } else { Weight::Regular }).color(if chosen { theme.on_accent } else { theme.text }) +} + +fn label_width(text: &str, size: f32, extra: f32) -> f32 { + text.chars().count() as f32 * size * LETTER + extra +} + +// performance mode and battery + +fn performance(scene: &Scene, height: f32) -> Rectangle { + let theme = theme::now(); + let power = scene.power; + + let charge: Box = match &power.battery { + Some(charge) => Box::new(Row::new(children![note(battery_words(charge)), battery(charge)]).gap(8.0).align(Center)), + None => Box::new(note("")), + }; + + let mut rows: Vec> = vec![Box::new(header("Performance", charge))]; + + if !power.profiles.is_empty() { + let count = power.profiles.len() as f32; + let width = (MODULE_W - 8.0 * (count - 1.0)) / count; + + let mut choices: Vec> = Vec::new(); + + for (index, profile) in power.profiles.iter().enumerate() { + let chosen = *profile == power.profile; + let lit = scene.control.lit(Spot::Profile(index)); + + let (name, glyph) = match profile.as_str() { + "low-power" => ("Low power", Glyph::Leaf), + "balanced" => ("Balanced", Glyph::Gauge), + "performance" => ("Performance", Glyph::Bolt), + other => (other, Glyph::Gauge), + }; + + let mark = if chosen { theme.on_accent } else { theme.text }; + + let content = Row::new(children![Column::new(children![ + Rectangle::new().width(width).height(16.0).align_child(Center, Center).child(icon(glyph, 15.0, mark)), + Rectangle::new().width(width).height(14.0).align_child(Center, Center).child(label(name, 10.5, chosen)), + ]) + .gap(5.0)]); + + let picked = profile.clone(); + + choices.push(Box::new( + segment(content, chosen, lit, width, PROFILE_H).on_hover(hovering(Spot::Profile(index))).on_click(move |_| { + let picked = picked.clone(); + + thread::spawn(move || system::set_profile(&picked)); + }), + )); + } + + rows.push(Box::new(Row::new(choices).gap(8.0))); + } + + module(height, Column::new(rows).gap(LINE_GAP)) +} + +fn battery_words(charge: &Charge) -> String { + let time = |minutes: u32| format!("{}:{:02}", minutes / 60, minutes % 60); + + match (charge.status.as_str(), charge.minutes) { + ("Discharging", Some(minutes)) => format!("{} left", time(minutes)), + ("Discharging", None) => String::from("On battery"), + ("Charging", Some(minutes)) => format!("Full in {}", time(minutes)), + ("Charging", None) => String::from("Charging"), + _ => String::from("On power"), + } +} + +// the external gpu: its load, memory and temperature, and who uses it + +fn gpu(scene: &Scene, height: f32) -> Rectangle { + let theme = theme::now(); + let gpu = scene.gpu; + + let mut facts = vec![format!("{}%", gpu.busy)]; + + if let Some(celsius) = gpu.celsius { + facts.push(format!("{celsius}°C")); + } + + if let Some(watts) = gpu.watts { + facts.push(format!("{watts:.0} W")); + } + + let title = Row::new(children![icon(Glyph::Chip, 15.0, theme.accent), Text::new(gpu.name.as_str()).size(12.0).weight(Weight::Bold).color(theme.text).elide()]).gap(8.0).align(Center); + + let top = Row::new(children![title, note(facts.join(" · "))]).justify(SpaceBetween).align(Center).width(MODULE_W).height(TITLE_H); + + let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 }; + + let memory_label = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024)); + let memory_w = label_width(&memory_label, 10.5, 4.0); + let bar_w = MODULE_W - memory_w - 10.0; + + let memory = Row::new(children![ + Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(TRACK).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)), + Text::new(memory_label).size(10.5).color(theme.dim), + ]) + .gap(10.0) + .align(Center) + .width(MODULE_W) + .height(VRAM_H); + + let mut rows: Vec> = Vec::new(); + + let shown = if gpu.programs.len() > MAX_ROWS { MAX_ROWS - 1 } else { MAX_ROWS }; + + for program in gpu.programs.iter().take(shown) { + let mut amount = size(program.vram_kib); + + if let Some(busy) = program.busy { + amount = format!("{amount} · {busy}%"); + } + + rows.push(Box::new( + Row::new(children![ + Text::new(program.name.as_str()).size(12.0).color(theme.text).elide(), + note(amount), + ]) + .justify(SpaceBetween) + .align(Center) + .width(MODULE_W) + .height(ROW_H), + )); + } + + if gpu.programs.len() > MAX_ROWS { + rows.push(Box::new(Rectangle::new().width(MODULE_W).height(ROW_H).align_child(Start, Center).child(note(format!("and {} more", gpu.programs.len() - shown))))); + } + + if gpu.programs.is_empty() { + rows.push(Box::new(Rectangle::new().width(MODULE_W).height(ROW_H).align_child(Start, Center).child(note("No programs on it")))); + } + + module(height, Column::new(children![top, memory, Column::new(rows)]).gap(LINE_GAP)) +} + +// video memory in KiB, the way a person reads it +fn size(kib: u64) -> String { + match kib { + 0..1024 => String::from("<1 MB"), + 1024..1_048_576 => format!("{} MB", kib / 1024), + _ => format!("{:.1} GB", kib as f64 / 1_048_576.0), + } +} diff --git a/src/glyph.rs b/src/glyph.rs new file mode 100644 index 0000000..87a8b04 --- /dev/null +++ b/src/glyph.rs @@ -0,0 +1,196 @@ +// 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, + 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, +} + +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 { + 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 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(x0 * s, y0 * s).to(x1 * s, y1 * s).stroke(thin, color).cap(Cap::Round); + + 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(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)], + 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(0.47, 0.74, 0.47, 0.12), + 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), + ], + // a dot under three arcs fanning upwards + Glyph::Wifi(lit) => { + let mut parts: Vec> = shapes![Circle::new().center(0.5 * s, 0.8 * s).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(0.5 * s, 0.84 * s).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> = shapes![Circle::new().center(0.5 * s, 0.5 * s).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(0.5 * s, 0.5 * s).radius(radius * s).start(start).sweep(90.0).stroke(thin, color).cap(Cap::Round))); + } + } + + parts + } + Glyph::Lock => shapes![ + Arc::new().center(0.5 * s, 0.42 * s).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), + Path::new().move_to(0.18 * s, 0.5 * s).line_to(0.82 * s, 0.5 * s).line_to(0.82 * s, 0.9 * s).line_to(0.18 * s, 0.9 * s).close().fill(color), + ], + Glyph::Sun => { + let mut parts: Vec> = shapes![Circle::new().center(0.5 * s, 0.5 * s).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> = shapes![ + Path::new().move_to(0.06 * s, 0.24 * s).line_to(0.94 * s, 0.24 * s).line_to(0.94 * s, 0.78 * s).line_to(0.06 * s, 0.78 * s).close().stroke(thin, color), + line(0.32, 0.64, 0.68, 0.64), + ]; + + for (row, y) in [0.38, 0.5].into_iter().enumerate() { + for key in 0..4 { + let x = 0.23 + key as f32 * 0.18 + row as f32 * 0.04; + + parts.push(Box::new(Circle::new().center(x * s, y * s).radius(0.045 * s).fill(color))); + } + } + + parts + } + Glyph::Leaf => shapes![ + Path::new().move_to(0.18 * s, 0.84 * s).quad_to(0.14 * s, 0.2 * s, 0.84 * s, 0.16 * s).quad_to(0.84 * s, 0.82 * s, 0.18 * s, 0.84 * s).close().stroke(thin, color), + line(0.18, 0.84, 0.56, 0.46), + ], + Glyph::Gauge => shapes![ + Arc::new().center(0.5 * s, 0.68 * s).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(0.5 * s, 0.68 * s).radius(0.07 * s).fill(color), + ], + Glyph::Bolt => shapes![ + Path::new() + .move_to(0.6 * s, 0.05 * s) + .line_to(0.2 * s, 0.56 * s) + .line_to(0.46 * s, 0.56 * s) + .line_to(0.38 * s, 0.95 * s) + .line_to(0.8 * s, 0.42 * s) + .line_to(0.54 * s, 0.42 * s) + .close() + .fill(color) + ], + // a die with pins on every side + Glyph::Chip => { + let mut parts: Vec> = shapes![ + Path::new().move_to(0.24 * s, 0.24 * s).line_to(0.76 * s, 0.24 * s).line_to(0.76 * s, 0.76 * s).line_to(0.24 * s, 0.76 * s).close().stroke(thin, color), + Path::new().move_to(0.4 * s, 0.4 * s).line_to(0.6 * s, 0.4 * s).line_to(0.6 * s, 0.6 * s).line_to(0.4 * s, 0.6 * s).close().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 + } + }; + + Canvas::new().width(size).height(size).shapes(shapes) +} diff --git a/src/island.rs b/src/island.rs index f5b8c77..fc0fdf9 100644 --- a/src/island.rs +++ b/src/island.rs @@ -4,13 +4,14 @@ use std::thread; use std::time::{Duration, Instant}; use amane::{ - Animation, Arc, Argument, Blend, Bus, Canvas, Cap, Center, Circle, Color, Column, Easing, End, Full, Gradient, - Image, InputArea, Layer, LayerWindow, Line, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, - Pointer, Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, - Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, shapes, + Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image, + InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer, + Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical, + Weight, Widget, Workspaces, Zone, children, shapes, }; use crate::art::Art; +use crate::glyph::{Glyph, bold_icon, icon}; use crate::parse::{self, BANDS, Sink}; use crate::sound::{EQ_PRESETS, MAX_GAIN, Sound}; use crate::spectrum::Spectrum; @@ -92,12 +93,12 @@ const THUMB: u32 = 256; const LETTER: f32 = 0.66; // 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); -const CHIP_OPEN: Color = Color::rgba(255, 255, 255, 34); +pub const CHIP: Color = Color::rgba(255, 255, 255, 18); +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 pub const CHIP_HOVER: Color = Color::rgba(255, 255, 255, 46); 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)] pub enum Menu { @@ -421,7 +422,7 @@ fn opening() -> Animation { } // the monitor of the focused workspace, which on hyprland follows the mouse; none without a workspace list -fn focused_monitor() -> Option { +pub fn focused_monitor() -> Option { let workspaces = Workspaces::read(); workspaces.list().iter().find(|workspace| workspace.focused()).and_then(|workspace| workspace.output()).map(String::from) @@ -533,12 +534,12 @@ fn next_player() { 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 } // 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 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); @@ -553,7 +554,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 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 { return face; } @@ -1421,92 +1422,3 @@ fn band(levels: &[f32], group: usize, groups: usize) -> f32 { 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) -} diff --git a/src/main.rs b/src/main.rs index 9064cef..5a75ee9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ mod art; +mod control; +mod glyph; mod island; mod parse; mod sound; mod spectrum; +mod system; mod theme; mod workspaces; @@ -12,11 +15,14 @@ fn main() { // the card's glow follows the cover art, which art::Art copies to this one path 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 App::new() .font("Fira Code") .ipc("island", island::command) + .ipc("control", control::command) .window_per_monitor(island::view) .window_per_monitor(workspaces::view) + .window_per_monitor(control::view) .run(); } diff --git a/src/parse.rs b/src/parse.rs index be22a4a..1a65f4f 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -327,6 +327,110 @@ pub fn rgb(hex: &str) -> Option<[u8; 3]> { 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//fdinfo/, 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, +} + +pub fn drm_client(fdinfo: &str) -> Option { + 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::().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

`: 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 { + 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) +} + #[cfg(test)] mod tests { use super::*; @@ -463,4 +567,35 @@ Sink #80 assert_eq!(rgb("ffb3af"), 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_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("")); + } } diff --git a/src/system.rs b/src/system.rs new file mode 100644 index 0000000..9359a96 --- /dev/null +++ b/src/system.rs @@ -0,0 +1,670 @@ +/* + * 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; empty without a device + pub device: 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, +} + +#[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 vpns: Vec, + + // bluez's openrc service is installed, so a click can start it + pub bluetooth_service: bool, +} + +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 vpns = vpns(); + let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists(); + + let stale = { + let links = Links::read(); + + links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service + }; + + if stale { + *Links::write() = Links { wifi, vpns, bluetooth_service }; + } +} + +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()); + + // the connected network's signal, in hundredths of a dBm + let bars = if connected.is_empty() { + 0 + } else { + let ordered = bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]); + + let signal = ordered + .list() + .iter() + .find(|entry| entry.list().first().map(|path| path.text()) == Some(connected)) + .and_then(|entry| entry.list().get(1)) + .map_or(-10_000.0, |signal| signal.number()); + + parse::wifi_bars(signal / 100.0) + }; + + let hotspot = access_point.get("Started").bool(); + + let clients = if hotspot { + bus.call(IWD, path, "net.connman.iwd.AccessPointDiagnostic", "GetDiagnostics", &[]).list().len() + } else { + 0 + }; + + Wifi { + service: true, + device: path.clone(), + 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, + } +} + +fn vpns() -> Vec { + let Ok(entries) = fs::read_dir("/etc/init.d") else { + return Vec::new(); + }; + + let mut vpns: Vec = 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/.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")); +} + +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, +} + +#[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, + + // the firmware's profile and the ones it offers, like low-power balanced performance + pub profile: String, + pub profiles: Vec, + + pub keyboard: Option, +} + +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)>> = Mutex::new(None); + +fn charge() -> Option { + 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::().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 { + 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::().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(); +} + +// logind writes the root-owned file for whoever sits at the machine, as amane's Brightness does +pub fn set_keyboard(level: u32) { + let Some(light) = Power::read().keyboard.clone() else { + return; + }; + + let arguments = [Argument::from("leds"), Argument::from(light.name.as_str()), Argument::from(level.min(light.max))]; + + Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments); + + refresh_power(); +} + +// 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, +} + +#[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, + pub watts: Option, + + // the most video memory first + pub programs: Vec, +} + +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 = HashMap::new(); + let mut engines: HashMap = 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, engines: &mut HashMap) -> Gpu { + let Ok(entries) = fs::read_dir("/sys/class/drm") else { + return Gpu::default(); + }; + + let mut cards: Vec = 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::().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::().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::().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) -> Vec { + let Ok(processes) = fs::read_dir("/proc") else { + return Vec::new(); + }; + + let mut found: BTreeMap)> = 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::().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 = 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 = 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() +} diff --git a/src/theme.rs b/src/theme.rs index 7d5baea..1e03bfe 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -73,6 +73,9 @@ pub struct Theme { // the open media card and the equalizer take the cover's colours as their background pub cover_colors: bool, + + // the iwd access point profile the control centre's hotspot starts (/var/lib/iwd/ap/.ap); empty: none + pub hotspot: String, } impl Service for Theme { @@ -80,6 +83,7 @@ impl Service for Theme { Self { colors: Animation::new(PINK_ON_PURPLE), cover_colors: false, + hotspot: String::new(), } } @@ -174,6 +178,7 @@ fn apply(theme: &str, config: &str, startup: bool) { let mut theme = Theme::write(); theme.cover_colors = setting("cover_colors") == Some("true"); + theme.hotspot = setting("hotspot").unwrap_or("").to_string(); // a change in the middle of a fade starts from the colours on screen now let now = theme.colors.value();