4fbfad3ce3
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.
279 lines
12 KiB
Rust
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)
|
|
}
|