control centre: Wi-Fi and Bluetooth menus as a map around this desktop

The menus are 820 px wide (the panel resizes to them). This desktop is a bubble in
the middle, networks and devices float around it, nearer the stronger (or the more
connected) they are, rings ripple out while scanning. Each is tied to the bubble by a
line: grey and wobbling while idle, running out from the bubble after a click until
it's connected, solid with a travelling pulse once connected.

The map is one Canvas: node discs and icons are shapes in it (glyph_shapes), the node
widgets only take the pointer. Seven icon Canvas widgets had cost 4.6 ms of gpu a
frame and halved the frame rate; now 1.8 ms at 60 fps. The menu window is sized to
its menu.
This commit is contained in:
server2
2026-10-11 02:58:50 +02:00
parent 7bbb23d9ca
commit 8fa951fe15
6 changed files with 533 additions and 163 deletions
+6 -5
View File
@@ -21,11 +21,11 @@ The pill shows only what runs: Wi-Fi signal while iwd runs (dim while its radio
The panel has: The panel has:
- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once. - **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu lists the networks in range (connected first, then by signal): click the connected one to disconnect, a saved or open one to join. A new network with a password has to be joined once with `iwctl` (the menu can't ask for passwords yet). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it. - *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it, the connected one to disconnect. A new network with a password has to be joined once with `iwctl` (the map can't ask for passwords yet). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
- *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu scans while it's open and lists the devices: click to connect, disconnect, or pair (pairing works for devices without a PIN). - *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair (pairing works for devices without a PIN).
- *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn.<name> start|stop`. - *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn.<name> start|stop`.
- *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup). - *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup).
- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath, so the two lie on top of each other. The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first. - **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath (the Wi-Fi and Bluetooth maps are 820 px wide, more than twice the panel), so the two lie on top of each other. The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first.
- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output. - **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output.
- **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness). - **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness).
- **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor. - **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.
@@ -116,9 +116,10 @@ src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli,
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava) src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
src/art.rs cover download (curl) and the palette file src/art.rs cover download (curl) and the palette file
src/workspaces.rs the workspace pill, top left on every monitor src/workspaces.rs the workspace pill, top left on every monitor
src/control.rs the control centre: status pill top right, its panel, `control` IPC src/control.rs the control centre: status pill top right, its panel and menus, `control` IPC
src/map.rs the Wi-Fi and Bluetooth menus' map: this desktop in the middle, what it can connect to around it
src/system.rs what the control centre shows: iwd, openvpn, power, the external GPU src/system.rs what the control centre shows: iwd, openvpn, power, the external GPU
src/glyph.rs every drawn icon src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
src/parse.rs pure text parsing with unit tests, no amane dependency src/parse.rs pure text parsing with unit tests, no amane dependency
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
+191 -87
View File
@@ -17,6 +17,7 @@ use amane::{
use crate::glyph::{Glyph, icon}; use crate::glyph::{Glyph, icon};
use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, COMPACT_H, HOVER_MS, PILL_GONE, SHADOW, SHAPE_DONE, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled}; use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, COMPACT_H, HOVER_MS, PILL_GONE, SHADOW, SHAPE_DONE, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled};
use crate::map::{self, AREA_H, Link, MAP_W, NODES, Node};
use crate::parse; use crate::parse;
use crate::sound::Sound; use crate::sound::Sound;
use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; use crate::system::{self, Charge, Gpu, Links, Power, VpnState};
@@ -38,6 +39,9 @@ const OUTPUTS: usize = 8;
// the pill and the panel sit this far from the monitor's right edge // the pill and the panel sit this far from the monitor's right edge
const RIGHT: f32 = 12.0; const RIGHT: f32 = 12.0;
// both windows are as wide as a map menu, the widest the panel becomes
const WINDOW_W: f32 = RIGHT + MAP_W + SHADOW;
// the pill is as wide as its icons, PILL_PAD on each side, PILL_GAP apart // the pill is as wide as its icons, PILL_PAD on each side, PILL_GAP apart
const PILL_PAD: f32 = 16.0; const PILL_PAD: f32 = 16.0;
const PILL_GAP: f32 = 12.0; const PILL_GAP: f32 = 12.0;
@@ -141,6 +145,9 @@ pub struct Control {
// the pointer is over the panel's window or the menu's; it closes only once it has left both // the pointer is over the panel's window or the menu's; it closes only once it has left both
inside_panel: bool, inside_panel: bool,
inside_menu: bool, inside_menu: bool,
// a network or device just clicked to connect (iwd path or bluez path): its line runs out until it's connected
pending: Option<(String, Instant)>,
} }
impl Service for Control { impl Service for Control {
@@ -158,6 +165,7 @@ impl Service for Control {
menu_open: menu_opening(), menu_open: menu_opening(),
inside_panel: false, inside_panel: false,
inside_menu: false, inside_menu: false,
pending: None,
} }
} }
@@ -184,6 +192,11 @@ impl Control {
fn busy(&self, tile: Tile, subtitle: &str) -> bool { 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)) self.busy.get(&tile).is_some_and(|(at, before)| before == subtitle && at.elapsed() < Duration::from_secs(10))
} }
// asked to connect less than 20 s ago
fn pending(&self, id: &str) -> bool {
self.pending.as_ref().is_some_and(|(pending, at)| pending == id && at.elapsed() < Duration::from_secs(20))
}
} }
// 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 // 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
@@ -290,7 +303,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let hovered_here = control.pointer_on.as_deref() == Some(here); 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 lift = if hovered_here { control.lit(Spot::Pill) } else { 0.0 } * (1.0 - grow);
let window_w = RIGHT + PANEL_W + SHADOW; // as wide as the widest thing it can become, a map menu; the shape grows leftwards from the corner
let window_w = WINDOW_W;
let window = LayerWindow::new() let window = LayerWindow::new()
.width(window_w) .width(window_w)
@@ -333,21 +347,25 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
*/ */
let menu_t = if opened_here { control.menu_open.value() } else { 0.0 }; let menu_t = if opened_here { control.menu_open.value() } else { 0.0 };
let menu_h = match (control.menu.filter(|_| menu_t > 0.0), &sound) { let menu_size = match (control.menu.filter(|_| menu_t > 0.0), &sound) {
(Some(menu), Some(sound)) => Some(menu_height(&menu_content(menu, &control, &links, &bluetooth, sound, layout.extra_output).0)), (Some(menu), Some(sound)) => {
let content = menu_content(menu, &control, &links, &bluetooth, sound, layout.extra_output).0;
Some((content.width, menu_height(&content)))
}
_ => None, _ => None,
}; };
let fade = if menu_h.is_some() { 1.0 - (menu_t / PILL_GONE).clamp(0.0, 1.0) } else { 1.0 }; let fade = if menu_size.is_some() { 1.0 - (menu_t / PILL_GONE).clamp(0.0, 1.0) } else { 1.0 };
let morph = ((menu_t - PILL_GONE) / (SHAPE_DONE - PILL_GONE)).clamp(0.0, 1.0); let morph = ((menu_t - PILL_GONE) / (SHAPE_DONE - PILL_GONE)).clamp(0.0, 1.0);
let morph = morph * morph * (3.0 - 2.0 * morph); let morph = morph * morph * (3.0 - 2.0 * morph);
let full_h = menu_h.map_or(layout.height(), |menu_h| mix(layout.height(), menu_h, morph)); let (full_w, full_h) = menu_size.map_or((PANEL_W, layout.height()), |(menu_w, menu_h)| (mix(PANEL_W, menu_w, morph), mix(layout.height(), menu_h, morph)));
let (icons, pill_w) = pill_items(&links, &bluetooth, &power); let (icons, pill_w) = pill_items(&links, &bluetooth, &power);
let width = mix(pill_w + PILL_HOVER_GROW * lift, PANEL_W, grow); let width = mix(pill_w + PILL_HOVER_GROW * lift, full_w, grow);
let height = mix(COMPACT_H, full_h, grow); let height = mix(COMPACT_H, full_h, grow);
let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow); let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow);
@@ -1393,17 +1411,16 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let control = Control::read(); let control = Control::read();
let window_w = RIGHT + PANEL_W + SHADOW; let window = |width: f32, height: f32| {
let largest = Layout::LARGEST.height(); LayerWindow::new()
.width(width)
let window = LayerWindow::new() .height(height)
.width(window_w) .anchor_vertical(Vertical::Top)
.height(TOP_GAP + largest + SHADOW) .anchor_horizontal(Horizontal::Right)
.anchor_vertical(Vertical::Top) .layer(Layer::Overlay)
.anchor_horizontal(Horizontal::Right) .space(Zone::Ignore)
.layer(Layer::Overlay) .namespace("magic-control-menu")
.space(Zone::Ignore) };
.namespace("magic-control-menu");
let here = monitor.name.as_str(); let here = monitor.name.as_str();
let on_here = control.opened_on.as_deref().is_none_or(|name| name == here); let on_here = control.opened_on.as_deref().is_none_or(|name| name == here);
@@ -1413,9 +1430,9 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let leaving = !control.menu_up && !control.expanded; let leaving = !control.menu_up && !control.expanded;
let (_, _, panel_reveal) = phases(if on_here { control.open.value() } else { 0.0 }); let (_, _, panel_reveal) = phases(if on_here { control.open.value() } else { 0.0 });
// never hidden (see the island's view): an empty click-through window instead // never hidden (see the island's view): a tiny, empty click-through window instead
let Some(menu) = control.menu.filter(|_| on_here && timeline > 0.0 && (!leaving || panel_reveal > 0.0)) else { let Some(menu) = control.menu.filter(|_| on_here && timeline > 0.0 && (!leaving || panel_reveal > 0.0)) else {
return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); return window(1.0, 1.0).click_through().child(Rectangle::new().width(0.0).height(0.0));
}; };
let links = Links::read(); let links = Links::read();
@@ -1425,40 +1442,60 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let (content, origin) = menu_content(menu, &control, &links, &bluetooth, &sound, extra); let (content, origin) = menu_content(menu, &control, &links, &bluetooth, &sound, extra);
let menu_w = content.width;
let menu_h = menu_height(&content); let menu_h = menu_height(&content);
// the maps float and wobble all the time: frames for as long as one shows
if matches!(menu, Menu::Wifi | Menu::Bluetooth) {
amane::request_frame();
}
let (grow, _, settled) = phases(timeline); let (grow, _, settled) = phases(timeline);
// leaving: the content fades with the panel's own timeline, the shape stays put on the panel // leaving: the content fades with the panel's own timeline, the shape stays put on the panel
let reveal = if leaving { settled * panel_reveal } else { settled }; let reveal = if leaving { settled * panel_reveal } else { settled };
// from the tile, measured from the panel's top left, which sits at the right of the menu-wide box
let (x0, y0, w0, h0) = origin; let (x0, y0, w0, h0) = origin;
let x0 = x0 + menu_w - PANEL_W;
// only as big as this menu and the tile it grows from: every frame redraws the whole window, and maps redraw all the time
let box_h = menu_h.max(y0 + h0);
let window_w = RIGHT + menu_w + SHADOW;
let window = window(window_w, TOP_GAP + box_h + SHADOW);
// to the menu's own size, against the same right edge as the panel
let (x, y) = (mix(x0, 0.0, grow), mix(y0, 0.0, grow)); let (x, y) = (mix(x0, 0.0, grow), mix(y0, 0.0, grow));
let (width, height) = (mix(w0, PANEL_W, grow), mix(h0, menu_h, grow)); let (width, height) = (mix(w0, menu_w, grow), mix(h0, menu_h, grow));
let radius = mix(MODULE_RADIUS, PANEL_RADIUS, grow); let radius = mix(MODULE_RADIUS, PANEL_RADIUS, grow);
let colors = theme::now().surfaces(); let colors = theme::now().surfaces();
let face = if reveal > 0.0 { let face = if reveal > 0.0 {
let page = Rectangle::new() let page = Rectangle::new()
.width(PANEL_W) .width(menu_w)
.height(menu_h) .height(menu_h)
.padding(MENU_PAD) .padding(MENU_PAD)
.child(Column::new(children![menu_header(&control, content.title, content.right), content.body]).gap(GAP)); .child(Column::new(children![menu_header(&control, content.title, content.right, menu_w - 2.0 * MENU_PAD), content.body]).gap(GAP));
veiled(page, width, height, radius, reveal, &colors) veiled(page, width, height, radius, reveal, &colors)
} else { } else {
Rectangle::new().width(0.0).height(0.0) Rectangle::new().width(0.0).height(0.0)
}; };
// a shadow only while it grows: settled, it lies on the panel, which has its own glow; a blur costs every frame, even at 0
let shadow = 0.45 * grow * (1.0 - settled);
let shape = Rectangle::new() let shape = Rectangle::new()
.width(width) .width(width)
.height(height) .height(height)
.radius(radius) .radius(radius)
.fill(backdrop(&colors, 255)) .fill(backdrop(&colors, 255))
.border(1.0, theme::now().outline) .border(1.0, theme::now().outline);
// a shadow only while it grows: settled, it lies on the panel, which has its own glow
.shadow(Shadow::drop("#000000").opacity(0.45 * grow * (1.0 - settled)).blur(24.0).offset(0.0, 10.0)) let shape = if shadow > 0.01 { shape.shadow(Shadow::drop("#000000").opacity(shadow).blur(24.0).offset(0.0, 10.0)) } else { shape };
let shape = shape
.align_child(Center, Start) .align_child(Center, Start)
.translate(x, y) .translate(x, y)
.on_hover(|inside| { .on_hover(|inside| {
@@ -1471,7 +1508,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
.child(face); .child(face);
let region = InputArea { let region = InputArea {
x: (window_w - RIGHT - PANEL_W + x).floor() as i32, x: (window_w - RIGHT - menu_w + x).floor() as i32,
y: (TOP_GAP + y).floor() as i32, y: (TOP_GAP + y).floor() as i32,
width: width.ceil() as i32, width: width.ceil() as i32,
height: height.ceil() as i32, height: height.ceil() as i32,
@@ -1483,7 +1520,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
.height(Parent) .height(Parent)
.padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 }) .padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 })
.align_child(End, Start) .align_child(End, Start)
.child(Rectangle::new().width(PANEL_W).height(largest).align_child(Start, Start).child(shape)), .child(Rectangle::new().width(menu_w).height(box_h).align_child(Start, Start).child(shape)),
) )
} }
@@ -1517,10 +1554,11 @@ fn menu_height(content: &MenuContent) -> f32 {
(2.0 * MENU_PAD + MENU_HEADER_H + GAP + content.height).min(Layout::LARGEST.height()) (2.0 * MENU_PAD + MENU_HEADER_H + GAP + content.height).min(Layout::LARGEST.height())
} }
// what a menu shows under its header // what a menu shows under its header, and how wide the menu is (the panel's width, or a map's)
struct MenuContent { struct MenuContent {
title: &'static str, title: &'static str,
right: Option<Box<dyn Widget>>, right: Option<Box<dyn Widget>>,
width: f32,
height: f32, height: f32,
body: Column, body: Column,
} }
@@ -1550,14 +1588,14 @@ impl Body {
self.add(Rectangle::new().width(MENU_W).height(NOTE_H).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), NOTE_H); self.add(Rectangle::new().width(MENU_W).height(NOTE_H).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), NOTE_H);
} }
fn into_content(self, title: &'static str, right: Option<Box<dyn Widget>>) -> MenuContent { fn into_content(self, title: &'static str, right: Option<Box<dyn Widget>>, width: f32) -> MenuContent {
let gap = self.gap; let gap = self.gap;
MenuContent { title, right, height: self.height, body: Column::new(self.rows).gap(gap) } MenuContent { title, right, width, height: self.height, body: Column::new(self.rows).gap(gap) }
} }
} }
fn menu_header(control: &Control, title: &str, right: Option<Box<dyn Widget>>) -> Row { fn menu_header(control: &Control, title: &str, right: Option<Box<dyn Widget>>, width: f32) -> Row {
let theme = theme::now(); let theme = theme::now();
let lit = control.lit(Spot::Back); let lit = control.lit(Spot::Back);
@@ -1580,7 +1618,7 @@ fn menu_header(control: &Control, title: &str, right: Option<Box<dyn Widget>>) -
parts.push(right); parts.push(right);
} }
Row::new(parts).justify(SpaceBetween).align(Center).width(MENU_W).height(MENU_HEADER_H) Row::new(parts).justify(SpaceBetween).align(Center).width(width).height(MENU_HEADER_H)
} }
// an on/off switch; grey: the service behind it isn't running, and switching on starts it // an on/off switch; grey: the service behind it isn't running, and switching on starts it
@@ -1680,59 +1718,86 @@ fn wifi_menu(control: &Control, links: &Links) -> MenuContent {
Box::new(toggle) as Box<dyn Widget> Box::new(toggle) as Box<dyn Widget>
}); });
let mut body = Body::new(4.0); let note = if !wifi.service {
Some("iwd isn't running. The switch starts it.")
if !wifi.service {
body.note("iwd isn't running. The switch starts it.");
} else if wifi.device.is_empty() { } else if wifi.device.is_empty() {
body.note("iwd sees no Wi-Fi device."); Some("iwd sees no Wi-Fi device.")
} else if wifi.access_point { } else if wifi.access_point {
body.note("The hotspot is using Wi-Fi; the switch stops it."); Some("The hotspot is using Wi-Fi; the switch stops it.")
} else if !wifi.powered { } else if !wifi.powered {
body.note("Wi-Fi is off."); Some("Wi-Fi is off.")
} else if wifi.networks.is_empty() { } else if wifi.networks.is_empty() {
body.note("Looking for networks…"); Some("Looking for networks…")
} else { } else {
for (index, network) in wifi.networks.iter().take(MENU_ROWS).enumerate() { None
let subtitle = if network.connected { };
"Connected"
} else if network.known { // by name, so each keeps its place around the bubble while signals change
"Saved" let mut networks: Vec<_> = if note.is_none() { wifi.networks.iter().take(NODES).collect() } else { Vec::new() };
} else if network.secured {
"New · join it once with iwctl" networks.sort_by(|one, other| one.name.cmp(&other.name));
let nodes = networks
.into_iter()
.enumerate()
.map(|(index, network)| {
let link = if network.connected {
Link::Connected
} else if network.connecting || control.pending(&network.path) {
Link::Connecting
} else { } else {
"Open" Link::Idle
}; };
let lock = network.secured.then(|| Box::new(icon(Glyph::Lock, 12.0, theme::now().dim)) as Box<dyn Widget>); let detail = match link {
Link::Connected => "Connected",
let row = menu_row(control, index, Glyph::Wifi(network.bars), network.connected, &network.name, subtitle, lock); Link::Connecting => "Connecting…",
Link::Idle if network.known => "Saved",
Link::Idle if !network.secured => "Open",
Link::Idle => "New · join with iwctl",
};
let joinable = network.known || !network.secured; let joinable = network.known || !network.secured;
let path = network.path.clone();
let row = if network.connected { let on_click: Option<Box<dyn Fn()>> = match link {
row.cursor(Pointer).on_click(|_| later(system::disconnect_wifi, None)) Link::Connected => Some(Box::new(|| {
} else if joinable { Control::write().pending = None;
let path = network.path.clone();
later(system::disconnect_wifi, None);
})),
Link::Idle if joinable => Some(Box::new(move || {
Control::write().pending = Some((path.clone(), Instant::now()));
row.cursor(Pointer).on_click(move |_| {
let path = path.clone(); let path = path.clone();
later(move || system::connect_network(&path), None); later(move || system::connect_network(&path), None);
}) })),
} else { _ => None,
row
}; };
body.add(row, MENU_ROW_H); Node {
} glyph: Glyph::Wifi(network.bars),
} name: network.name.clone(),
detail: String::from(detail),
nearness: f32::from(network.bars.saturating_sub(1)) / 2.0,
link,
lit: control.lit(Spot::Row(index)),
on_hover: Box::new(hovering(Spot::Row(index))),
on_click,
}
})
.collect();
let mut body = Body::new(GAP);
body.add(map::map(MAP_W - 2.0 * MENU_PAD, AREA_H, &links.host, nodes, wifi.scanning, note), AREA_H);
if wifi.service { if wifi.service {
body.add(footer(control, "Stop the Wi-Fi service").on_click(|_| later(system::stop_wifi_service, None)), 30.0); body.add(footer(control, "Stop the Wi-Fi service").on_click(|_| later(system::stop_wifi_service, None)), 30.0);
} }
body.into_content("Wi-Fi", right) body.into_content("Wi-Fi", right, MAP_W)
} }
fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> MenuContent { fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> MenuContent {
@@ -1745,44 +1810,83 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> Me
Box::new(toggle) as Box<dyn Widget> Box::new(toggle) as Box<dyn Widget>
}); });
let mut body = Body::new(4.0); let note = if !bluetooth.available() && !links.bluetooth_service {
Some("bluez isn't installed: emerge net-wireless/bluez")
if !bluetooth.available() && !links.bluetooth_service {
body.note("bluez isn't installed.");
body.note("emerge net-wireless/bluez, then switch it on here.");
} else if !bluetooth.available() { } else if !bluetooth.available() {
body.note("bluetoothd isn't running. The switch starts it."); Some("bluetoothd isn't running. The switch starts it.")
} else if !bluetooth.powered() { } else if !bluetooth.powered() {
body.note("Bluetooth is off."); Some("Bluetooth is off.")
} else if bluetooth.devices().is_empty() { } else if bluetooth.devices().is_empty() {
body.note(if bluetooth.scanning() { "Looking for devices…" } else { "No devices." }); Some(if bluetooth.scanning() { "Looking for devices…" } else { "No devices." })
} else { } else {
for (index, device) in bluetooth.devices().iter().take(MENU_ROWS).enumerate() { None
let subtitle = match (device.connected(), device.paired(), device.battery()) { };
(true, _, Some(battery)) => format!("Connected · {battery}%"),
(true, _, None) => String::from("Connected"), // by address, so each keeps its place around the bubble
(false, true, _) => String::from("Paired"), let mut devices: Vec<_> = if note.is_none() { bluetooth.devices().iter().take(NODES).collect() } else { Vec::new() };
(false, false, _) => String::from("Not paired · click to pair"),
devices.sort_by(|one, other| one.address().cmp(other.address()));
let nodes = devices
.into_iter()
.enumerate()
.map(|(index, device)| {
let link = if device.connected() {
Link::Connected
} else if control.pending(device.path()) {
Link::Connecting
} else {
Link::Idle
};
let detail = match (link, device.battery()) {
(Link::Connected, Some(battery)) => format!("Connected · {battery}%"),
(Link::Connected, None) => String::from("Connected"),
(Link::Connecting, _) => String::from("Connecting…"),
_ if device.paired() => String::from("Paired"),
_ => String::from("Not paired · click to pair"),
};
// what it is, as far as bluez can tell
let glyph = match device.icon() {
kind if kind.starts_with("audio") => Glyph::Speaker,
"input-keyboard" => Glyph::Keyboard,
_ => Glyph::Bluetooth,
}; };
let path = String::from(device.path()); let path = String::from(device.path());
let (connected, paired) = (device.connected(), device.paired()); let (connected, paired) = (device.connected(), device.paired());
let row = menu_row(control, index, Glyph::Bluetooth, connected, device.name(), &subtitle, None).cursor(Pointer).on_click(move |_| { let on_click: Box<dyn Fn()> = Box::new(move || {
if connected { if connected {
Control::write().pending = None;
Bluetooth::disconnect(&path); Bluetooth::disconnect(&path);
} else if paired {
Bluetooth::connect(&path);
} else { } else {
Bluetooth::pair(&path); Control::write().pending = Some((path.clone(), Instant::now()));
if paired { Bluetooth::connect(&path) } else { Bluetooth::pair(&path) }
} }
}); });
body.add(row, MENU_ROW_H); Node {
} glyph,
} name: device.name().to_string(),
detail,
nearness: if connected { 1.0 } else if paired { 0.6 } else { 0.1 },
link,
lit: control.lit(Spot::Row(index)),
on_hover: Box::new(hovering(Spot::Row(index))),
on_click: Some(on_click),
}
})
.collect();
body.into_content("Bluetooth", right) let mut body = Body::new(GAP);
body.add(map::map(MAP_W - 2.0 * MENU_PAD, AREA_H, &links.host, nodes, bluetooth.available() && bluetooth.scanning(), note), AREA_H);
body.into_content("Bluetooth", right, MAP_W)
} }
fn vpn_menu(control: &Control, links: &Links) -> MenuContent { fn vpn_menu(control: &Control, links: &Links) -> MenuContent {
@@ -1813,7 +1917,7 @@ fn vpn_menu(control: &Control, links: &Links) -> MenuContent {
body.add(row, MENU_ROW_H); body.add(row, MENU_ROW_H);
} }
body.into_content("VPN", None) body.into_content("VPN", None, PANEL_W)
} }
// every output with its own volume; a click on its name makes it the default // every output with its own volume; a click on its name makes it the default
@@ -1860,5 +1964,5 @@ fn sound_menu(control: &Control, sound: &Sound) -> MenuContent {
body.add(Column::new(children![line, output_slider(control, sink, MENU_W)]).gap(6.0), OUTPUT_H + 6.0 + SLIDER_H); body.add(Column::new(children![line, output_slider(control, sink, MENU_W)]).gap(6.0), OUTPUT_H + 6.0 + SLIDER_H);
} }
body.into_content("Sound", None) body.into_content("Sound", None, PANEL_W)
} }
+72 -68
View File
@@ -26,6 +26,8 @@ pub enum Glyph {
Gauge, Gauge,
Bolt, Bolt,
Chip, Chip,
// this machine, in the middle of the Wi-Fi and Bluetooth maps
Laptop,
} }
pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas { pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
@@ -34,18 +36,30 @@ pub fn icon(glyph: Glyph, size: f32, color: Color) -> Canvas {
// play, pause, previous and next thickened by an outline `weight` px wide in their own colour; 0 draws none // 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 { pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
let s = size; Canvas::new().width(size).height(size).shapes(glyph_shapes(glyph, size, color, weight, (0.0, 0.0)))
}
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) * the glyph's shapes with its top left at `origin`, for drawing many icons into one
}; * Canvas: every Canvas widget costs the gpu on its own (7 small icon canvases took a
* map from 1.8 to 6.4 ms a frame), shapes inside one canvas cost next to nothing
*/
pub fn glyph_shapes(glyph: Glyph, size: f32, color: Color, weight: f32, origin: (f32, f32)) -> Vec<Box<dyn Shape>> {
let s = size;
let (ox, oy) = origin;
// a fraction of the glyph's box, as a point on the canvas
let x = move |k: f32| ox + k * s;
let y = move |k: f32| oy + k * s;
let triangle = |x0: f32, x1: f32| Path::new().move_to(x(x0), y(0.14)).line_to(x(x1), y(0.5)).line_to(x(x0), y(0.86)).close().fill(color).stroke(weight, color);
let bar = |x0: f32, x1: f32| { let bar = |x0: f32, x1: f32| {
Path::new() Path::new()
.move_to(x0, 0.14 * s) .move_to(x(x0), y(0.14))
.line_to(x1, 0.14 * s) .line_to(x(x1), y(0.14))
.line_to(x1, 0.86 * s) .line_to(x(x1), y(0.86))
.line_to(x0, 0.86 * s) .line_to(x(x0), y(0.86))
.close() .close()
.fill(color) .fill(color)
.stroke(weight, color) .stroke(weight, color)
@@ -53,12 +67,12 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
let speaker = || { let speaker = || {
Path::new() Path::new()
.move_to(0.08 * s, 0.36 * s) .move_to(x(0.08), y(0.36))
.line_to(0.28 * s, 0.36 * s) .line_to(x(0.28), y(0.36))
.line_to(0.54 * s, 0.14 * s) .line_to(x(0.54), y(0.14))
.line_to(0.54 * s, 0.86 * s) .line_to(x(0.54), y(0.86))
.line_to(0.28 * s, 0.64 * s) .line_to(x(0.28), y(0.64))
.line_to(0.08 * s, 0.64 * s) .line_to(x(0.08), y(0.64))
.close() .close()
.fill(color) .fill(color)
}; };
@@ -67,73 +81,69 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
let faint = Color::rgba(color.red(), color.green(), color.blue(), color.alpha() / 3); 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 line = |x0: f32, y0: f32, x1: f32, y1: f32| Line::new().from(x(x0), y(y0)).to(x(x1), y(y1)).stroke(thin, color).cap(Cap::Round);
let shapes = match glyph { // a closed outline through the box's corners at these fractions
Glyph::Play => shapes![triangle(0.2 * s, 0.9 * s)], let frame = |x0: f32, y0: f32, x1: f32, y1: f32| Path::new().move_to(x(x0), y(y0)).line_to(x(x1), y(y0)).line_to(x(x1), y(y1)).line_to(x(x0), y(y1)).close();
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)], match glyph {
Glyph::Previous => shapes![triangle(0.92 * s, 0.3 * s), bar(0.1 * s, 0.26 * s)], Glyph::Play => shapes![triangle(0.2, 0.9)],
Glyph::Speaker => shapes![ Glyph::Pause => shapes![bar(0.18, 0.42), bar(0.58, 0.82)],
speaker(), Glyph::Next => shapes![triangle(0.08, 0.7), bar(0.74, 0.9)],
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::Previous => shapes![triangle(0.92, 0.3), bar(0.1, 0.26)],
], Glyph::Speaker => shapes![speaker(), Arc::new().center(x(0.56), y(0.5)).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round)],
Glyph::Muted => shapes![speaker(), line(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)], Glyph::Muted => shapes![speaker(), line(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)],
Glyph::Bluetooth => shapes![ Glyph::Bluetooth => shapes![
Path::new() Path::new()
.move_to(0.28 * s, 0.3 * s) .move_to(x(0.28), y(0.3))
.line_to(0.72 * s, 0.68 * s) .line_to(x(0.72), y(0.68))
.line_to(0.5 * s, 0.9 * s) .line_to(x(0.5), y(0.9))
.line_to(0.5 * s, 0.1 * s) .line_to(x(0.5), y(0.1))
.line_to(0.72 * s, 0.32 * s) .line_to(x(0.72), y(0.32))
.line_to(0.28 * s, 0.7 * s) .line_to(x(0.28), y(0.7))
.stroke(thin, color) .stroke(thin, color)
.cap(Cap::Round) .cap(Cap::Round)
], ],
Glyph::Chevron => shapes![ Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
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) Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
],
Glyph::Back => shapes![
Path::new().move_to(0.64 * s, 0.14 * s).line_to(0.3 * s, 0.5 * s).line_to(0.64 * s, 0.86 * s).stroke(thin.max(1.6), color).cap(Cap::Round)
],
// an eighth note: round head, stem, a curved flag // an eighth note: round head, stem, a curved flag
Glyph::Note => shapes![ Glyph::Note => shapes![
Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color), Circle::new().center(x(0.32), y(0.74)).radius(0.17 * s).fill(color),
line(0.47, 0.74, 0.47, 0.12), 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), Path::new().move_to(x(0.47), y(0.12)).quad_to(x(0.62), y(0.3), x(0.84), y(0.38)).stroke(thin, color).cap(Cap::Round),
], ],
// a dot under three arcs fanning upwards // a dot under three arcs fanning upwards
Glyph::Wifi(lit) => { 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)]; let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.8)).radius(0.08 * s).fill(color)];
for (arc, radius) in [0.26, 0.46, 0.66].into_iter().enumerate() { for (arc, radius) in [0.26, 0.46, 0.66].into_iter().enumerate() {
let shade = if (arc as u8) < lit { color } else { faint }; 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.push(Box::new(Arc::new().center(x(0.5), y(0.84)).radius(radius * s).start(-45.0).sweep(90.0).stroke(thin, shade).cap(Cap::Round)));
} }
parts parts
} }
// a dot sending out waves to both sides // a dot sending out waves to both sides
Glyph::Hotspot => { 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)]; let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.09 * s).fill(color)];
for radius in [0.24, 0.42] { for radius in [0.24, 0.42] {
for start in [45.0, 225.0] { 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.push(Box::new(Arc::new().center(x(0.5), y(0.5)).radius(radius * s).start(start).sweep(90.0).stroke(thin, color).cap(Cap::Round)));
} }
} }
parts parts
} }
Glyph::Lock => shapes![ 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), Arc::new().center(x(0.5), y(0.42)).radius(0.19 * s).start(-90.0).sweep(180.0).stroke(thin, color),
line(0.31, 0.42, 0.31, 0.52), line(0.31, 0.42, 0.31, 0.52),
line(0.69, 0.42, 0.69, 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), frame(0.18, 0.5, 0.82, 0.9).fill(color),
], ],
Glyph::Sun => { 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)]; let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.17 * s).fill(color)];
for ray in 0..8 { for ray in 0..8 {
let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_4).sin_cos(); let (sin, cos) = (ray as f32 * std::f32::consts::FRAC_PI_4).sin_cos();
@@ -144,47 +154,41 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
parts parts
} }
Glyph::Keyboard => { Glyph::Keyboard => {
let mut parts: Vec<Box<dyn Shape>> = shapes![ let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.06, 0.24, 0.94, 0.78).stroke(thin, color), line(0.32, 0.64, 0.68, 0.64)];
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 (row, key_y) in [0.38, 0.5].into_iter().enumerate() {
for key in 0..4 { for key in 0..4 {
let x = 0.23 + key as f32 * 0.18 + row as f32 * 0.04; let key_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.push(Box::new(Circle::new().center(x(key_x), y(key_y)).radius(0.045 * s).fill(color)));
} }
} }
parts parts
} }
Glyph::Leaf => shapes![ 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), Path::new().move_to(x(0.18), y(0.84)).quad_to(x(0.14), y(0.2), x(0.84), y(0.16)).quad_to(x(0.84), y(0.82), x(0.18), y(0.84)).close().stroke(thin, color),
line(0.18, 0.84, 0.56, 0.46), line(0.18, 0.84, 0.56, 0.46),
], ],
Glyph::Gauge => shapes![ 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), Arc::new().center(x(0.5), y(0.68)).radius(0.38 * s).start(-90.0).sweep(180.0).stroke(thin, color).cap(Cap::Round),
line(0.5, 0.68, 0.68, 0.42), line(0.5, 0.68, 0.68, 0.42),
Circle::new().center(0.5 * s, 0.68 * s).radius(0.07 * s).fill(color), Circle::new().center(x(0.5), y(0.68)).radius(0.07 * s).fill(color),
], ],
Glyph::Bolt => shapes![ Glyph::Bolt => shapes![
Path::new() Path::new()
.move_to(0.6 * s, 0.05 * s) .move_to(x(0.6), y(0.05))
.line_to(0.2 * s, 0.56 * s) .line_to(x(0.2), y(0.56))
.line_to(0.46 * s, 0.56 * s) .line_to(x(0.46), y(0.56))
.line_to(0.38 * s, 0.95 * s) .line_to(x(0.38), y(0.95))
.line_to(0.8 * s, 0.42 * s) .line_to(x(0.8), y(0.42))
.line_to(0.54 * s, 0.42 * s) .line_to(x(0.54), y(0.42))
.close() .close()
.fill(color) .fill(color)
], ],
// a die with pins on every side // a die with pins on every side
Glyph::Chip => { Glyph::Chip => {
let mut parts: Vec<Box<dyn Shape>> = shapes![ let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.24, 0.24, 0.76, 0.76).stroke(thin, color), frame(0.4, 0.4, 0.6, 0.6).fill(color)];
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] { 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.08, at, 0.22)));
@@ -195,7 +199,7 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
parts parts
} }
}; // a screen on a base
Glyph::Laptop => shapes![frame(0.18, 0.2, 0.82, 0.66).stroke(thin, color), Path::new().move_to(x(0.06), y(0.78)).line_to(x(0.94), y(0.78)).stroke(thin * 1.4, color).cap(Cap::Round)],
Canvas::new().width(size).height(size).shapes(shapes) }
} }
+1
View File
@@ -2,6 +2,7 @@ mod art;
mod control; mod control;
mod glyph; mod glyph;
mod island; mod island;
mod map;
mod parse; mod parse;
mod sound; mod sound;
mod spectrum; mod spectrum;
+244
View File
@@ -0,0 +1,244 @@
/*
* the Wi-Fi and Bluetooth menus as a map: this desktop as a bubble in the middle and
* every network or device floating around it, nearer the stronger it is. a line ties
* each one to the bubble: grey and wobbling while it isn't connected, running out from
* the bubble towards it while it connects, solid (with a pulse travelling along it)
* once connected. it moves all the time, so the menu asks for frames while it shows
*/
use std::f32::consts::{PI, TAU};
use std::sync::OnceLock;
use std::time::Instant;
use amane::{Blend, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes};
use crate::glyph::{Glyph, glyph_shapes};
use crate::island::{CHIP_OPEN, faded};
use crate::theme;
// how wide the map menus are, against the panel's 360
pub const MAP_W: f32 = 820.0;
// the map's own height, under the menu's header
pub const AREA_H: f32 = 420.0;
const BUBBLE: f32 = 40.0;
const NODE_R: f32 = 22.0;
const ICON: f32 = 18.0;
const LABEL_W: f32 = 140.0;
const LABEL_H: f32 = 30.0;
// at most this many around the bubble
pub const NODES: usize = 12;
// the wobbling line of what isn't connected
const IDLE_LINE: Color = Color::rgba(205, 205, 212, 85);
#[derive(Clone, Copy, PartialEq)]
pub enum Link {
Idle,
Connecting,
Connected,
}
pub struct Node {
pub glyph: Glyph,
pub name: String,
pub detail: String,
// 0 far out, 1 close to the bubble
pub nearness: f32,
pub link: Link,
pub lit: f32,
pub on_hover: Box<dyn Fn(bool)>,
pub on_click: Option<Box<dyn Fn()>>,
}
// seconds since the shell first drew a map: the clock every float and wobble runs on
fn clock() -> f32 {
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed().as_secs_f32()
}
pub fn map(width: f32, height: f32, host: &str, nodes: Vec<Node>, scanning: bool, note: Option<&str>) -> Rectangle {
let theme = theme::now();
let t = clock();
let (cx, cy) = (width / 2.0, height / 2.0);
let bubble = BUBBLE + (t * 1.3).sin() * 1.5;
// the ellipse the farthest ones sit on, wider than tall like the menu, with room for their labels above or below
let (rx, ry) = (width / 2.0 - LABEL_W / 2.0 - 6.0, cy - NODE_R - LABEL_H - 9.0);
// and the one the nearest stay outside of, clear of the bubble, its ring and its name
let (inner_x, inner_y) = (BUBBLE + NODE_R + 110.0, BUBBLE + NODE_R + 58.0);
let count = nodes.len().max(1) as f32;
let mut idle = Path::new();
let mut running = Path::new();
let mut solid = Path::new();
let mut glow = Path::new();
let mut dots: Vec<Box<dyn Shape>> = Vec::new();
let mut discs: Vec<Box<dyn Shape>> = Vec::new();
let mut floating: Vec<Box<dyn Widget>> = Vec::new();
for (index, node) in nodes.into_iter().enumerate() {
let phase = index as f32;
// evenly round, a quarter step past the top, so no one sits right on top of or under the bubble
let angle = -PI / 2.0 + TAU * (phase + 0.25) / count;
let reach = 1.0 - 0.4 * node.nearness.clamp(0.0, 1.0);
// pushed out to the inner ellipse if the nearness took it inside
let (ex, ey) = (angle.cos() * rx * reach, angle.sin() * ry * reach);
let inside = ((ex / inner_x).powi(2) + (ey / inner_y).powi(2)).sqrt();
let push = if inside < 1.0 { 1.0 / inside.max(0.01) } else { 1.0 };
// a slow float, each on its own rhythm
let drift = ((t * 0.55 + phase * 1.7).sin() * 6.0, (t * 0.45 + phase * 2.3).cos() * 5.0);
let x = cx + ex * push + drift.0;
let y = cy + ey * push + drift.1;
let (dx, dy) = (x - cx, y - cy);
let length = (dx * dx + dy * dy).sqrt().max(1.0);
let (ux, uy) = (dx / length, dy / length);
let start = (cx + ux * bubble, cy + uy * bubble);
let span = (length - bubble - NODE_R).max(0.0);
let point = |along: f32, swing: f32| (start.0 + ux * span * along - uy * swing, start.1 + uy * span * along + ux * swing);
match node.link {
Link::Idle | Link::Connecting => {
// still at both ends, swinging in between, the swing running outwards
let amplitude = if node.link == Link::Idle { 5.0 } else { 2.5 };
let mut path = idle.move_to(start.0, start.1);
for step in 1..=28 {
let along = step as f32 / 28.0;
let swing = amplitude * (along * PI).sin() * (along * 3.0 * TAU - t * 2.4 + phase).sin();
let (px, py) = point(along, swing);
path = path.line_to(px, py);
}
idle = path;
if node.link == Link::Connecting {
// the line runs out from the bubble to the device, over and over until it's connected
let reached = (t * 0.7 + phase * 0.13).fract();
let reached = 1.0 - (1.0 - reached).powi(2);
let tip = point(reached, 0.0);
running = running.move_to(start.0, start.1).line_to(tip.0, tip.1);
dots.push(Box::new(Circle::new().center(tip.0, tip.1).radius(3.5).fill(theme.flame)));
}
}
Link::Connected => {
let end = point(1.0, 0.0);
solid = solid.move_to(start.0, start.1).line_to(end.0, end.1);
glow = glow.move_to(start.0, start.1).line_to(end.0, end.1);
// a pulse travelling out along it
let pulse = point((t * 0.45 + phase * 0.2).fract(), 0.0);
dots.push(Box::new(Circle::new().center(pulse.0, pulse.1).radius(2.5).fill(theme.flame)));
}
}
let (fill, mark) = match node.link {
Link::Connected => (theme.accent, theme.on_accent),
Link::Connecting => (Color::blend(CHIP_OPEN, theme.accent, 0.45), theme.text),
Link::Idle => (CHIP_OPEN, theme.text),
};
let lit = node.lit;
// its disc and icon go into the map's one canvas (see glyph_shapes), on top of every line
let radius = NODE_R * (1.0 + 0.12 * lit);
discs.push(Box::new(Circle::new().center(x, y).radius(radius).fill(Color::blend(fill, theme.flame, 0.2 * lit))));
discs.push(Box::new(Circle::new().center(x, y).radius(radius).stroke(1.0, Color::blend(theme.outline, theme.accent, lit))));
discs.extend(glyph_shapes(node.glyph, ICON, mark, 0.0, (x - ICON / 2.0, y - ICON / 2.0)));
// what's left as a widget is only where the pointer finds it
let target = Rectangle::new().width(2.0 * NODE_R).height(2.0 * NODE_R).radius(Full).translate(x - NODE_R, y - NODE_R).on_hover(node.on_hover);
let target = match node.on_click {
Some(click) => target.cursor(Pointer).on_click(move |_| click()),
None => target,
};
floating.push(Box::new(target));
let bold = node.link == Link::Connected;
// away from the bubble: above the ones above it, so a label never lies on its own line
let label_y = if y < cy - NODE_R { y - NODE_R - 5.0 - LABEL_H } else { y + NODE_R + 5.0 };
floating.push(Box::new(
Rectangle::new().width(LABEL_W).height(LABEL_H).align_child(Center, Start).translate(x - LABEL_W / 2.0, label_y).child(
Column::new(vec![
Box::new(centred(Text::new(node.name).size(11.5).weight(if bold { Weight::Bold } else { Weight::Regular }).color(Color::blend(theme.text, theme.flame, 0.3 * lit)).elide(), 15.0)),
Box::new(centred(Text::new(node.detail).size(10.0).color(theme.dim).elide(), 13.0)),
])
.gap(2.0),
),
));
}
let mut lines: Vec<Box<dyn Shape>> = shapes![
idle.stroke(1.5, IDLE_LINE).cap(Cap::Round),
glow.stroke(7.0, faded(theme.accent, 45)).cap(Cap::Round),
solid.stroke(2.4, theme.accent).cap(Cap::Round),
running.stroke(2.2, faded(theme.accent, 220)).cap(Cap::Round),
];
// while it looks for networks or devices, rings ripple out from the bubble
if scanning {
for ring in 0..2 {
let grown = (t / 1.8 + ring as f32 * 0.5).fract();
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble + 6.0 + grown * 90.0).stroke(1.5, faded(theme.accent, (110.0 * (1.0 - grown)) as u8))));
}
}
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble + 9.0).fill(faded(theme.accent, 38))));
lines.push(Box::new(Circle::new().center(cx, cy).radius(bubble).fill(theme.accent)));
lines.extend(glyph_shapes(Glyph::Laptop, 34.0, theme.on_accent, 0.0, (cx - 17.0, cy - 17.0)));
lines.extend(dots);
lines.extend(discs);
let mut layers: Vec<Box<dyn Widget>> = vec![Box::new(Canvas::new().width(width).height(height).shapes(lines))];
// this desktop's name under its bubble, and what to know under that
layers.push(Box::new(
Rectangle::new()
.width(LABEL_W * 2.0)
.height(16.0)
.align_child(Center, Center)
.translate(cx - LABEL_W, cy + BUBBLE + 12.0)
.child(Text::new(host).size(12.0).weight(Weight::Bold).color(theme.text)),
));
if let Some(note) = note {
layers.push(Box::new(
Rectangle::new().width(LABEL_W * 3.0).height(16.0).align_child(Center, Center).translate(cx - LABEL_W * 1.5, cy + BUBBLE + 32.0).child(Text::new(note).size(11.0).color(theme.dim)),
));
}
layers.extend(floating);
Rectangle::new().width(width).height(height).child(Stack::new(layers).width(width).height(height))
}
fn centred(text: Text, height: f32) -> Rectangle {
Rectangle::new().width(LABEL_W).height(height).align_child(Center, Center).child(text)
}
+19 -3
View File
@@ -49,6 +49,9 @@ pub struct Wifi {
// the networks in range, the connected one first, then by signal // the networks in range, the connected one first, then by signal
pub networks: Vec<Network>, pub networks: Vec<Network>,
// iwd is scanning for networks
pub scanning: bool,
} }
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
@@ -59,6 +62,9 @@ pub struct Network {
pub secured: bool, pub secured: bool,
pub known: bool, pub known: bool,
pub connected: bool, pub connected: bool,
// iwd is joining it right now
pub connecting: bool,
pub bars: u8, pub bars: u8,
} }
@@ -89,6 +95,9 @@ pub struct Links {
// bluez's openrc service is installed, so a click can start it // bluez's openrc service is installed, so a click can start it
pub bluetooth_service: bool, pub bluetooth_service: bool,
// this machine's name, for the bubble in the middle of the maps
pub host: String,
} }
impl Service for Links { impl Service for Links {
@@ -110,15 +119,16 @@ pub fn refresh() {
let wifi = wifi(); let wifi = wifi();
let vpns = vpns(); let vpns = vpns();
let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists(); let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists();
let host = read(Path::new("/proc/sys/kernel/hostname"));
let stale = { let stale = {
let links = Links::read(); let links = Links::read();
links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host
}; };
if stale { if stale {
*Links::write() = Links { wifi, vpns, bluetooth_service }; *Links::write() = Links { wifi, vpns, bluetooth_service, host };
} }
} }
@@ -150,6 +160,10 @@ fn wifi() -> Wifi {
let connected = station.get("ConnectedNetwork").text(); let connected = station.get("ConnectedNetwork").text();
let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text()); let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text());
// iwd names the network it's joining as connected already; the state tells the two apart
let state = station.get("State").text();
let joined = state == "connected" || state == "roaming";
// every network in range as [path, signal in hundredths of a dBm], strongest first // every network in range as [path, signal in hundredths of a dBm], strongest first
let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing }; let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing };
@@ -166,7 +180,8 @@ fn wifi() -> Wifi {
name: found.get("Name").text().to_string(), name: found.get("Name").text().to_string(),
secured: found.get("Type").text() != "open", secured: found.get("Type").text() != "open",
known: !found.get("KnownNetwork").text().is_empty(), known: !found.get("KnownNetwork").text().is_empty(),
connected: path == connected, connected: path == connected && joined,
connecting: path == connected && !joined,
bars: parse::wifi_bars(signal / 100.0), bars: parse::wifi_bars(signal / 100.0),
}) })
}) })
@@ -197,6 +212,7 @@ fn wifi() -> Wifi {
hotspot_name: access_point.get("Name").text().to_string(), hotspot_name: access_point.get("Name").text().to_string(),
clients, clients,
networks, networks,
scanning: station.get("Scanning").bool(),
} }
} }