Krolyxon
Commit fdbd8e0

Branches

Refs →

budctlfdbd8e0

budctl-gui: Nothing-style motion

The window snapped from one state to the next. Now it moves the Nothing
way: 150-250 ms, eased out on cubic-bezier(0.25, 0.1, 0.25, 1), no bounce,
fading rather than sliding.

- motion.rs: the curve, Tween, and the Eased surface that buttons sit on,
  so hovers, the chosen segment, the keyboard cursor and the current
  preset's circle fade.
- dots.rs: the switch stretches from knob to fill; battery rings light
  their dots one after the other (on first show and on a new level);
  slider dots crossfade size; a blinking light and scanning dots while
  connecting, and a blinking light while the buds ring.
- App: pages fade in (on open and on the first reading), dialogs fade in
  and out (the closing one keeps what it said, and answers nothing),
  as do the error banner and the open bands. Look::fade brings surfaces
  in twice as fast as text, so a dialog card is solid by halfway.
- Each widget asks for frames only while it moves, and the app subscribes
  to window::frames only while a fade runs: an idle window uses no CPU.
- Config: gui.motion (default true) turns it all off. The tests run
  without motion, so the snapshots and README screenshots are unchanged.
- iced's "advanced" feature, for the Eased wrapper widget (Widget, Tree,
  Shell). It is a feature of iced, not a new dependency.

Refs #1
Author
krolyxon
Date
2026-11-01 17:03 +0530
Parent
9500e1e
Commit
fdbd8e0d6a9a32c8baeaa9fd639f58dcda354217

+1410 −262

13 files changed

  1. M CHANGELOG.md +15 −0
  2. M CLAUDE.md +1 −0
  3. M README.md +3 −1
  4. M crates/budctl-core/src/config.rs +6 −0
  5. M crates/budctl-gui/Cargo.toml +1 −1
  6. M crates/budctl-gui/src/dots.rs +392 −15
  7. M crates/budctl-gui/src/earbuds.rs +15 −3
  8. M crates/budctl-gui/src/equalizer.rs +5 −3
  9. M crates/budctl-gui/src/main.rs +403 −138
  10. A crates/budctl-gui/src/motion.rs +446 −0
  11. M crates/budctl-gui/src/overview.rs +1 −0
  12. M crates/budctl-gui/src/theme.rs +60 −0
  13. M crates/budctl-gui/src/ui.rs +62 −101
M CHANGELOG.md View
@@ -1,5 +1,20 @@
11 # Changelog
22
3+## Unreleased
4+
5+The desktop app moves, the Nothing way: short, eased out, fading rather than sliding.
6+
7+- Hovers, the chosen segment, the keyboard cursor and the current preset's circle fade instead
8+ of snapping. Switches stretch from the knob into the fill as they turn.
9+- Pages fade in when opened, and when the buds are first read; dialogs fade in and out, as do the
10+ error above a page and the bands of a custom preset.
11+- The battery rings light their dots one after the other, like the Glyph lights, when they first
12+ show and when a level changes.
13+- While the app looks for the buds, a row of dots scans and the sidebar's light blinks; a light
14+ blinks beside Stop while the buds ring.
15+- Nothing moves once a change has settled, so an idle window uses no CPU. `"gui": {"motion":
16+ false}` in the config turns the animations off.
17+
318 ## 0.1.2 (2026-10-29)
419
520 A new look for the desktop app, after Nothing OS in Material You colors, and a window that no
M CLAUDE.md View
@@ -125,6 +125,7 @@ Exit codes:
125125 - **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command (`gui.idle_seconds` in the config). A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI.
126126 - **Files:** writes the built-in palette to `~/.config/budctl/theme.json` when missing (never overwrites it), and logs errors and session events to `~/.local/state/budctl/gui.log` (`log.rs`), replaced each launch. Frames stay on stderr with `--debug`. The CLI logs to stderr only, since status bars run it every few seconds.
127127 - **Speed:** the CPU renderer repaints only what changed, one patch per changed thing, and it takes a canvas drawn afresh as changed on every frame. So every canvas keeps its drawing between frames (`kept.rs`), and cards sit on layers of their own (`ui::layer`), so a scroll paints a card once rather than once per row. Debug builds optimize the dependencies (`[profile.dev.package."*"]`); unoptimized, the window lags.
128+- **Motion:** Nothing's, per `motion.rs`: 150–250 ms, eased out on `cubic-bezier(0.25, 0.1, 0.25, 1)`, no bounce; things fade rather than slide. Small moves live in their widget (hovers and state changes on `motion::eased` surfaces; the switch, ring sweep, slider dots, blink and scan in `dots.rs`): a widget starts its tween on a redraw, asks for frames only while it runs, then the window rests. Fades of whole parts (page, dialog, error banner, open bands) are in `App`: `Look::fade`, with the `window::frames` subscription only while one runs. `gui.motion: false` turns it all off; the tests run without motion, so snapshots show settled states.
128129 - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner.
129130 - **Screenshots:** the README's come from the demo buds: `cargo test -p budctl-gui readme_screenshots -- --ignored` renders them again into `docs/screenshots/`.
130131
M README.md View
@@ -149,7 +149,8 @@ is optional; a file with a mistake is reported and the defaults are used.
149149 "device": null,
150150 "gui": {
151151 "idle_seconds": 10,
152− "start_page": "overview"
152+ "start_page": "overview",
153+ "motion": true
153154 }
154155 }
155156 ```
@@ -158,6 +159,7 @@ is optional; a file with a mistake is reported and the defaults are used.
158159 - `gui.idle_seconds`: how long the app keeps the control channel open after the last change
159160 (1–600).
160161 - `gui.start_page`: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`.
162+- `gui.motion`: `false` turns the app's animations off; every change then shows at once.
161163
162164 ## How it works
163165
M crates/budctl-core/src/config.rs View
@@ -30,6 +30,9 @@ pub struct Gui {
3030 pub idle_seconds: u64,
3131 /// The page the window opens on: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`.
3232 pub start_page: String,
33+ /// Whether things move: fades, switches, the battery rings' sweep. Off, every change shows
34+ /// at once.
35+ pub motion: bool,
3336 }
3437
3538 impl Default for Gui {
@@ -37,6 +40,7 @@ impl Default for Gui {
3740 Gui {
3841 idle_seconds: 10,
3942 start_page: "overview".to_owned(),
43+ motion: true,
4044 }
4145 }
4246 }
@@ -148,6 +152,8 @@ mod tests {
148152 assert_eq!(config.device().unwrap(), Some("AA:BB:CC:DD:EE:FF".parse().unwrap()));
149153 assert_eq!(config.gui.idle_seconds, 30);
150154 assert_eq!(config.gui.start_page, "overview");
155+ assert!(config.gui.motion, "things move unless the file says not to");
156+ assert!(!parse(r#"{"gui": {"motion": false}}"#).unwrap().gui.motion);
151157 }
152158
153159 #[test]
M crates/budctl-gui/Cargo.toml View
@@ -14,7 +14,7 @@ path = "src/main.rs"
1414 [dependencies]
1515 budctl-core = { path = "../budctl-core" }
1616 # Wayland only (no X11) and CPU rendering (no GPU stack): the smallest iced that draws this UI.
17−iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "canvas", "crisp"] }
17+iced = { version = "0.14.0", default-features = false, features = ["tiny-skia", "wayland", "thread-pool", "canvas", "crisp", "advanced"] }
1818 serde = { version = "1.0.229", features = ["derive"] }
1919 serde_json = "1.0.154"
2020
M crates/budctl-gui/src/dots.rs View
@@ -1,20 +1,29 @@
1−//! LED dots, like the Glyph lights: battery rings, and the sliders for the bass level and the EQ
2−//! bands. Drawn on a canvas, so they take any theme color, and kept between frames ([`Kept`]).
1+//! LED dots, like the Glyph lights: battery rings, switches, the sliders for the bass level and
2+//! the EQ bands, and the dots that blink or scan while something is under way. Drawn on a canvas,
3+//! so they take any theme color, and kept between frames ([`Kept`]).
4+//!
5+//! Each one moves on its own ([`motion`]): it asks for the frames it needs and nothing more, so
6+//! the window rests once it has settled.
37
48 use std::f32::consts::{FRAC_PI_2, TAU};
59
610 use iced::alignment::Vertical;
11+use iced::time::{Duration, Instant};
712 use iced::widget::canvas::{self, Event, Frame, Path};
813 use iced::widget::text;
9−use iced::{Color, Element, Font, Length, Point, Rectangle, Renderer, Theme, mouse};
14+use iced::{Color, Element, Font, Length, Point, Rectangle, Renderer, Size, Theme, mouse};
1015
1116 use crate::Message;
1217 use crate::kept::Kept;
18+use crate::motion::{self, Mix, Tween};
1319 use crate::theme::Look;
1420
1521 /// A battery ring, `size` pixels across: a ring of dots, lit clockwise from the top up to the
1622 /// `level` (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it
1723 /// is unknown. The figure is drawn with the dots, so the page needs no layer of its own for it.
24+///
25+/// The dots light one after the other, like the Glyph lights: when the ring first shows, and
26+/// when the level changes. The figure is the level at once.
1827 pub fn ring<'a>(look: Look, level: Option<u8>, size: f32) -> Element<'a, Message> {
1928 let p = look.p;
2029 let ring = Ring {
@@ -23,6 +32,7 @@ pub fn ring<'a>(look: Look, level: Option<u8>, size: f32) -> Element<'a, Message
2332 off: p.s3,
2433 figure: if level.is_some() { p.fg } else { p.mute },
2534 font: look.dot(),
35+ motion: look.motion,
2636 };
2737 iced::widget::canvas(ring).width(size).height(size).into()
2838 }
@@ -34,25 +44,71 @@ struct Ring {
3444 off: Color,
3545 figure: Color,
3646 font: Font,
47+ motion: bool,
48+}
49+
50+/// The dots in a ring.
51+const RING_DOTS: u16 = 44;
52+
53+impl Ring {
54+ /// How many dots the level lights.
55+ fn lit(&self) -> u16 {
56+ self.level
57+ .map_or(0, |level| lit_count(f32::from(level) / 100.0, RING_DOTS))
58+ }
59+}
60+
61+/// A ring's dots on their way to the level.
62+#[derive(Default)]
63+pub struct Sweep {
64+ kept: Kept<(u16, Ring)>,
65+ /// How many dots are lit, as a number that moves; `None` before the first frame.
66+ lit: Option<Tween<f32>>,
67+ now: Option<Instant>,
68+}
69+
70+impl Sweep {
71+ /// The dots lit now: `ring`'s own when nothing has moved yet.
72+ fn lit(&self, ring: &Ring) -> u16 {
73+ match (self.lit, self.now) {
74+ (Some(lit), Some(now)) => lit_count(lit.at(now) / f32::from(RING_DOTS), RING_DOTS),
75+ _ => ring.lit(),
76+ }
77+ }
3778 }
3879
3980 impl canvas::Program<Message> for Ring {
40− type State = Kept<Ring>;
81+ type State = Sweep;
82+
83+ fn update(
84+ &self,
85+ state: &mut Sweep,
86+ event: &Event,
87+ _bounds: Rectangle,
88+ _cursor: mouse::Cursor,
89+ ) -> Option<canvas::Action<Message>> {
90+ let now = motion::redraw(event)?;
91+ state.now = Some(now);
92+ // First seen, the ring starts dark and sweeps up to the level.
93+ let lit = state.lit.get_or_insert_with(|| Tween::at_rest(0.0));
94+ lit.aim(f32::from(self.lit()), now, motion::SWEEP, self.motion);
95+ lit.running(now).then(canvas::Action::request_redraw)
96+ }
4197
4298 fn draw(
4399 &self,
44− kept: &Kept<Ring>,
100+ state: &Sweep,
45101 renderer: &Renderer,
46102 _theme: &Theme,
47103 bounds: Rectangle,
48104 _cursor: mouse::Cursor,
49105 ) -> Vec<canvas::Geometry> {
50− const N: u16 = 44;
51− let geometry = kept.draw(renderer, bounds.size(), *self, |frame| {
106+ const N: u16 = RING_DOTS;
107+ let lit = state.lit(self);
108+ let geometry = state.kept.draw(renderer, bounds.size(), (lit, *self), |frame| {
52109 let center = frame.center();
53110 let dot = (bounds.width * 0.045).max(3.0);
54111 let radius = bounds.width / 2.0 - dot / 2.0 - 1.0;
55− let lit = self.level.map_or(0, |level| lit_count(f32::from(level) / 100.0, N));
56112 for n in 0..N {
57113 let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2;
58114 let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin());
@@ -100,6 +156,7 @@ pub struct Slider<F> {
100156 pub release: Message,
101157 pub on: Color,
102158 pub off: Color,
159+ pub motion: bool,
103160 }
104161
105162 impl<F: Fn(i16, bool) -> Message + 'static> Slider<F> {
@@ -149,13 +206,30 @@ impl<F> Slider<F> {
149206 const DOT_BIG: f32 = 11.0;
150207 const DOT: f32 = 5.0;
151208
152−/// What a slider's drawing shows: its range, its value, which way it runs, and its colors.
153−type Shows = (i16, i16, i16, bool, Color, Color);
209+/// What a slider's drawing shows: its range, its value, which way it runs, its colors, and the
210+/// value it is leaving with how far the value's dot has grown.
211+type Shows = (i16, i16, i16, bool, Color, Color, Option<i16>, f32);
154212
155213 #[derive(Default)]
156214 pub struct Drag {
157215 held: bool,
158216 kept: Kept<Shows>,
217+ /// The value last drawn.
218+ value: Option<i16>,
219+ /// The value before it, whose large dot shrinks as the new one's grows.
220+ left: Option<i16>,
221+ grow: Option<Tween<f32>>,
222+ now: Option<Instant>,
223+}
224+
225+impl Drag {
226+ /// How far the value's dot has grown, 0 to 1.
227+ fn grown(&self) -> f32 {
228+ match (self.grow, self.now) {
229+ (Some(grow), Some(now)) => grow.at(now),
230+ _ => 1.0,
231+ }
232+ }
159233 }
160234
161235 impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
@@ -168,6 +242,9 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
168242 bounds: Rectangle,
169243 cursor: mouse::Cursor,
170244 ) -> Option<canvas::Action<Message>> {
245+ if let Some(now) = motion::redraw(event) {
246+ return self.follow(state, now);
247+ }
171248 let Event::Mouse(event) = event else {
172249 return None;
173250 };
@@ -212,11 +289,22 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
212289 bounds: Rectangle,
213290 _cursor: mouse::Cursor,
214291 ) -> Vec<canvas::Geometry> {
215− let shows = (self.low, self.high, self.value, self.vertical, self.on, self.off);
292+ let grown = state.grown();
293+ let left = state.left.filter(|_| grown < 1.0);
294+ let shows = (
295+ self.low,
296+ self.high,
297+ self.value,
298+ self.vertical,
299+ self.on,
300+ self.off,
301+ left,
302+ grown,
303+ );
216304 vec![
217305 state
218306 .kept
219− .draw(renderer, bounds.size(), shows, |frame| self.dots(frame)),
307+ .draw(renderer, bounds.size(), shows, |frame| self.dots(frame, left, grown)),
220308 ]
221309 }
222310
@@ -230,8 +318,28 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
230318 }
231319
232320 impl<F> Slider<F> {
233− /// One dot per step; the value's dot is larger.
234− fn dots(&self, frame: &mut Frame) {
321+ /// At a redraw: when the value has changed, its dot starts growing and the last one's
322+ /// shrinking. No travel: the large dot fades from one place to the other.
323+ fn follow(&self, state: &mut Drag, now: Instant) -> Option<canvas::Action<Message>> {
324+ state.now = Some(now);
325+ if let Some(last) = state.value
326+ && last != self.value
327+ {
328+ state.left = Some(last);
329+ let mut grow = Tween::at_rest(0.0);
330+ grow.aim(1.0, now, motion::HOVER, self.motion);
331+ state.grow = Some(grow);
332+ }
333+ state.value = Some(self.value);
334+ state
335+ .grow
336+ .is_some_and(|grow| grow.running(now))
337+ .then(canvas::Action::request_redraw)
338+ }
339+
340+ /// One dot per step; the value's dot is larger, grown `grown` of the way (0 to 1), and the
341+ /// dot of the value `left` is shrinking back.
342+ fn dots(&self, frame: &mut Frame, left: Option<i16>, grown: f32) {
235343 let size = frame.size();
236344 let at = |along: f32| {
237345 if self.vertical {
@@ -249,7 +357,9 @@ impl<F> Slider<F> {
249357 while value <= self.high {
250358 let lit = (from..=to).contains(&value);
251359 let (diameter, color) = if value == self.value {
252− (DOT_BIG, self.on)
360+ (DOT.mix(DOT_BIG, grown), self.on)
361+ } else if left == Some(value) {
362+ (DOT_BIG.mix(DOT, grown), if lit { self.on } else { self.off })
253363 } else if lit {
254364 (DOT, self.on)
255365 } else {
@@ -264,6 +374,262 @@ impl<F> Slider<F> {
264374 }
265375 }
266376
377+/// An on/off switch, 64 by 34: off, a dark pill with a dim knob on the left; on, the fill runs
378+/// to the right end, with a dot in it. Turned, the knob stretches into the fill (or back) as its
379+/// color changes and the dot fades.
380+pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
381+ let p = look.p;
382+ let switch = Switch {
383+ on,
384+ track: p.s2,
385+ off: p.mute,
386+ fill: p.fill,
387+ dot: p.fill_fg,
388+ motion: look.motion,
389+ };
390+ iced::widget::canvas(switch).width(64).height(34).into()
391+}
392+
393+#[derive(Clone, Copy, PartialEq)]
394+struct Switch {
395+ on: bool,
396+ track: Color,
397+ off: Color,
398+ fill: Color,
399+ dot: Color,
400+ motion: bool,
401+}
402+
403+impl Switch {
404+ fn target(&self) -> f32 {
405+ if self.on { 1.0 } else { 0.0 }
406+ }
407+}
408+
409+/// How far a switch is turned on: 0 off, 1 on.
410+#[derive(Default)]
411+pub struct Turn {
412+ kept: Kept<(f32, Switch)>,
413+ on: Option<Tween<f32>>,
414+ now: Option<Instant>,
415+}
416+
417+impl canvas::Program<Message> for Switch {
418+ type State = Turn;
419+
420+ fn update(
421+ &self,
422+ state: &mut Turn,
423+ event: &Event,
424+ _bounds: Rectangle,
425+ _cursor: mouse::Cursor,
426+ ) -> Option<canvas::Action<Message>> {
427+ let now = motion::redraw(event)?;
428+ state.now = Some(now);
429+ // First seen, it is as it is; it moves only when turned.
430+ let on = state.on.get_or_insert_with(|| Tween::at_rest(self.target()));
431+ on.aim(self.target(), now, motion::STATE, self.motion);
432+ on.running(now).then(canvas::Action::request_redraw)
433+ }
434+
435+ fn draw(
436+ &self,
437+ state: &Turn,
438+ renderer: &Renderer,
439+ _theme: &Theme,
440+ bounds: Rectangle,
441+ _cursor: mouse::Cursor,
442+ ) -> Vec<canvas::Geometry> {
443+ let t = match (state.on, state.now) {
444+ (Some(on), Some(now)) => on.at(now),
445+ _ => self.target(),
446+ };
447+ let geometry = state.kept.draw(renderer, bounds.size(), (t, *self), |frame| {
448+ let size = frame.size();
449+ let track = Path::rounded_rectangle(Point::ORIGIN, size, (size.height / 2.0).into());
450+ frame.fill(&track, self.track);
451+ // The knob, 26 across, stretching to the track's inner width.
452+ const PAD: f32 = 4.0;
453+ let height = size.height - 2.0 * PAD;
454+ let width = height.mix(size.width - 2.0 * PAD, t);
455+ let knob = Path::rounded_rectangle(Point::new(PAD, PAD), Size::new(width, height), (height / 2.0).into());
456+ frame.fill(&knob, self.off.mix(self.fill, t));
457+ if t > 0.0 {
458+ let center = Point::new(PAD + width - 8.0 - 5.0, size.height / 2.0);
459+ let dot = Color {
460+ a: self.dot.a * t,
461+ ..self.dot
462+ };
463+ frame.fill(&Path::circle(center, 5.0), dot);
464+ }
465+ });
466+ vec![geometry]
467+ }
468+}
469+
470+/// A dot that blinks hard, lit and dark in turn, like a recording light: something is under way.
471+/// Without motion, it stays lit.
472+pub fn blink<'a>(look: Look, color: Color, size: f32) -> Element<'a, Message> {
473+ let blink = Blink {
474+ color,
475+ motion: look.motion,
476+ };
477+ iced::widget::canvas(blink).width(size).height(size).into()
478+}
479+
480+#[derive(Clone, Copy, PartialEq)]
481+struct Blink {
482+ color: Color,
483+ motion: bool,
484+}
485+
486+#[derive(Default)]
487+pub struct Blinking {
488+ kept: Kept<(bool, Blink)>,
489+ since: Option<Instant>,
490+ dark: bool,
491+}
492+
493+impl canvas::Program<Message> for Blink {
494+ type State = Blinking;
495+
496+ fn update(
497+ &self,
498+ state: &mut Blinking,
499+ event: &Event,
500+ _bounds: Rectangle,
501+ _cursor: mouse::Cursor,
502+ ) -> Option<canvas::Action<Message>> {
503+ let now = motion::redraw(event)?;
504+ if !self.motion {
505+ state.dark = false;
506+ return None;
507+ }
508+ let since = *state.since.get_or_insert(now);
509+ let (step, next) = steps(since, motion::BLINK, now);
510+ state.dark = step % 2 == 1;
511+ Some(next.map_or_else(canvas::Action::request_redraw, canvas::Action::request_redraw_at))
512+ }
513+
514+ fn draw(
515+ &self,
516+ state: &Blinking,
517+ renderer: &Renderer,
518+ _theme: &Theme,
519+ bounds: Rectangle,
520+ _cursor: mouse::Cursor,
521+ ) -> Vec<canvas::Geometry> {
522+ let geometry = state.kept.draw(renderer, bounds.size(), (state.dark, *self), |frame| {
523+ if !state.dark {
524+ let size = frame.size();
525+ frame.fill(
526+ &Path::circle(frame.center(), size.width.min(size.height) / 2.0),
527+ self.color,
528+ );
529+ }
530+ });
531+ vec![geometry]
532+ }
533+}
534+
535+/// Dots that scan from left to right with a fading tail, one step at a time, like a hardware
536+/// spinner: the buds are being looked for. Without motion, the middle dot is lit and still.
537+pub fn scan<'a>(look: Look, color: Color) -> Element<'a, Message> {
538+ let scan = Scan {
539+ on: color,
540+ off: look.p.s3,
541+ motion: look.motion,
542+ };
543+ let width = f32::from(SCAN_DOTS) * SCAN_PITCH - (SCAN_PITCH - SCAN_DOT);
544+ iced::widget::canvas(scan).width(width).height(SCAN_DOT).into()
545+}
546+
547+const SCAN_DOTS: u16 = 9;
548+const SCAN_DOT: f32 = 6.0;
549+const SCAN_PITCH: f32 = 12.0;
550+
551+#[derive(Clone, Copy, PartialEq)]
552+struct Scan {
553+ on: Color,
554+ off: Color,
555+ motion: bool,
556+}
557+
558+#[derive(Default)]
559+pub struct Scanning {
560+ kept: Kept<(u16, Scan)>,
561+ since: Option<Instant>,
562+ /// The lit dot, by its place; past the last one, only its tail shows.
563+ head: Option<u16>,
564+}
565+
566+impl canvas::Program<Message> for Scan {
567+ type State = Scanning;
568+
569+ fn update(
570+ &self,
571+ state: &mut Scanning,
572+ event: &Event,
573+ _bounds: Rectangle,
574+ _cursor: mouse::Cursor,
575+ ) -> Option<canvas::Action<Message>> {
576+ let now = motion::redraw(event)?;
577+ if !self.motion {
578+ state.head = Some(SCAN_DOTS / 2);
579+ return None;
580+ }
581+ let since = *state.since.get_or_insert(now);
582+ let (step, next) = steps(since, motion::SCAN, now);
583+ // The head runs past the end until its tail of two has left too.
584+ let cycle = u128::from(SCAN_DOTS + 2);
585+ state.head = u16::try_from(step % cycle).ok();
586+ Some(next.map_or_else(canvas::Action::request_redraw, canvas::Action::request_redraw_at))
587+ }
588+
589+ fn draw(
590+ &self,
591+ state: &Scanning,
592+ renderer: &Renderer,
593+ _theme: &Theme,
594+ bounds: Rectangle,
595+ _cursor: mouse::Cursor,
596+ ) -> Vec<canvas::Geometry> {
597+ let head = state.head.unwrap_or(SCAN_DOTS / 2);
598+ let geometry = state.kept.draw(renderer, bounds.size(), (head, *self), |frame| {
599+ let y = frame.size().height / 2.0;
600+ for n in 0..SCAN_DOTS {
601+ // The head at full strength, then 60% and 30%: Nothing's three steps of opacity.
602+ let color = match head.checked_sub(n) {
603+ Some(0) => self.on,
604+ Some(1) => Color {
605+ a: self.on.a * 0.6,
606+ ..self.on
607+ },
608+ Some(2) => Color {
609+ a: self.on.a * 0.3,
610+ ..self.on
611+ },
612+ _ => self.off,
613+ };
614+ let x = SCAN_DOT / 2.0 + f32::from(n) * SCAN_PITCH;
615+ frame.fill(&Path::circle(Point::new(x, y), SCAN_DOT / 2.0), color);
616+ }
617+ });
618+ vec![geometry]
619+ }
620+}
621+
622+/// For something that changes every `every` from `since`: how many steps have gone by at
623+/// `now`, and when the next one is due (`None` past what an instant can hold).
624+fn steps(since: Instant, every: Duration, now: Instant) -> (u128, Option<Instant>) {
625+ let step = now.saturating_duration_since(since).as_millis() / every.as_millis().max(1);
626+ let next = u32::try_from(step.saturating_add(1))
627+ .ok()
628+ .and_then(|n| every.checked_mul(n))
629+ .and_then(|gone| since.checked_add(gone));
630+ (step, next)
631+}
632+
267633 #[cfg(test)]
268634 mod tests {
269635 use super::*;
@@ -278,9 +644,20 @@ mod tests {
278644 release: Message::SaveBass,
279645 on: Color::WHITE,
280646 off: Color::BLACK,
647+ motion: false,
281648 }
282649 }
283650
651+ #[test]
652+ fn steps_come_on_time() {
653+ let since = Instant::now();
654+ let every = Duration::from_millis(100);
655+ assert_eq!(steps(since, every, since), (0, Some(since + every)));
656+ let (step, next) = steps(since, every, since + Duration::from_millis(250));
657+ assert_eq!(step, 2);
658+ assert_eq!(next, Some(since + Duration::from_millis(300)));
659+ }
660+
284661 #[test]
285662 fn rings_light_their_share() {
286663 assert_eq!(lit_count(0.8, 44), 35);
M crates/budctl-gui/src/earbuds.rs View
@@ -1,10 +1,11 @@
11 //! The Earbud settings page: dual connection and its devices, find the buds, and what they run.
22
33 use budctl_core::status::DualDevice;
4−use iced::Element;
5−use iced::widget::{column, container};
4+use iced::widget::{column, container, row};
5+use iced::{Alignment, Element};
66
77 use crate::buds::Buds;
8+use crate::dots;
89 use crate::icons::Icon;
910 use crate::theme::Look;
1011 use crate::ui;
@@ -54,7 +55,18 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem
5455 Icon::Find,
5556 "Find my earbuds",
5657 Some(about.to_owned()),
57− ui::pill(look, if ringing { "Stop" } else { "Ring" }, ringing, Message::Find),
58+ row![]
59+ // A blinking live dot while the buds ring.
60+ .push(ringing.then(|| dots::blink(look, look.p.live, 8.0)))
61+ .push(ui::pill(
62+ look,
63+ if ringing { "Stop" } else { "Ring" },
64+ ringing,
65+ Message::Find,
66+ ))
67+ .spacing(12)
68+ .align_y(Alignment::Center)
69+ .into(),
5870 None,
5971 marks.focus == Some(Item::Find),
6072 );
M crates/budctl-gui/src/equalizer.rs View
@@ -48,10 +48,11 @@ pub fn edited(buds: &Buds, editing: Option<u8>) -> Option<&EqPreset> {
4848 buds.preset(editing?).filter(|preset| preset.custom)
4949 }
5050
51−/// `editing`: the custom preset whose bands are open; `draft`: bands moved on the screen and not
52−/// saved on the buds yet.
51+/// `editing`: the custom preset whose bands are open, drawn in `opened` (they fade in when
52+/// opened); `draft`: bands moved on the screen and not saved on the buds yet.
5353 pub fn view<'a>(
5454 look: Look,
55+ opened: Look,
5556 buds: &'a Buds,
5657 editing: Option<u8>,
5758 draft: Option<&'a Draft>,
@@ -113,7 +114,7 @@ pub fn view<'a>(
113114 })
114115 .collect();
115116 let editor: Option<Element<'a, Message>> = match edited(buds, editing) {
116− Some(preset) => Some(bands(look, preset, draft, marks)),
117+ Some(preset) => Some(bands(opened, preset, draft, marks)),
117118 None if eq.presets.iter().any(|p| p.custom) => Some(
118119 container(ui::note(
119120 look,
@@ -206,6 +207,7 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks:
206207 release: Message::SaveBands(id),
207208 on: p.fill,
208209 off: p.s3,
210+ motion: look.motion,
209211 };
210212 let band = column![
211213 ui::num(look, &gain(band.gain), 28.0, p.fg),
M crates/budctl-gui/src/main.rs View
@@ -13,6 +13,7 @@ mod icons;
1313 mod kept;
1414 mod keys;
1515 mod log;
16+mod motion;
1617 mod overview;
1718 mod settings;
1819 mod theme;
@@ -87,6 +88,7 @@ fn main() -> ExitCode {
8788 Page::Overview
8889 });
8990 let idle = Duration::from_secs(config.gui.idle_seconds);
91+ let motion = config.gui.motion;
9092 let device = options.device.or_else(|| config.device().unwrap_or_default());
9193 if options.theme.is_none() {
9294 write_built_in_theme();
@@ -96,6 +98,7 @@ fn main() -> ExitCode {
9698 let mut app = App::new(theme_file.clone(), device, options.demo);
9799 app.page = start_page;
98100 app.idle = idle;
101+ app.look.motion = motion;
99102 app
100103 };
101104 let mut application = iced::application(boot, App::update, App::view);
@@ -295,6 +298,41 @@ enum NameTarget {
295298 Rename(u8),
296299 }
297300
301+/// A dialog over the window.
302+#[derive(Debug, Clone, PartialEq)]
303+enum Overlay {
304+ /// The list of keys.
305+ Help,
306+ /// "Ring the buds?".
307+ Find,
308+ /// The preset-name dialog, with its heading and its button.
309+ Name {
310+ heading: String,
311+ action: &'static str,
312+ prompt: NamePrompt,
313+ },
314+ /// "Delete the preset?", for the preset of this name.
315+ Delete(String),
316+ /// "Turn dual connection off?".
317+ Dual,
318+}
319+
320+impl Overlay {
321+ /// Whether `other` is the same dialog, whatever it shows now (the name typed so far).
322+ fn same(&self, other: &Overlay) -> bool {
323+ std::mem::discriminant(self) == std::mem::discriminant(other)
324+ }
325+}
326+
327+/// What the window shows, in broad strokes: the parts that fade in or out when they change.
328+struct Scene {
329+ page: Page,
330+ read: bool,
331+ overlay: Option<Overlay>,
332+ problem: bool,
333+ bands: Option<u8>,
334+}
335+
298336 /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
299337 /// items with a write on its way.
300338 #[derive(Clone, Copy)]
@@ -396,6 +434,10 @@ enum Message {
396434 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
397435 Pointer,
398436 CloseHelp,
437+ /// A frame is drawn while something fades, at this time.
438+ Frame(Instant),
439+ /// Nothing: what a dialog fading out sends, if pressed.
440+ Noop,
399441 }
400442
401443 struct App {
@@ -445,6 +487,18 @@ struct App {
445487 editing: Option<u8>,
446488 /// "Turn dual connection off?" is open.
447489 confirm_dual: bool,
490+ /// The time the fades are drawn at: of the last frame, or of the last fade begun.
491+ now: Instant,
492+ /// The page fading in, since when, and the page open before it.
493+ page_fade: Option<(Instant, Page)>,
494+ /// When the open dialog began fading in.
495+ overlay_since: Option<Instant>,
496+ /// A dialog fading out, as it was, and since when.
497+ closing: Option<(Overlay, Instant)>,
498+ /// When the problem above the page began fading in.
499+ problem_since: Option<Instant>,
500+ /// When the open bands of a custom preset began fading in.
501+ bands_since: Option<Instant>,
448502 }
449503
450504 impl App {
@@ -461,7 +515,10 @@ impl App {
461515 page: Page::Overview,
462516 buds,
463517 anc_level,
464− look: Look { p: theme::BUILT_IN },
518+ look: Look {
519+ p: theme::BUILT_IN,
520+ motion: false,
521+ },
465522 theme: theme::iced_theme(&theme::BUILT_IN),
466523 palette: theme::Source::new(theme_file),
467524 keys: keys::Keys::default(),
@@ -484,12 +541,26 @@ impl App {
484541 confirm_delete: None,
485542 editing: None,
486543 confirm_dual: false,
544+ now: Instant::now(),
545+ page_fade: None,
546+ overlay_since: None,
547+ closing: None,
548+ problem_since: None,
549+ bands_since: None,
487550 };
488551 app.refresh_palette();
489552 app
490553 }
491554
555+ /// Acts on `message`, then starts the fades for what it changed.
492556 fn update(&mut self, message: Message) -> Task<Message> {
557+ let before = self.scene();
558+ let task = self.apply(message);
559+ self.begin_fades(before);
560+ task
561+ }
562+
563+ fn apply(&mut self, message: Message) -> Task<Message> {
493564 match message {
494565 Message::Open(page) => return self.open(page),
495566 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
@@ -579,10 +650,113 @@ impl App {
579650 Message::Key(key, modifiers) => return self.key(&key, modifiers),
580651 Message::Pointer => self.cursor_shown = false,
581652 Message::CloseHelp => self.help = false,
653+ Message::Frame(now) => self.frame(now),
654+ Message::Noop => {}
582655 }
583656 Task::none()
584657 }
585658
659+ fn scene(&self) -> Scene {
660+ Scene {
661+ page: self.page,
662+ read: self.has_reading(),
663+ overlay: self.overlay(),
664+ problem: self.problem_shown(),
665+ bands: self.editing,
666+ }
667+ }
668+
669+ /// Starts the fades for what changed since `before`: the page or its first reading, a
670+ /// dialog opening or closing, the problem above the page, the bands of a preset opening.
671+ fn begin_fades(&mut self, before: Scene) {
672+ if !self.look.motion {
673+ return;
674+ }
675+ let after = self.scene();
676+ let now = Instant::now();
677+ if (after.page, after.read) != (before.page, before.read) {
678+ self.page_fade = Some((now, before.page));
679+ }
680+ match (before.overlay, &after.overlay) {
681+ (Some(old), Some(new)) if old.same(new) => {}
682+ (_, Some(_)) => {
683+ self.overlay_since = Some(now);
684+ self.closing = None;
685+ }
686+ (Some(old), None) => {
687+ self.overlay_since = None;
688+ self.closing = Some((old, now));
689+ }
690+ (None, None) => {}
691+ }
692+ if after.problem && !before.problem {
693+ self.problem_since = Some(now);
694+ }
695+ if after.bands.is_some() && after.bands != before.bands {
696+ self.bands_since = Some(now);
697+ }
698+ if self.fading() {
699+ self.now = self.now.max(now);
700+ }
701+ }
702+
703+ /// A frame of the fades, at `now`: the ones over are dropped.
704+ fn frame(&mut self, now: Instant) {
705+ self.now = now;
706+ let going = |since: &Instant, length| !motion::over(*since, length, now);
707+ self.page_fade = self.page_fade.filter(|(since, _)| going(since, motion::PAGE));
708+ self.overlay_since = self.overlay_since.filter(|since| going(since, motion::OPEN));
709+ self.closing = self.closing.take().filter(|(_, since)| going(since, motion::CLOSE));
710+ self.problem_since = self.problem_since.filter(|since| going(since, motion::OPEN));
711+ self.bands_since = self.bands_since.filter(|since| going(since, motion::OPEN));
712+ }
713+
714+ /// Whether something fades: the window then wants every frame.
715+ fn fading(&self) -> bool {
716+ self.page_fade.is_some()
717+ || self.overlay_since.is_some()
718+ || self.closing.is_some()
719+ || self.problem_since.is_some()
720+ || self.bands_since.is_some()
721+ }
722+
723+ /// How far a fade that began at `since` and lasts `length` is (0 to 1); 1 for none.
724+ fn shown(&self, since: Option<Instant>, length: Duration) -> f32 {
725+ since.map_or(1.0, |since| motion::progress(since, length, self.now))
726+ }
727+
728+ /// The dialog open, if one is: the first of them, as the keys take them.
729+ fn overlay(&self) -> Option<Overlay> {
730+ if self.help {
731+ return Some(Overlay::Help);
732+ }
733+ if self.confirm_find {
734+ return Some(Overlay::Find);
735+ }
736+ if let Some(prompt) = &self.prompt {
737+ let (heading, action) = match prompt.target {
738+ NameTarget::New => ("New preset".to_owned(), "Create"),
739+ NameTarget::Rename(id) => {
740+ let name = self.buds.preset(id).map_or("the preset", |preset| preset.name.as_str());
741+ (format!("Rename {name}"), "Rename")
742+ }
743+ };
744+ return Some(Overlay::Name {
745+ heading,
746+ action,
747+ prompt: prompt.clone(),
748+ });
749+ }
750+ if let Some(id) = self.confirm_delete {
751+ let name = self
752+ .buds
753+ .preset(id)
754+ .map_or("this preset", |preset| preset.name.as_str());
755+ return Some(Overlay::Delete(name.to_owned()));
756+ }
757+ self.confirm_dual.then_some(Overlay::Dual)
758+ }
759+
586760 /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
587761 /// answer, the item shows that it is saving and takes no other write.
588762 fn write(&mut self, item: Item, command: Command) {
@@ -1273,16 +1447,22 @@ impl App {
12731447 }
12741448
12751449 fn subscription(&self) -> Subscription<Message> {
1276− let events = event::listen_with(|event, _status, _window| on_event(event));
1277− if self.link == Link::Demo {
1278− return events;
1450+ let mut subscriptions = vec![event::listen_with(|event, _status, _window| on_event(event))];
1451+ if self.link != Link::Demo {
1452+ subscriptions.push(Subscription::run_with((self.device, self.idle), worker::run).map(Message::Worker));
12791453 }
1280− let worker = Subscription::run_with((self.device, self.idle), worker::run).map(Message::Worker);
1281− Subscription::batch([events, worker])
1454+ // Every frame while something fades, and none once it is over: the window rests.
1455+ if self.fading() {
1456+ subscriptions.push(window::frames().map(Message::Frame));
1457+ }
1458+ Subscription::batch(subscriptions)
12821459 }
12831460
12841461 fn view(&self) -> Element<'_, Message> {
1285− let look = self.look;
1462+ // The page fades in when opened, and when its first reading comes.
1463+ let look = self
1464+ .look
1465+ .fade(self.shown(self.page_fade.map(|(since, _)| since), motion::PAGE));
12861466 let pending = self.pending();
12871467 let page = match self.page {
12881468 Page::Overview if self.has_reading() => {
@@ -1292,13 +1472,14 @@ impl App {
12921472 };
12931473 overview::view(look, &self.buds, self.anc_level, self.bass_draft, marks)
12941474 }
1295− Page::Overview => self.connecting(),
1475+ Page::Overview => self.connecting(look),
12961476 Page::Equalizer if self.has_reading() => {
12971477 let marks = Marks {
12981478 focus: self.focus(),
12991479 pending: &pending,
13001480 };
1301− equalizer::view(look, &self.buds, self.editing, self.draft.as_ref(), marks)
1481+ let bands = look.fade(self.shown(self.bands_since, motion::OPEN));
1482+ equalizer::view(look, bands, &self.buds, self.editing, self.draft.as_ref(), marks)
13021483 }
13031484 Page::Gestures if self.has_reading() => {
13041485 let marks = Marks {
@@ -1328,8 +1509,9 @@ impl App {
13281509 .spacing(16)
13291510 .into(),
13301511 };
1331− let page = column![self.problem(), page].spacing(20);
1332− let p = look.p;
1512+ let problem = look.fade(self.shown(self.problem_since, motion::OPEN));
1513+ let page = column![self.problem(problem), page].spacing(20);
1514+ let p = self.look.p;
13331515 let content = scrollable(
13341516 container(container(page).max_width(860))
13351517 .center_x(Length::Fill)
@@ -1354,24 +1536,22 @@ impl App {
13541536 background: Some(p.bg.into()),
13551537 ..container::Style::default()
13561538 });
1357− if self.help {
1358− stack![window, self.help()].into()
1359− } else if self.confirm_find {
1360− stack![window, self.confirm_find()].into()
1361− } else if let Some(prompt) = &self.prompt {
1362− stack![window, self.name_prompt(prompt)].into()
1363− } else if let Some(id) = self.confirm_delete {
1364− stack![window, self.confirm_delete(id)].into()
1365− } else if self.confirm_dual {
1366− stack![window, self.confirm_dual()].into()
1539+ // A dialog fades in over the dimmed window; closed, it fades out, and nothing in it
1540+ // answers any more.
1541+ if let Some(overlay) = self.overlay() {
1542+ let look = self.look.fade(self.shown(self.overlay_since, motion::OPEN));
1543+ stack![window, opaque(overlay_view(look, overlay))].into()
1544+ } else if let Some((overlay, since)) = &self.closing {
1545+ let look = self.look.fade(1.0 - motion::progress(*since, motion::CLOSE, self.now));
1546+ let fading = overlay_view(look, overlay.clone()).map(|_| Message::Noop);
1547+ stack![window, fading].into()
13671548 } else {
13681549 window.into()
13691550 }
13701551 }
13711552
13721553 /// The Overview before the first reading: connecting, or why it did not work.
1373− fn connecting(&self) -> Element<'_, Message> {
1374− let look = self.look;
1554+ fn connecting(&self, look: Look) -> Element<'_, Message> {
13751555 let p = look.p;
13761556 let (headline, detail) = match &self.link {
13771557 Link::Failed {
@@ -1393,7 +1573,7 @@ impl App {
13931573 let state = if failed {
13941574 row![ui::dot(p.err, 10.0), ui::label(look, "no link", 11.0, p.err)]
13951575 } else {
1396− row![ui::dot(p.live, 10.0), ui::label(look, "searching", 11.0, p.live)]
1576+ row![dots::scan(look, p.live), ui::label(look, "searching", 11.0, p.live)]
13971577 };
13981578 let words = column![
13991579 state.spacing(10).align_y(Alignment::Center),
@@ -1409,14 +1589,18 @@ impl App {
14091589 .into()
14101590 }
14111591
1592+ /// Whether a failure shows above the page: see [`App::problem`].
1593+ fn problem_shown(&self) -> bool {
1594+ matches!(self.link, Link::Failed { .. }) && self.read_at.is_some()
1595+ }
1596+
14121597 /// A failure after the buds were read once: what went wrong, above the page.
1413− fn problem(&self) -> Option<Element<'_, Message>> {
1598+ fn problem(&self, look: Look) -> Option<Element<'_, Message>> {
14141599 let Link::Failed { message, .. } = &self.link else {
14151600 return None;
14161601 };
14171602 // Before the first reading, the Overview says it already.
14181603 self.read_at?;
1419− let look = self.look;
14201604 let p = look.p;
14211605 let content = row![
14221606 ui::dot(p.err, 10.0),
@@ -1451,7 +1635,12 @@ impl App {
14511635 let device = column![
14521636 text(self.buds.name.as_str()).size(18).font(look.semibold()).color(p.fg),
14531637 row![
1454− ui::dot(light, 8.0),
1638+ // Blinking while it looks for the buds, like a recording light.
1639+ if self.link == Link::Connecting {
1640+ dots::blink(look, light, 8.0)
1641+ } else {
1642+ ui::dot(light, 8.0)
1643+ },
14551644 ui::label(look, state, 11.0, p.grey),
14561645 space::horizontal()
14571646 ]
@@ -1462,7 +1651,15 @@ impl App {
14621651 ]
14631652 .spacing(14)
14641653 .padding([0, 10]);
1654+ // The page opened and the one left fade in at their new sizes, with the page.
1655+ let changed = self
1656+ .page_fade
1657+ .map(|(since, from)| (from, self.shown(Some(since), motion::PAGE)));
14651658 let pages = Page::ALL.into_iter().fold(Column::new().spacing(2), |pages, page| {
1659+ let look = match changed {
1660+ Some((from, t)) if page == from || page == self.page => look.fade(t),
1661+ _ => look,
1662+ };
14661663 pages.push(ui::nav_item(look, page.word(), page == self.page, Message::Open(page)))
14671664 });
14681665 let hint = container(ui::label(look, "? keys · q quit", 10.5, p.grey)).padding([0, 10]);
@@ -1472,128 +1669,122 @@ impl App {
14721669 .height(Length::Fill)
14731670 .into()
14741671 }
1672+}
14751673
1476− /// A dialog over the dimmed window: heading, words, and Cancel beside the main action.
1477− fn dialog<'a>(
1478− &self,
1479− heading: String,
1480− words: &'a str,
1481− answer: fn(bool) -> Message,
1482− action: &'a str,
1483− ) -> Element<'a, Message> {
1484− let look = self.look;
1485− let buttons = row![
1486− space::horizontal(),
1487− ui::pill(look, "Cancel", false, answer(false)),
1488− ui::pill(look, action, true, answer(true)),
1489− ]
1490− .spacing(10);
1491− let body = column![ui::headline(look, heading), ui::note(look, words)].spacing(10);
1492− let dialog = ui::panel(look, column![body, buttons].spacing(26).into());
1493− opaque(ui::scrim(look, dialog, 480.0))
1494− }
1495−
1496− /// "Ring the buds?": the sound is loud enough to hurt in the ears.
1497− fn confirm_find(&self) -> Element<'_, Message> {
1498− self.dialog(
1674+/// A dialog, `look` as it fades.
1675+fn overlay_view<'a>(look: Look, overlay: Overlay) -> Element<'a, Message> {
1676+ match overlay {
1677+ Overlay::Help => help(look),
1678+ // The sound is loud enough to hurt in the ears.
1679+ Overlay::Find => dialog(
1680+ look,
14991681 "Ring your buds?".to_owned(),
15001682 "Take them out of your ears first: the sound is loud. They ring until you press Stop.",
15011683 Message::ConfirmFind,
15021684 "Ring",
1503− )
1504− }
1505−
1506− /// "Turn dual connection off?": with two devices connected, the buds may drop one.
1507− fn confirm_dual(&self) -> Element<'_, Message> {
1508− self.dialog(
1685+ ),
1686+ Overlay::Name {
1687+ heading,
1688+ action,
1689+ prompt,
1690+ } => name_prompt(look, heading, action, &prompt),
1691+ // Its bands are lost.
1692+ Overlay::Delete(name) => dialog(
1693+ look,
1694+ format!("Delete {name}?"),
1695+ "Its bands are lost. The buds keep their other presets.",
1696+ Message::ConfirmDelete,
1697+ "Delete",
1698+ ),
1699+ // With two devices connected, the buds may drop one.
1700+ Overlay::Dual => dialog(
1701+ look,
15091702 "Turn dual connection off?".to_owned(),
15101703 "If two devices are connected to your buds, they may drop one of them.",
15111704 Message::ConfirmDual,
15121705 "Turn off",
1513− )
1706+ ),
15141707 }
1708+}
15151709
1516− /// "Delete the preset?": its bands are lost.
1517− fn confirm_delete(&self, id: u8) -> Element<'_, Message> {
1518− let name = self
1519− .buds
1520− .preset(id)
1521− .map_or("this preset", |preset| preset.name.as_str());
1522− self.dialog(
1523− format!("Delete {name}?"),
1524− "Its bands are lost. The buds keep their other presets.",
1525− Message::ConfirmDelete,
1526− "Delete",
1527− )
1528− }
1710+/// A dialog over the dimmed window: heading, words, and Cancel beside the main action.
1711+fn dialog<'a>(
1712+ look: Look,
1713+ heading: String,
1714+ words: &'static str,
1715+ answer: fn(bool) -> Message,
1716+ action: &'static str,
1717+) -> Element<'a, Message> {
1718+ let buttons = row![
1719+ space::horizontal(),
1720+ ui::pill(look, "Cancel", false, answer(false)),
1721+ ui::pill(look, action, true, answer(true)),
1722+ ]
1723+ .spacing(10);
1724+ let body = column![ui::headline(look, heading), ui::note(look, words)].spacing(10);
1725+ let dialog = ui::panel(look, column![body, buttons].spacing(26).into());
1726+ ui::scrim(look, dialog, 480.0)
1727+}
15291728
1530− /// The dialog that asks for a preset's name, over a dimmed window.
1531− fn name_prompt<'a>(&'a self, prompt: &'a NamePrompt) -> Element<'a, Message> {
1532− let look = self.look;
1533− let p = look.p;
1534− let (heading, action) = match prompt.target {
1535− NameTarget::New => ("New preset".to_owned(), "Create"),
1536− NameTarget::Rename(id) => {
1537− let name = self.buds.preset(id).map_or("the preset", |preset| preset.name.as_str());
1538− (format!("Rename {name}"), "Rename")
1539− }
1540− };
1541− let field = text_input("Name", &prompt.text)
1542− .id(NAME_INPUT)
1543− .on_input(Message::NameInput)
1544− .on_submit(Message::SubmitName)
1545− .size(16)
1546− .padding([12, 20])
1547− .style(move |_, _| text_input::Style {
1548− background: p.s2.into(),
1549− border: Border {
1550− color: p.fill,
1551− width: 1.0,
1552− radius: 999.0.into(),
1553− },
1554− icon: p.grey,
1555− placeholder: p.grey,
1556− value: p.fg,
1557− selection: p.container,
1558− });
1559− let problem = prompt
1560− .problem
1561− .as_deref()
1562− .map(|problem| container(text(problem).size(13).color(p.err)).padding([0, 20]));
1563− let buttons = row![
1564− space::horizontal(),
1565− ui::pill(look, "Cancel", false, Message::CancelName),
1566− ui::pill(look, action, true, Message::SubmitName),
1567− ]
1568− .spacing(10);
1569− let dialog = column![ui::headline(look, heading), field]
1570− .push(problem)
1571− .push(buttons)
1572− .spacing(18);
1573− opaque(ui::scrim(look, ui::panel(look, dialog.into()), 480.0))
1574− }
1729+/// The dialog that asks for a preset's name, over a dimmed window.
1730+fn name_prompt<'a>(look: Look, heading: String, action: &'static str, prompt: &NamePrompt) -> Element<'a, Message> {
1731+ let p = look.p;
1732+ let field = text_input("Name", &prompt.text)
1733+ .id(NAME_INPUT)
1734+ .on_input(Message::NameInput)
1735+ .on_submit(Message::SubmitName)
1736+ .size(16)
1737+ .padding([12, 20])
1738+ .style(move |_, _| text_input::Style {
1739+ background: p.s2.into(),
1740+ border: Border {
1741+ color: p.fill,
1742+ width: 1.0,
1743+ radius: 999.0.into(),
1744+ },
1745+ icon: p.grey,
1746+ placeholder: p.grey,
1747+ value: p.fg,
1748+ selection: p.container,
1749+ });
1750+ let problem = prompt
1751+ .problem
1752+ .clone()
1753+ .map(|problem| container(text(problem).size(13).color(p.err)).padding([0, 20]));
1754+ let buttons = row![
1755+ space::horizontal(),
1756+ ui::pill(look, "Cancel", false, Message::CancelName),
1757+ ui::pill(look, action, true, Message::SubmitName),
1758+ ]
1759+ .spacing(10);
1760+ let dialog = column![ui::headline(look, heading), field]
1761+ .push(problem)
1762+ .push(buttons)
1763+ .spacing(18);
1764+ ui::scrim(look, ui::panel(look, dialog.into()), 480.0)
1765+}
15751766
1576− /// The list of keys, over a dimmed window. A key or a click closes it.
1577− fn help(&self) -> Element<'_, Message> {
1578− let look = self.look;
1579− let p = look.p;
1580− let rows = keys::HELP
1581− .iter()
1582− .fold(Column::new().spacing(10), |rows, &(keys, what)| {
1583− rows.push(
1584− row![
1585− text(keys).size(13).font(look.mono()).color(p.fg).width(180),
1586− text(what).size(14).color(p.fg_dim)
1587− ]
1588− .align_y(Alignment::Center),
1589− )
1590− });
1591− let heading = row![ui::num(look, "keys", 40.0, p.fg), ui::dot(p.live, 12.0)]
1592− .spacing(12)
1593− .align_y(Alignment::Center);
1594− let list = ui::panel(look, column![heading, rows].spacing(22).into());
1595− opaque(mouse_area(ui::scrim(look, list, 580.0)).on_press(Message::CloseHelp))
1596− }
1767+/// The list of keys, over a dimmed window. A key or a click closes it.
1768+fn help<'a>(look: Look) -> Element<'a, Message> {
1769+ let p = look.p;
1770+ let rows = keys::HELP
1771+ .iter()
1772+ .fold(Column::new().spacing(10), |rows, &(keys, what)| {
1773+ rows.push(
1774+ row![
1775+ text(keys).size(13).font(look.mono()).color(p.fg).width(180),
1776+ text(what).size(14).color(p.fg_dim)
1777+ ]
1778+ .align_y(Alignment::Center),
1779+ )
1780+ });
1781+ let heading = row![ui::num(look, "keys", 40.0, p.fg), ui::dot(p.live, 12.0)]
1782+ .spacing(12)
1783+ .align_y(Alignment::Center);
1784+ let list = ui::panel(look, column![heading, rows].spacing(22).into());
1785+ mouse_area(ui::scrim(look, list, 580.0))
1786+ .on_press(Message::CloseHelp)
1787+ .into()
15971788 }
15981789
15991790 /// What a window event means to the window: focus, a key, a click.
@@ -1971,7 +2162,7 @@ mod tests {
19712162 message: "control channel busy".to_owned(),
19722163 }));
19732164 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1974− assert!(app.problem().is_none(), "the Overview says it, not a banner");
2165+ assert!(!app.problem_shown(), "the Overview says it, not a banner");
19752166
19762167 let _ = commands.try_recv();
19772168 let _ = app.update(Message::Refresh);
@@ -1987,7 +2178,7 @@ mod tests {
19872178 assert!(app.pending().is_empty());
19882179 assert!(switch(&app, "dual"));
19892180 assert!(!app.buds.connected);
1990− assert!(app.problem().is_some());
2181+ assert!(app.problem_shown());
19912182 }
19922183
19932184 #[test]
@@ -2415,6 +2606,80 @@ mod tests {
24152606 render(&dir, shots);
24162607 }
24172608
2609+ /// The demo buds, with motion on.
2610+ fn moving() -> App {
2611+ let mut app = app();
2612+ app.look.motion = true;
2613+ app
2614+ }
2615+
2616+ /// A frame long after every fade began.
2617+ fn later() -> Message {
2618+ Message::Frame(Instant::now() + Duration::from_secs(5))
2619+ }
2620+
2621+ #[test]
2622+ fn pages_fade_in_and_the_window_rests_after() {
2623+ let mut app = moving();
2624+ assert!(!app.fading(), "nothing moves at the start");
2625+ let _ = app.update(Message::Open(Page::Equalizer));
2626+ assert_eq!(app.page_fade.map(|(_, from)| from), Some(Page::Overview));
2627+ assert!(app.fading());
2628+ let _ = app.update(later());
2629+ assert_eq!(app.page_fade, None);
2630+ assert!(!app.fading(), "no more frames once the fade is over");
2631+ }
2632+
2633+ #[test]
2634+ fn dialogs_fade_in_and_out() {
2635+ let mut app = moving();
2636+ let _ = app.update(Message::ShowHelp);
2637+ assert!(app.overlay_since.is_some());
2638+ let _ = app.update(Message::CloseHelp);
2639+ assert_eq!(app.overlay_since, None);
2640+ assert_eq!(app.closing.as_ref().map(|(overlay, _)| overlay), Some(&Overlay::Help));
2641+ let _ = app.update(later());
2642+ assert_eq!(app.closing, None);
2643+ assert!(!app.fading());
2644+
2645+ // Typing a name does not start the dialog over.
2646+ let _ = app.update(Message::NewPreset);
2647+ let opened = app.overlay_since;
2648+ assert!(opened.is_some());
2649+ let _ = app.update(Message::NameInput("Mine".to_owned()));
2650+ assert_eq!(app.overlay_since, opened);
2651+ }
2652+
2653+ #[test]
2654+ fn a_dialog_fading_out_keeps_what_it_said() {
2655+ let mut app = moving();
2656+ app.page = Page::Equalizer;
2657+ let name = app.buds.preset(4).unwrap().name.clone();
2658+ let _ = app.update(Message::DeletePreset(4));
2659+ let _ = app.update(Message::ConfirmDelete(true));
2660+ assert!(app.buds.preset(4).is_none(), "the demo buds delete at once");
2661+ assert_eq!(
2662+ app.closing.as_ref().map(|(overlay, _)| overlay),
2663+ Some(&Overlay::Delete(name))
2664+ );
2665+ }
2666+
2667+ #[test]
2668+ fn without_motion_nothing_fades() {
2669+ let mut app = app();
2670+ for message in [
2671+ Message::Open(Page::Earbuds),
2672+ Message::ShowHelp,
2673+ Message::CloseHelp,
2674+ Message::Find,
2675+ Message::ConfirmFind(false),
2676+ ] {
2677+ let _ = app.update(message);
2678+ assert!(!app.fading());
2679+ }
2680+ assert_eq!(app.closing, None);
2681+ }
2682+
24182683 /// The README's screenshots: the demo buds in the built-in palette, written to
24192684 /// `docs/screenshots/` by `cargo test -p budctl-gui readme_screenshots -- --ignored`.
24202685 #[test]
A crates/budctl-gui/src/motion.rs View
@@ -0,0 +1,446 @@
1+//! Motion, in Nothing's way: short, eased out, never bouncing, and fading rather than sliding.
2+//!
3+//! Small moves run inside their widget: a hover, a switch, a ring's dots. The widget starts its
4+//! tween when a redraw shows that what it should show has changed, asks for the frames the tween
5+//! needs, and then lets the window rest, so only its own patch is painted again. Fades of whole
6+//! parts (a page, a dialog) run in the app, which draws the view again on each frame of one.
7+//!
8+//! With motion off (`gui.motion` in the config, and in the tests), everything is at once.
9+
10+use iced::advanced::layout::{self, Layout};
11+use iced::advanced::widget::{Operation, Tree, Widget, tree};
12+use iced::advanced::{Clipboard, Shell, mouse, overlay, renderer};
13+use iced::time::{Duration, Instant};
14+use iced::{Border, Color, Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector, window};
15+
16+use crate::Message;
17+
18+/// A hover, in and out.
19+pub const HOVER: Duration = Duration::from_millis(150);
20+/// A change of state: a switch, the chosen segment, the keyboard cursor.
21+pub const STATE: Duration = Duration::from_millis(200);
22+/// A page fading in.
23+pub const PAGE: Duration = Duration::from_millis(240);
24+/// A dialog fading in.
25+pub const OPEN: Duration = Duration::from_millis(200);
26+/// A dialog fading out.
27+pub const CLOSE: Duration = Duration::from_millis(150);
28+/// A battery ring lighting its dots, one after the other.
29+pub const SWEEP: Duration = Duration::from_millis(600);
30+/// How long a blinking dot stays lit, and dark.
31+pub const BLINK: Duration = Duration::from_millis(500);
32+/// One step of the scanning dots.
33+pub const SCAN: Duration = Duration::from_millis(90);
34+
35+/// The ease-out curve `cubic-bezier(0.25, 0.1, 0.25, 1)`: quick at first, settling gently. `t`
36+/// is the share of the time gone, from 0 to 1; the result, the share of the way gone.
37+pub fn ease(t: f32) -> f32 {
38+ // A cubic Bézier from (0, 0) to (1, 1) with control points (0.25, 0.1) and (0.25, 1),
39+ // along one axis.
40+ fn bezier(s: f32, a: f32, b: f32) -> f32 {
41+ let r = 1.0 - s;
42+ 3.0 * r * r * s * a + 3.0 * r * s * s * b + s * s * s
43+ }
44+ if t <= 0.0 {
45+ return 0.0;
46+ }
47+ if t >= 1.0 {
48+ return 1.0;
49+ }
50+ // x grows with s, so halving finds the s where x is t.
51+ let (mut low, mut high) = (0.0_f32, 1.0_f32);
52+ for _ in 0..24 {
53+ let mid = (low + high) / 2.0;
54+ if bezier(mid, 0.25, 0.25) < t {
55+ low = mid;
56+ } else {
57+ high = mid;
58+ }
59+ }
60+ bezier((low + high) / 2.0, 0.1, 1.0)
61+}
62+
63+/// How far a fade that began at `since` and lasts `length` is at `now`, eased: 0 to 1.
64+pub fn progress(since: Instant, length: Duration, now: Instant) -> f32 {
65+ let gone = now.saturating_duration_since(since).as_secs_f32();
66+ ease(gone / length.as_secs_f32().max(f32::EPSILON))
67+}
68+
69+/// Whether a fade that began at `since` and lasts `length` is over at `now`.
70+pub fn over(since: Instant, length: Duration, now: Instant) -> bool {
71+ now.saturating_duration_since(since) >= length
72+}
73+
74+/// What a tween can move: numbers, colors, paints.
75+pub trait Mix: Copy + PartialEq {
76+ /// `self` moved `t` (0 to 1) of the way to `to`.
77+ fn mix(self, to: Self, t: f32) -> Self;
78+}
79+
80+impl Mix for f32 {
81+ fn mix(self, to: f32, t: f32) -> f32 {
82+ self + (to - self) * t
83+ }
84+}
85+
86+impl Mix for Color {
87+ /// Transparent takes the other color's hue, so a fill fades in as itself, not from black.
88+ fn mix(self, to: Color, t: f32) -> Color {
89+ let from = if self.a == 0.0 { Color { a: 0.0, ..to } } else { self };
90+ let to = if to.a == 0.0 { Color { a: 0.0, ..from } } else { to };
91+ Color {
92+ r: from.r.mix(to.r, t),
93+ g: from.g.mix(to.g, t),
94+ b: from.b.mix(to.b, t),
95+ a: from.a.mix(to.a, t),
96+ }
97+ }
98+}
99+
100+/// A value on its way to another, eased.
101+#[derive(Debug, Clone, Copy, PartialEq)]
102+pub struct Tween<T> {
103+ from: T,
104+ to: T,
105+ /// When it set off; `None` once it is there, or when it went at once.
106+ start: Option<Instant>,
107+ length: Duration,
108+}
109+
110+impl<T: Mix> Tween<T> {
111+ /// A value that stays where it is.
112+ pub fn at_rest(value: T) -> Self {
113+ Tween {
114+ from: value,
115+ to: value,
116+ start: None,
117+ length: Duration::ZERO,
118+ }
119+ }
120+
121+ /// Sets off for `to` from wherever it is now, taking `length`; at once without `motion`.
122+ /// Aiming where it already goes changes nothing.
123+ pub fn aim(&mut self, to: T, now: Instant, length: Duration, motion: bool) {
124+ if to == self.to {
125+ return;
126+ }
127+ self.from = self.at(now);
128+ self.to = to;
129+ self.start = motion.then_some(now);
130+ self.length = length;
131+ }
132+
133+ /// Where it is at `now`.
134+ pub fn at(&self, now: Instant) -> T {
135+ match self.start {
136+ Some(start) if !over(start, self.length, now) => self.from.mix(self.to, progress(start, self.length, now)),
137+ _ => self.to,
138+ }
139+ }
140+
141+ /// Where it is going.
142+ pub fn target(&self) -> T {
143+ self.to
144+ }
145+
146+ /// Whether it is still on its way at `now`.
147+ pub fn running(&self, now: Instant) -> bool {
148+ self.start.is_some_and(|start| !over(start, self.length, now))
149+ }
150+}
151+
152+/// When a redraw is under way, its time.
153+pub fn redraw(event: &Event) -> Option<Instant> {
154+ match event {
155+ Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
156+ _ => None,
157+ }
158+}
159+
160+/// A surface's paint: its fill, and its 2 px frame (the keyboard cursor). Transparent is none.
161+#[derive(Debug, Clone, Copy, PartialEq)]
162+pub struct Paint {
163+ pub fill: Color,
164+ pub frame: Color,
165+}
166+
167+impl Paint {
168+ pub const NONE: Paint = Paint {
169+ fill: Color::TRANSPARENT,
170+ frame: Color::TRANSPARENT,
171+ };
172+
173+ /// Just a fill.
174+ pub fn fill(color: Color) -> Paint {
175+ Paint {
176+ fill: color,
177+ frame: Color::TRANSPARENT,
178+ }
179+ }
180+
181+ /// `self`, framed in `color` when `shown`.
182+ pub fn framed(self, color: Color, shown: bool) -> Paint {
183+ Paint {
184+ frame: if shown { color } else { Color::TRANSPARENT },
185+ ..self
186+ }
187+ }
188+}
189+
190+impl Mix for Paint {
191+ fn mix(self, to: Paint, t: f32) -> Paint {
192+ Paint {
193+ fill: self.fill.mix(to.fill, t),
194+ frame: self.frame.mix(to.frame, t),
195+ }
196+ }
197+}
198+
199+/// `content` on a surface painted `rest`, or `hover` while the mouse is over it (when
200+/// `interactive`). A change of paint fades: the hover in [`HOVER`], anything else (the chosen
201+/// segment, the keyboard cursor) in [`STATE`].
202+///
203+/// The content keeps its own clicks and keys; give a button in it no background or border.
204+pub fn eased<'a>(
205+ content: impl Into<Element<'a, Message>>,
206+ rest: Paint,
207+ hover: Paint,
208+ radius: f32,
209+ interactive: bool,
210+ motion: bool,
211+) -> Element<'a, Message> {
212+ Element::new(Eased {
213+ content: content.into(),
214+ rest,
215+ hover,
216+ radius,
217+ interactive,
218+ motion,
219+ })
220+}
221+
222+/// `content` on a surface of one paint, which fades when it changes.
223+pub fn surface<'a>(
224+ content: impl Into<Element<'a, Message>>,
225+ paint: Paint,
226+ radius: f32,
227+ motion: bool,
228+) -> Element<'a, Message> {
229+ eased(content, paint, paint, radius, false, motion)
230+}
231+
232+struct Eased<'a> {
233+ content: Element<'a, Message>,
234+ rest: Paint,
235+ hover: Paint,
236+ radius: f32,
237+ interactive: bool,
238+ motion: bool,
239+}
240+
241+/// What an [`Eased`] surface shows, kept between views.
242+struct Shown {
243+ hovered: bool,
244+ paint: Tween<Paint>,
245+ /// The time of the last redraw: the paint is drawn as it is then.
246+ now: Option<Instant>,
247+}
248+
249+impl Widget<Message, Theme, Renderer> for Eased<'_> {
250+ fn tag(&self) -> tree::Tag {
251+ tree::Tag::of::<Shown>()
252+ }
253+
254+ fn state(&self) -> tree::State {
255+ tree::State::new(Shown {
256+ hovered: false,
257+ paint: Tween::at_rest(self.rest),
258+ now: None,
259+ })
260+ }
261+
262+ fn children(&self) -> Vec<Tree> {
263+ vec![Tree::new(&self.content)]
264+ }
265+
266+ fn diff(&self, tree: &mut Tree) {
267+ tree.diff_children(std::slice::from_ref(&self.content));
268+ }
269+
270+ fn size(&self) -> Size<Length> {
271+ self.content.as_widget().size()
272+ }
273+
274+ fn size_hint(&self) -> Size<Length> {
275+ self.content.as_widget().size_hint()
276+ }
277+
278+ fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
279+ match tree.children.first_mut() {
280+ Some(child) => self.content.as_widget_mut().layout(child, renderer, limits),
281+ None => layout::Node::new(Size::ZERO),
282+ }
283+ }
284+
285+ fn update(
286+ &mut self,
287+ tree: &mut Tree,
288+ event: &Event,
289+ layout: Layout<'_>,
290+ cursor: mouse::Cursor,
291+ renderer: &Renderer,
292+ clipboard: &mut dyn Clipboard,
293+ shell: &mut Shell<'_, Message>,
294+ viewport: &Rectangle,
295+ ) {
296+ if let Some(child) = tree.children.first_mut() {
297+ self.content
298+ .as_widget_mut()
299+ .update(child, event, layout, cursor, renderer, clipboard, shell, viewport);
300+ }
301+ let shown = tree.state.downcast_mut::<Shown>();
302+ // A fade starts now, not at the last redraw: that may be long gone, and the fade with it.
303+ let now = match redraw(event) {
304+ Some(now) => {
305+ shown.now = Some(now);
306+ now
307+ }
308+ None => Instant::now(),
309+ };
310+ let hovered = self.interactive && cursor.is_over(layout.bounds());
311+ let length = if hovered == shown.hovered { STATE } else { HOVER };
312+ shown.hovered = hovered;
313+ let target = if hovered { self.hover } else { self.rest };
314+ if target != shown.paint.target() || shown.paint.running(now) {
315+ shown.paint.aim(target, now, length, self.motion);
316+ shell.request_redraw();
317+ }
318+ }
319+
320+ fn draw(
321+ &self,
322+ tree: &Tree,
323+ renderer: &mut Renderer,
324+ theme: &Theme,
325+ style: &renderer::Style,
326+ layout: Layout<'_>,
327+ cursor: mouse::Cursor,
328+ viewport: &Rectangle,
329+ ) {
330+ use iced::advanced::Renderer as _;
331+
332+ let shown = tree.state.downcast_ref::<Shown>();
333+ let paint = shown.now.map_or(shown.paint.target(), |now| shown.paint.at(now));
334+ if paint.fill.a > 0.0 || paint.frame.a > 0.0 {
335+ renderer.fill_quad(
336+ renderer::Quad {
337+ bounds: layout.bounds(),
338+ border: Border {
339+ color: paint.frame,
340+ width: if paint.frame.a > 0.0 { 2.0 } else { 0.0 },
341+ radius: self.radius.into(),
342+ },
343+ ..renderer::Quad::default()
344+ },
345+ paint.fill,
346+ );
347+ }
348+ if let Some(child) = tree.children.first() {
349+ self.content
350+ .as_widget()
351+ .draw(child, renderer, theme, style, layout, cursor, viewport);
352+ }
353+ }
354+
355+ fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) {
356+ if let Some(child) = tree.children.first_mut() {
357+ self.content.as_widget_mut().operate(child, layout, renderer, operation);
358+ }
359+ }
360+
361+ fn mouse_interaction(
362+ &self,
363+ tree: &Tree,
364+ layout: Layout<'_>,
365+ cursor: mouse::Cursor,
366+ viewport: &Rectangle,
367+ renderer: &Renderer,
368+ ) -> mouse::Interaction {
369+ tree.children.first().map_or_else(mouse::Interaction::default, |child| {
370+ self.content
371+ .as_widget()
372+ .mouse_interaction(child, layout, cursor, viewport, renderer)
373+ })
374+ }
375+
376+ fn overlay<'b>(
377+ &'b mut self,
378+ tree: &'b mut Tree,
379+ layout: Layout<'b>,
380+ renderer: &Renderer,
381+ viewport: &Rectangle,
382+ translation: Vector,
383+ ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
384+ let child = tree.children.first_mut()?;
385+ self.content
386+ .as_widget_mut()
387+ .overlay(child, layout, renderer, viewport, translation)
388+ }
389+}
390+
391+#[cfg(test)]
392+mod tests {
393+ use super::*;
394+
395+ #[test]
396+ fn the_curve_eases_out() {
397+ assert!(ease(0.0).abs() < 1e-4);
398+ assert!((ease(1.0) - 1.0).abs() < 1e-4);
399+ // Out of range is held at the ends.
400+ assert!(ease(-1.0).abs() < 1e-4);
401+ assert!((ease(2.0) - 1.0).abs() < 1e-4);
402+ // Most of the way is gone by half the time: it settles gently.
403+ assert!(ease(0.5) > 0.75);
404+ let mut last = 0.0;
405+ for step in 1..=100 {
406+ let now = ease(step as f32 / 100.0);
407+ assert!(now >= last, "never goes back, so never bounces");
408+ assert!(now <= 1.0 + 1e-4, "never overshoots");
409+ last = now;
410+ }
411+ }
412+
413+ #[test]
414+ fn tweens_go_where_they_are_aimed() {
415+ let start = Instant::now();
416+ let mut tween = Tween::at_rest(0.0_f32);
417+ assert!(!tween.running(start));
418+ tween.aim(10.0, start, STATE, true);
419+ assert!(tween.running(start));
420+ assert_eq!(tween.at(start), 0.0);
421+ let halfway = tween.at(start + STATE / 2);
422+ assert!(halfway > 5.0 && halfway < 10.0, "{halfway}");
423+ assert_eq!(tween.at(start + STATE), 10.0);
424+ assert!(!tween.running(start + STATE));
425+
426+ // Aimed elsewhere on the way, it sets off from where it is.
427+ tween.aim(0.0, start + STATE / 2, STATE, true);
428+ assert!((tween.at(start + STATE / 2) - halfway).abs() < 1e-3);
429+
430+ // Without motion, it is there at once.
431+ let mut still = Tween::at_rest(0.0_f32);
432+ still.aim(1.0, start, STATE, false);
433+ assert!(!still.running(start));
434+ assert_eq!(still.at(start), 1.0);
435+ }
436+
437+ #[test]
438+ fn colors_fade_in_as_themselves() {
439+ let red = Color::from_rgb(1.0, 0.0, 0.0);
440+ let half = Color::TRANSPARENT.mix(red, 0.5);
441+ assert_eq!(half, Color { a: 0.5, ..red });
442+ let half = red.mix(Color::TRANSPARENT, 0.5);
443+ assert_eq!(half, Color { a: 0.5, ..red });
444+ assert_eq!(red.mix(Color::WHITE, 1.0), Color::WHITE);
445+ }
446+}
M crates/budctl-gui/src/overview.rs View
@@ -202,6 +202,7 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M
202202 release: Message::SaveBass,
203203 on: p.fill,
204204 off: p.s3,
205+ motion: look.motion,
205206 };
206207 let control = row![container(ui::value(look, &shown)).align_right(76), slider.view(200.0)]
207208 .spacing(14)
M crates/budctl-gui/src/theme.rs View
@@ -216,9 +216,48 @@ const DOT: Font = Font {
216216 #[derive(Debug, Clone, Copy)]
217217 pub struct Look {
218218 pub p: Palette,
219+ /// Whether things move: off, every change shows at once (`gui.motion`, and the tests).
220+ pub motion: bool,
219221 }
220222
221223 impl Look {
224+ /// The look of something fading in or out, `t` of the way visible (0 to 1).
225+ ///
226+ /// Surfaces (the dimming behind a dialog, cards, circles) come in twice as fast as what is
227+ /// on them, so a card is solid by halfway and its words fade in on it, never through to
228+ /// what is under it. Going out, the words go first, then the card.
229+ pub fn fade(self, t: f32) -> Look {
230+ let t = t.clamp(0.0, 1.0);
231+ if t >= 1.0 {
232+ return self;
233+ }
234+ let surface = (t * 2.0).min(1.0);
235+ let s = |c: Color| Color { a: c.a * surface, ..c };
236+ let f = |c: Color| Color { a: c.a * t, ..c };
237+ let p = self.p;
238+ Look {
239+ p: Palette {
240+ bg: s(p.bg),
241+ s1: s(p.s1),
242+ s2: s(p.s2),
243+ s3: s(p.s3),
244+ mute: f(p.mute),
245+ grey: f(p.grey),
246+ fg: f(p.fg),
247+ fg_dim: f(p.fg_dim),
248+ fill: f(p.fill),
249+ fill_fg: f(p.fill_fg),
250+ container: s(p.container),
251+ container_fg: f(p.container_fg),
252+ live: f(p.live),
253+ live_fg: f(p.live_fg),
254+ err: f(p.err),
255+ err_fg: f(p.err_fg),
256+ },
257+ ..self
258+ }
259+ }
260+
222261 pub fn medium(self) -> Font {
223262 Font {
224263 weight: Weight::Medium,
@@ -261,6 +300,27 @@ pub fn iced_theme(p: &Palette) -> iced::Theme {
261300 mod tests {
262301 use super::*;
263302
303+ #[test]
304+ fn fading_only_makes_colors_clearer() {
305+ let look = Look {
306+ p: BUILT_IN,
307+ motion: true,
308+ };
309+ assert_eq!(look.fade(1.0).p, BUILT_IN);
310+ let half = look.fade(0.5).p;
311+ assert_eq!(half.fg, Color { a: 0.5, ..BUILT_IN.fg });
312+ assert_eq!(
313+ half.live,
314+ Color {
315+ a: 0.5,
316+ ..BUILT_IN.live
317+ }
318+ );
319+ assert_eq!(half.s1, BUILT_IN.s1, "a card is solid by halfway");
320+ assert_eq!(look.fade(0.25).p.s1.a, 0.5);
321+ assert_eq!(look.fade(-1.0).p.s1.a, 0.0);
322+ }
323+
264324 #[test]
265325 fn reads_the_aurora_keys() {
266326 let palette = parse(
M crates/budctl-gui/src/ui.rs View
@@ -1,14 +1,18 @@
11 //! The building blocks every page uses, in Nothing OS's shapes: caps labels, dot-matrix words,
22 //! round cards, rows with their icon in a circle, pill switches, pill tracks and chips.
33 //! Everything takes its colors from the [`Look`].
4+//!
5+//! What can be pressed sits on a [`motion::eased`] surface, so its hover and its changes of
6+//! state fade rather than snap.
47
5−use iced::alignment::Horizontal;
68 use iced::widget::{Column, Row, Stack, button, column, container, row, space, text};
7−use iced::{Alignment, Border, Color, Element, Length};
9+use iced::{Alignment, Border, Color, Element, Length, Theme};
810
911 use crate::Message;
12+use crate::dots;
1013 use crate::icons::{self, Icon};
11−use crate::theme::{Look, Palette};
14+use crate::motion::{self, Paint};
15+use crate::theme::Look;
1216
1317 /// Round corners: cards, and the rows in them.
1418 const CARD: f32 = 28.0;
@@ -102,7 +106,8 @@ pub fn scrim<'a>(look: Look, content: Element<'a, Message>, width: f32) -> Eleme
102106 container(container(content).width(width))
103107 .center(Length::Fill)
104108 .style(move |_| container::Style {
105− background: Some(Color { a: 0.74, ..bg }.into()),
109+ // As clear as the background is: a dialog fading in brings its dimming with it.
110+ background: Some(Color { a: 0.74 * bg.a, ..bg }.into()),
106111 ..container::Style::default()
107112 })
108113 .into()
@@ -154,62 +159,45 @@ pub fn list_row<'a>(
154159 p.s2
155160 };
156161 let glyph = if current { p.fill_fg } else { p.fg };
157− let content = row![disc(icons::icon(icon, 20.0, glyph), 44.0, circle), words, trailing]
158− .spacing(14)
159− .align_y(Alignment::Center)
160− .padding([6, 14]);
162+ let content = row![
163+ disc(look, icons::icon(icon, 20.0, glyph), 44.0, circle),
164+ words,
165+ trailing
166+ ]
167+ .spacing(14)
168+ .align_y(Alignment::Center)
169+ .padding([6, 14]);
161170 let content = container(content).padding([0, 6]);
162− button(content)
171+ let interactive = on_press.is_some();
172+ let rest = Paint::fill(if focused { p.s3 } else { Color::TRANSPARENT }).framed(p.fill, focused);
173+ let hover = Paint { fill: p.s3, ..rest };
174+ let row = button(content)
163175 .on_press_maybe(on_press)
164176 .padding(0)
165177 .width(Length::Fill)
166− .style(move |_, status| button::Style {
167− background: (focused || hovered(status)).then_some(p.s3.into()),
168− text_color: p.fg,
169− border: cursor(p, focused, CARD),
170− ..button::Style::default()
171− })
172− .into()
178+ .style(bare(p.fg));
179+ motion::eased(row, rest, hover, CARD, interactive, look.motion)
173180 }
174181
175182 /// `content` centered in a circle `size` across.
176−fn disc<'a>(content: Element<'a, Message>, size: f32, color: Color) -> Element<'a, Message> {
177− container(content)
178− .center(size)
179− .style(move |_| surface(color, ROUND))
180− .into()
183+fn disc<'a>(look: Look, content: Element<'a, Message>, size: f32, color: Color) -> Element<'a, Message> {
184+ motion::surface(container(content).center(size), Paint::fill(color), ROUND, look.motion)
181185 }
182186
183−/// The keyboard cursor: a 2 px frame in the fill color. No frame without it.
184−fn cursor(p: Palette, focused: bool, radius: f32) -> Border {
185− Border {
186− color: p.fill,
187− width: if focused { 2.0 } else { 0.0 },
188− radius: radius.into(),
187+/// A button with no paint of its own: the [`motion::eased`] surface under it paints it.
188+fn bare(text_color: Color) -> impl Fn(&Theme, button::Status) -> button::Style {
189+ move |_, _| button::Style {
190+ background: None,
191+ text_color,
192+ border: Border::default(),
193+ ..button::Style::default()
189194 }
190195 }
191196
192−fn hovered(status: button::Status) -> bool {
193− matches!(status, button::Status::Hovered | button::Status::Pressed)
194−}
195−
196197 /// An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the
197198 /// right end, with a dot in it. It only shows the state: the row around it takes the click.
198199 pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
199− let p = look.p;
200− let knob = container(on.then(|| dot(p.fill_fg, 10.0)))
201− .width(if on { Length::Fill } else { Length::Fixed(26.0) })
202− .height(26)
203− .align_x(Horizontal::Right)
204− .align_y(Alignment::Center)
205− .padding([0, 8])
206− .style(move |_| surface(if on { p.fill } else { p.mute }, ROUND));
207− container(knob)
208− .width(64)
209− .height(34)
210− .padding(4)
211− .style(move |_| surface(p.s2, ROUND))
212− .into()
200+ dots::switch(look, on)
213201 }
214202
215203 /// A chevron: the row opens a page.
@@ -245,27 +233,19 @@ pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool, focuse
245233 .on_press(segment.on_press)
246234 .width(Length::Fill)
247235 .padding(if large { [14, 8] } else { [10, 12] })
248− .style(move |_, status| button::Style {
249− background: if selected {
250− Some(p.fill.into())
251− } else {
252− hovered(status).then_some(p.s3.into())
253− },
254− text_color: color,
255− border: Border::default().rounded(ROUND),
256− ..button::Style::default()
257− });
258− choices = choices.push(choice);
236+ .style(bare(color));
237+ // The chosen cell's fill fades out as the next one's fades in.
238+ let rest = Paint::fill(if selected { p.fill } else { Color::TRANSPARENT });
239+ let hover = Paint::fill(if selected { p.fill } else { p.s3 });
240+ choices = choices.push(motion::eased(choice, rest, hover, ROUND, true, look.motion));
259241 }
260− container(choices)
261− .padding(6)
262− .width(Length::Fill)
263− .style(move |_| container::Style {
264− background: Some(p.s2.into()),
265− border: cursor(p, focused, ROUND),
266− ..container::Style::default()
267− })
268− .into()
242+ let track = Paint::fill(p.s2).framed(p.fill, focused);
243+ motion::surface(
244+ container(choices).padding(6).width(Length::Fill),
245+ track,
246+ ROUND,
247+ look.motion,
248+ )
269249 }
270250
271251 /// A page in the sidebar: a dot-matrix word, large with the live dot when open.
@@ -282,17 +262,13 @@ pub fn nav_item<'a>(look: Look, word: &str, open: bool, on_press: Message) -> El
282262 .align_y(Alignment::Center)
283263 .into()
284264 };
285− button(content)
265+ let item = button(content)
286266 .on_press(on_press)
287267 .width(Length::Fill)
288268 .padding([6, 10])
289− .style(move |_, status| button::Style {
290− background: (!open && hovered(status)).then_some(p.s1.into()),
291− text_color: p.fg,
292− border: Border::default().rounded(CARD),
293− ..button::Style::default()
294− })
295− .into()
269+ .style(bare(p.fg));
270+ let hover = if open { Paint::NONE } else { Paint::fill(p.s1) };
271+ motion::eased(item, Paint::NONE, hover, CARD, true, look.motion)
296272 }
297273
298274 /// A chip: a caps label in a pill. `filled` with the fill color, for the main action, or on a
@@ -305,51 +281,36 @@ pub fn pill<'a>(look: Look, words: &str, filled: bool, on_press: Message) -> Ele
305281 pub fn pill_with<'a>(look: Look, words: &str, filled: bool, focused: bool, on_press: Message) -> Element<'a, Message> {
306282 let p = look.p;
307283 let color = if filled { p.fill_fg } else { p.fg };
308− button(label(look, words, 11.0, color))
284+ let pill = button(label(look, words, 11.0, color))
309285 .on_press(on_press)
310286 .padding([10, 18])
311− .style(move |_, status| button::Style {
312− background: Some(match (filled, hovered(status)) {
313− (true, _) => p.fill.into(),
314− (false, true) => p.mute.into(),
315− (false, false) => p.s3.into(),
316− }),
317− text_color: color,
318− border: cursor(p, focused, ROUND),
319− ..button::Style::default()
320− })
321− .into()
287+ .style(bare(color));
288+ let rest = Paint::fill(if filled { p.fill } else { p.s3 }).framed(p.fill, focused);
289+ let hover = Paint::fill(if filled { p.fill } else { p.mute }).framed(p.fill, focused);
290+ motion::eased(pill, rest, hover, ROUND, true, look.motion)
322291 }
323292
324293 /// A round button with just an icon, like the pencil on a preset's row.
325294 pub fn icon_button<'a>(look: Look, icon: Icon, focused: bool, on_press: Message) -> Element<'a, Message> {
326295 let p = look.p;
327− button(container(icons::icon(icon, 18.0, p.fg)).center(40))
296+ let round = button(container(icons::icon(icon, 18.0, p.fg)).center(40))
328297 .on_press(on_press)
329298 .padding(0)
330− .style(move |_, status| button::Style {
331− background: Some(if focused || hovered(status) { p.mute } else { p.s2 }.into()),
332− text_color: p.fg,
333− border: cursor(p, focused, ROUND),
334− ..button::Style::default()
335− })
336− .into()
299+ .style(bare(p.fg));
300+ let rest = Paint::fill(if focused { p.mute } else { p.s2 }).framed(p.fill, focused);
301+ let hover = Paint::fill(p.mute).framed(p.fill, focused);
302+ motion::eased(round, rest, hover, ROUND, true, look.motion)
337303 }
338304
339305 /// A wide chip, like "Refresh".
340306 pub fn wide_button<'a>(look: Look, words: &str, on_press: Message) -> Element<'a, Message> {
341307 let p = look.p;
342− button(container(label(look, words, 11.0, p.fg)).center_x(Length::Fill))
308+ let wide = button(container(label(look, words, 11.0, p.fg)).center_x(Length::Fill))
343309 .on_press(on_press)
344310 .width(Length::Fill)
345311 .padding([11, 12])
346− .style(move |_, status| button::Style {
347− background: Some(if hovered(status) { p.mute } else { p.s3 }.into()),
348− text_color: p.fg,
349− border: Border::default().rounded(ROUND),
350− ..button::Style::default()
351− })
352− .into()
312+ .style(bare(p.fg));
313+ motion::eased(wide, Paint::fill(p.s3), Paint::fill(p.mute), ROUND, true, look.motion)
353314 }
354315
355316 /// A filled circle, like the "connected" light or the live dot.