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
+191 -87
View File
@@ -17,6 +17,7 @@ use amane::{
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::map::{self, AREA_H, Link, MAP_W, NODES, Node};
use crate::parse;
use crate::sound::Sound;
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
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
const PILL_PAD: f32 = 16.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
inside_panel: 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 {
@@ -158,6 +165,7 @@ impl Service for Control {
menu_open: menu_opening(),
inside_panel: false,
inside_menu: false,
pending: None,
}
}
@@ -184,6 +192,11 @@ impl Control {
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))
}
// 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
@@ -290,7 +303,8 @@ pub fn view(monitor: &Monitor) -> LayerWindow {
let hovered_here = control.pointer_on.as_deref() == Some(here);
let lift = if hovered_here { control.lit(Spot::Pill) } else { 0.0 } * (1.0 - grow);
let window_w = RIGHT + PANEL_W + SHADOW;
// 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()
.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_h = 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)),
let menu_size = match (control.menu.filter(|_| menu_t > 0.0), &sound) {
(Some(menu), Some(sound)) => {
let content = menu_content(menu, &control, &links, &bluetooth, sound, layout.extra_output).0;
Some((content.width, menu_height(&content)))
}
_ => 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 = 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 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 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 window_w = RIGHT + PANEL_W + SHADOW;
let largest = Layout::LARGEST.height();
let window = LayerWindow::new()
.width(window_w)
.height(TOP_GAP + largest + SHADOW)
.anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Right)
.layer(Layer::Overlay)
.space(Zone::Ignore)
.namespace("magic-control-menu");
let window = |width: f32, height: f32| {
LayerWindow::new()
.width(width)
.height(height)
.anchor_vertical(Vertical::Top)
.anchor_horizontal(Horizontal::Right)
.layer(Layer::Overlay)
.space(Zone::Ignore)
.namespace("magic-control-menu")
};
let here = monitor.name.as_str();
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 (_, _, 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 {
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();
@@ -1425,40 +1442,60 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
let (content, origin) = menu_content(menu, &control, &links, &bluetooth, &sound, extra);
let menu_w = content.width;
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);
// 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 };
// 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 = 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 (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 colors = theme::now().surfaces();
let face = if reveal > 0.0 {
let page = Rectangle::new()
.width(PANEL_W)
.width(menu_w)
.height(menu_h)
.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)
} else {
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()
.width(width)
.height(height)
.radius(radius)
.fill(backdrop(&colors, 255))
.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))
.border(1.0, theme::now().outline);
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)
.translate(x, y)
.on_hover(|inside| {
@@ -1471,7 +1508,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
.child(face);
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,
width: width.ceil() as i32,
height: height.ceil() as i32,
@@ -1483,7 +1520,7 @@ pub fn menu_view(monitor: &Monitor) -> LayerWindow {
.height(Parent)
.padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 })
.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())
}
// 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 {
title: &'static str,
right: Option<Box<dyn Widget>>,
width: f32,
height: f32,
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);
}
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;
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 lit = control.lit(Spot::Back);
@@ -1580,7 +1618,7 @@ fn menu_header(control: &Control, title: &str, right: Option<Box<dyn Widget>>) -
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
@@ -1680,59 +1718,86 @@ fn wifi_menu(control: &Control, links: &Links) -> MenuContent {
Box::new(toggle) as Box<dyn Widget>
});
let mut body = Body::new(4.0);
if !wifi.service {
body.note("iwd isn't running. The switch starts it.");
let note = if !wifi.service {
Some("iwd isn't running. The switch starts it.")
} 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 {
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 {
body.note("Wi-Fi is off.");
Some("Wi-Fi is off.")
} else if wifi.networks.is_empty() {
body.note("Looking for networks…");
Some("Looking for networks…")
} else {
for (index, network) in wifi.networks.iter().take(MENU_ROWS).enumerate() {
let subtitle = if network.connected {
"Connected"
} else if network.known {
"Saved"
} else if network.secured {
"New · join it once with iwctl"
None
};
// by name, so each keeps its place around the bubble while signals change
let mut networks: Vec<_> = if note.is_none() { wifi.networks.iter().take(NODES).collect() } else { Vec::new() };
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 {
"Open"
Link::Idle
};
let lock = network.secured.then(|| Box::new(icon(Glyph::Lock, 12.0, theme::now().dim)) as Box<dyn Widget>);
let row = menu_row(control, index, Glyph::Wifi(network.bars), network.connected, &network.name, subtitle, lock);
let detail = match link {
Link::Connected => "Connected",
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 path = network.path.clone();
let row = if network.connected {
row.cursor(Pointer).on_click(|_| later(system::disconnect_wifi, None))
} else if joinable {
let path = network.path.clone();
let on_click: Option<Box<dyn Fn()>> = match link {
Link::Connected => Some(Box::new(|| {
Control::write().pending = None;
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();
later(move || system::connect_network(&path), None);
})
} else {
row
})),
_ => None,
};
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 {
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 {
@@ -1745,44 +1810,83 @@ fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> Me
Box::new(toggle) as Box<dyn Widget>
});
let mut body = Body::new(4.0);
if !bluetooth.available() && !links.bluetooth_service {
body.note("bluez isn't installed.");
body.note("emerge net-wireless/bluez, then switch it on here.");
let note = if !bluetooth.available() && !links.bluetooth_service {
Some("bluez isn't installed: emerge net-wireless/bluez")
} 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() {
body.note("Bluetooth is off.");
Some("Bluetooth is off.")
} 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 {
for (index, device) in bluetooth.devices().iter().take(MENU_ROWS).enumerate() {
let subtitle = match (device.connected(), device.paired(), device.battery()) {
(true, _, Some(battery)) => format!("Connected · {battery}%"),
(true, _, None) => String::from("Connected"),
(false, true, _) => String::from("Paired"),
(false, false, _) => String::from("Not paired · click to pair"),
None
};
// by address, so each keeps its place around the bubble
let mut devices: Vec<_> = if note.is_none() { bluetooth.devices().iter().take(NODES).collect() } else { Vec::new() };
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 (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 {
Control::write().pending = None;
Bluetooth::disconnect(&path);
} else if paired {
Bluetooth::connect(&path);
} 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 {
@@ -1813,7 +1917,7 @@ fn vpn_menu(control: &Control, links: &Links) -> MenuContent {
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
@@ -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.into_content("Sound", None)
body.into_content("Sound", None, PANEL_W)
}
+72 -68
View File
@@ -26,6 +26,8 @@ pub enum Glyph {
Gauge,
Bolt,
Chip,
// this machine, in the middle of the Wi-Fi and Bluetooth maps
Laptop,
}
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
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| {
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)
.move_to(x(x0), y(0.14))
.line_to(x(x1), y(0.14))
.line_to(x(x1), y(0.86))
.line_to(x(x0), y(0.86))
.close()
.fill(color)
.stroke(weight, color)
@@ -53,12 +67,12 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
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)
.move_to(x(0.08), y(0.36))
.line_to(x(0.28), y(0.36))
.line_to(x(0.54), y(0.14))
.line_to(x(0.54), y(0.86))
.line_to(x(0.28), y(0.64))
.line_to(x(0.08), y(0.64))
.close()
.fill(color)
};
@@ -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 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 {
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),
],
// a closed outline through the box's corners at these fractions
let frame = |x0: f32, y0: f32, x1: f32, y1: f32| Path::new().move_to(x(x0), y(y0)).line_to(x(x1), y(y0)).line_to(x(x1), y(y1)).line_to(x(x0), y(y1)).close();
match glyph {
Glyph::Play => shapes![triangle(0.2, 0.9)],
Glyph::Pause => shapes![bar(0.18, 0.42), bar(0.58, 0.82)],
Glyph::Next => shapes![triangle(0.08, 0.7), bar(0.74, 0.9)],
Glyph::Previous => shapes![triangle(0.92, 0.3), bar(0.1, 0.26)],
Glyph::Speaker => shapes![speaker(), Arc::new().center(x(0.56), y(0.5)).radius(0.3 * s).start(45.0).sweep(90.0).stroke(thin, color).cap(Cap::Round)],
Glyph::Muted => shapes![speaker(), line(0.68, 0.36, 0.94, 0.64), line(0.94, 0.36, 0.68, 0.64)],
Glyph::Bluetooth => shapes![
Path::new()
.move_to(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)
.move_to(x(0.28), y(0.3))
.line_to(x(0.72), y(0.68))
.line_to(x(0.5), y(0.9))
.line_to(x(0.5), y(0.1))
.line_to(x(0.72), y(0.32))
.line_to(x(0.28), y(0.7))
.stroke(thin, color)
.cap(Cap::Round)
],
Glyph::Chevron => shapes![
Path::new().move_to(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(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)
],
Glyph::Chevron => shapes![Path::new().move_to(x(0.12), y(0.32)).line_to(x(0.5), y(0.7)).line_to(x(0.88), y(0.32)).stroke(1.5, color).cap(Cap::Round)],
Glyph::Back => shapes![Path::new().move_to(x(0.64), y(0.14)).line_to(x(0.3), y(0.5)).line_to(x(0.64), y(0.86)).stroke(thin.max(1.6), color).cap(Cap::Round)],
// an eighth note: round head, stem, a curved flag
Glyph::Note => shapes![
Circle::new().center(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),
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
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() {
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
}
// 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)];
let mut parts: Vec<Box<dyn Shape>> = shapes![Circle::new().center(x(0.5), y(0.5)).radius(0.09 * s).fill(color)];
for radius in [0.24, 0.42] {
for start in [45.0, 225.0] {
parts.push(Box::new(Arc::new().center(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
}
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.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 => {
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 {
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
}
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),
];
let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.06, 0.24, 0.94, 0.78).stroke(thin, color), line(0.32, 0.64, 0.68, 0.64)];
for (row, 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 {
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
}
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),
],
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),
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![
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)
.move_to(x(0.6), y(0.05))
.line_to(x(0.2), y(0.56))
.line_to(x(0.46), y(0.56))
.line_to(x(0.38), y(0.95))
.line_to(x(0.8), y(0.42))
.line_to(x(0.54), y(0.42))
.close()
.fill(color)
],
// a die with pins on every side
Glyph::Chip => {
let mut parts: Vec<Box<dyn Shape>> = shapes![
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),
];
let mut parts: Vec<Box<dyn Shape>> = shapes![frame(0.24, 0.24, 0.76, 0.76).stroke(thin, color), frame(0.4, 0.4, 0.6, 0.6).fill(color)];
for at in [0.4, 0.6] {
parts.push(Box::new(line(at, 0.08, at, 0.22)));
@@ -195,7 +199,7 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas {
parts
}
};
Canvas::new().width(size).height(size).shapes(shapes)
// 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)],
}
}
+1
View File
@@ -2,6 +2,7 @@ mod art;
mod control;
mod glyph;
mod island;
mod map;
mod parse;
mod sound;
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
pub networks: Vec<Network>,
// iwd is scanning for networks
pub scanning: bool,
}
#[derive(Clone, PartialEq)]
@@ -59,6 +62,9 @@ pub struct Network {
pub secured: bool,
pub known: bool,
pub connected: bool,
// iwd is joining it right now
pub connecting: bool,
pub bars: u8,
}
@@ -89,6 +95,9 @@ pub struct Links {
// bluez's openrc service is installed, so a click can start it
pub bluetooth_service: bool,
// this machine's name, for the bubble in the middle of the maps
pub host: String,
}
impl Service for Links {
@@ -110,15 +119,16 @@ pub fn refresh() {
let wifi = wifi();
let vpns = vpns();
let bluetooth_service = Path::new("/etc/init.d/bluetooth").exists();
let host = read(Path::new("/proc/sys/kernel/hostname"));
let stale = {
let links = Links::read();
links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service
links.wifi != wifi || links.vpns != vpns || links.bluetooth_service != bluetooth_service || links.host != host
};
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 network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text());
// iwd names the network it's joining as connected already; the state tells the two apart
let state = station.get("State").text();
let joined = state == "connected" || state == "roaming";
// every network in range as [path, signal in hundredths of a dBm], strongest first
let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing };
@@ -166,7 +180,8 @@ fn wifi() -> Wifi {
name: found.get("Name").text().to_string(),
secured: found.get("Type").text() != "open",
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),
})
})
@@ -197,6 +212,7 @@ fn wifi() -> Wifi {
hotspot_name: access_point.get("Name").text().to_string(),
clients,
networks,
scanning: station.get("Scanning").bool(),
}
}