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