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:
+244
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user