Files
amane-config/src/map.rs
T
server2 4fbfad3ce3 Config window: every setting in config.toml, Hyprland options from Hyprland
All tunable values (sizes, timings, text sizes, colours, polls, equalizer
presets and bands, hotspot, extra output) are settings declared once in
config.rs, read from ~/.config/amane/config.toml, applied live on save.
shell.conf's four values move over on first start.

settings.rs: a normal window (control centre gear, or ipc config) with a
page per part, a page per Hyprland category, search and reset per row.
hypr.rs: Hyprland's options from hyprctl descriptions/getoption, applied
with hl.config and kept in ~/.config/hypr/amane.lua.
2026-10-15 02:18:57 +02:00

279 lines
12 KiB
Rust

/*
* 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<dyn Fn(bool)>,
// left and right clicks, told apart by the button
pub on_click: Option<Box<dyn Fn(Button)>>,
}
// 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<f32>,
pub lit: f32,
pub on_hover: Box<dyn Fn(bool)>,
pub on_click: Box<dyn Fn()>,
}
// seconds since the shell first drew a map: the clock every float and wobble runs on
pub 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, centre: Centre, nodes: Vec<Node>) -> 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<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;
// 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<Box<dyn Shape>> = 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<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 (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)
}