/* * 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, Button, Canvas, Cap, Center, Circle, Color, Column, Full, Path, Pointer, Rectangle, Shape, Stack, Start, Text, Weight, Widget, shapes}; use crate::config::appearance::{overlays, text}; use crate::config::control::maps; use crate::glyph::{Glyph, glyph_shapes}; use crate::island::faded; use crate::theme; // the map's sizes and timings are settings (config.rs: control.maps.*) // a click on the bubble shows its rings at least until all three have run out once pub fn ripple_s() -> f32 { 2.0 * maps::ring_gap_s() + maps::ring_s() } #[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, // left and right clicks, told apart by the button pub on_click: Option>, } // this desktop's bubble in the middle: its name, what to know, and a click that searches again pub struct Centre<'a> { pub host: &'a str, pub note: Option<&'a str>, // seconds since a search began (see Control::searching): rings run out from the bubble pub searching: Option, pub lit: f32, pub on_hover: Box, pub on_click: Box, } // seconds since the shell first drew a map: the clock every float and wobble runs on pub fn clock() -> f32 { static START: OnceLock = OnceLock::new(); START.get_or_init(Instant::now).elapsed().as_secs_f32() } pub fn map(width: f32, height: f32, centre: Centre, nodes: Vec) -> Rectangle { let theme = theme::now(); let t = clock(); let (cx, cy) = (width / 2.0, height / 2.0); let bubble = maps::bubble() * (1.0 + 0.08 * centre.lit) + (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 - maps::label_width() / 2.0 - maps::drift_x() - 6.0, cy - maps::node_radius() - maps::label_height() - maps::drift_y() - 9.0); // and the one the nearest stay outside of, clear of the bubble, its ring and its name let (inner_x, inner_y) = (maps::bubble() + maps::node_radius() + 150.0, maps::bubble() + maps::node_radius() + 80.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> = Vec::new(); let mut discs: Vec> = Vec::new(); let mut floating: Vec> = 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; // the stronger the nearer, but never far in, so neighbours' labels don't meet let reach = 1.0 - 0.1 * 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, two waves so the path never quite repeats let drift = ( ((t * 0.31 + phase * 1.7).sin() * 0.7 + (t * 0.53 + phase * 0.9).sin() * 0.3) * maps::drift_x(), ((t * 0.27 + phase * 2.3).cos() * 0.7 + (t * 0.47 + phase * 1.3).cos() * 0.3) * maps::drift_y(), ); 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 - maps::node_radius()).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(overlays::chip_open(), theme.accent, 0.45), theme.text), Link::Idle => (overlays::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 = maps::node_radius() * (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, maps::icon(), mark, 0.0, (x - maps::icon() / 2.0, y - maps::icon() / 2.0))); // what's left as a widget is only where the pointer finds it let target = Rectangle::new().width(2.0 * maps::node_radius()).height(2.0 * maps::node_radius()).radius(Full).translate(x - maps::node_radius(), y - maps::node_radius()).on_hover(node.on_hover); let target = match node.on_click { Some(click) => target.cursor(Pointer).on_click(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 - maps::node_radius() { y - maps::node_radius() - 5.0 - maps::label_height() } else { y + maps::node_radius() + 5.0 }; floating.push(Box::new( Rectangle::new().width(maps::label_width()).height(maps::label_height()).align_child(Center, Start).translate(x - maps::label_width() / 2.0, label_y).child( Column::new(vec![ Box::new(centred(Text::new(node.name).size(text::label()).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(text::tiny()).color(theme.dim).elide(), 13.0)), ]) .gap(2.0), ), )); } let mut lines: Vec> = shapes![ idle.stroke(1.5, overlays::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 searches, rings run out from the bubble and fade, the first one from the moment of the click if let Some(since) = centre.searching { for ring in 0..3 { let age = since - ring as f32 * maps::ring_gap_s(); if age < 0.0 { continue; } let grown = (age / maps::ring_s().max(0.01)).fract(); let eased = 1.0 - (1.0 - grown).powi(2); lines.push(Box::new( Circle::new().center(cx, cy).radius(bubble + 4.0 + eased * maps::ring_reach()).stroke(2.0, faded(theme.accent, (150.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(Color::blend(theme.accent, theme.flame, 0.3 * centre.lit)))); 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> = vec![Box::new(Canvas::new().width(width).height(height).shapes(lines))]; // this desktop's name under its bubble, and what to know under that (on hover: what a click does) layers.push(Box::new( Rectangle::new() .width(maps::label_width() * 2.0) .height(16.0) .align_child(Center, Center) .translate(cx - maps::label_width(), cy + maps::bubble() + 12.0) .child(Text::new(centre.host).size(text::body()).weight(Weight::Bold).color(theme.text)), )); let note = if centre.lit > 0.5 { Some("Click to search again") } else { centre.note }; if let Some(note) = note { layers.push(Box::new( Rectangle::new().width(maps::label_width() * 3.0).height(16.0).align_child(Center, Center).translate(cx - maps::label_width() * 1.5, cy + maps::bubble() + 32.0).child(Text::new(note).size(text::small()).color(theme.dim)), )); } // the bubble's own target, under the nodes' let on_click = centre.on_click; layers.push(Box::new( Rectangle::new() .width(2.0 * maps::bubble()) .height(2.0 * maps::bubble()) .radius(Full) .translate(cx - maps::bubble(), cy - maps::bubble()) .cursor(Pointer) .on_hover(centre.on_hover) .on_click(move |_| on_click()), )); 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(maps::label_width()).height(height).align_child(Center, Center).child(text) }