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)
}