control centre: status pill top right that opens into a macOS-like panel
Wi-Fi and hotspot (iwd, never D-Bus-activated by a read), VPN (openrc openvpn.*), Bluetooth (bluez), battery, volume, screen and keyboard light, performance mode (platform profile + governor + EPP through doas -n), and the external GPU with the programs using it (drm fdinfo). Icons move to glyph.rs.
This commit is contained in:
@@ -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.<name> 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/<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 status # expanded=<bool> wifi=<state> vpn=<state> profile=<p> battery=<n%>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.<name>` 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
|
||||
|
||||
|
||||
@@ -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/<name>.ap
|
||||
# (see README "Control centre"); leave it out and the hotspot tile says so
|
||||
# hotspot = MyHotspot
|
||||
|
||||
+1052
File diff suppressed because it is too large
Load Diff
+196
@@ -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<Box<dyn Shape>> = 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<Box<dyn Shape>> = 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<Box<dyn Shape>> = 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<Box<dyn Shape>> = 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<Box<dyn Shape>> = 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)
|
||||
}
|
||||
+12
-100
@@ -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<String> {
|
||||
pub fn focused_monitor() -> Option<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
+7
-1
@@ -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();
|
||||
}
|
||||
|
||||
+135
@@ -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/<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)
|
||||
}
|
||||
|
||||
#[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(""));
|
||||
}
|
||||
}
|
||||
|
||||
+670
@@ -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<Vpn>,
|
||||
|
||||
// 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<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"));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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<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,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/<name>.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();
|
||||
|
||||
Reference in New Issue
Block a user