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
+1410 −262
13 files changed
- M CHANGELOG.md +15 −0
- M CLAUDE.md +1 −0
- M README.md +3 −1
- M crates/budctl-core/src/config.rs +6 −0
- M crates/budctl-gui/Cargo.toml +1 −1
- M crates/budctl-gui/src/dots.rs +392 −15
- M crates/budctl-gui/src/earbuds.rs +15 −3
- M crates/budctl-gui/src/equalizer.rs +5 −3
- M crates/budctl-gui/src/main.rs +403 −138
- A crates/budctl-gui/src/motion.rs +446 −0
- M crates/budctl-gui/src/overview.rs +1 −0
- M crates/budctl-gui/src/theme.rs +60 −0
- M crates/budctl-gui/src/ui.rs +62 −101
| @@ -1,5 +1,20 @@ | |||
| 1 | 1 | # Changelog | |
| 2 | 2 | ||
| 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 | + | ||
| 3 | 18 | ## 0.1.2 (2026-10-29) | |
| 4 | 19 | ||
| 5 | 20 | A new look for the desktop app, after Nothing OS in Material You colors, and a window that no | |
| @@ -125,6 +125,7 @@ Exit codes: | |||
| 125 | 125 | - **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. | |
| 126 | 126 | - **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. | |
| 127 | 127 | - **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. | |
| 128 | 129 | - **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. | |
| 129 | 130 | - **Screenshots:** the README's come from the demo buds: `cargo test -p budctl-gui readme_screenshots -- --ignored` renders them again into `docs/screenshots/`. | |
| 130 | 131 | ||
| @@ -149,7 +149,8 @@ is optional; a file with a mistake is reported and the defaults are used. | |||
| 149 | 149 | "device": null, | |
| 150 | 150 | "gui": { | |
| 151 | 151 | "idle_seconds": 10, | |
| 152 | − | "start_page": "overview" | |
| 152 | + | "start_page": "overview", | |
| 153 | + | "motion": true | |
| 153 | 154 | } | |
| 154 | 155 | } | |
| 155 | 156 | ``` | |
| @@ -158,6 +159,7 @@ is optional; a file with a mistake is reported and the defaults are used. | |||
| 158 | 159 | - `gui.idle_seconds`: how long the app keeps the control channel open after the last change | |
| 159 | 160 | (1–600). | |
| 160 | 161 | - `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. | |
| 161 | 163 | ||
| 162 | 164 | ## How it works | |
| 163 | 165 | ||
| @@ -30,6 +30,9 @@ pub struct Gui { | |||
| 30 | 30 | pub idle_seconds: u64, | |
| 31 | 31 | /// The page the window opens on: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`. | |
| 32 | 32 | 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, | |
| 33 | 36 | } | |
| 34 | 37 | ||
| 35 | 38 | impl Default for Gui { | |
| @@ -37,6 +40,7 @@ impl Default for Gui { | |||
| 37 | 40 | Gui { | |
| 38 | 41 | idle_seconds: 10, | |
| 39 | 42 | start_page: "overview".to_owned(), | |
| 43 | + | motion: true, | |
| 40 | 44 | } | |
| 41 | 45 | } | |
| 42 | 46 | } | |
| @@ -148,6 +152,8 @@ mod tests { | |||
| 148 | 152 | assert_eq!(config.device().unwrap(), Some("AA:BB:CC:DD:EE:FF".parse().unwrap())); | |
| 149 | 153 | assert_eq!(config.gui.idle_seconds, 30); | |
| 150 | 154 | 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); | |
| 151 | 157 | } | |
| 152 | 158 | ||
| 153 | 159 | #[test] | |
| @@ -14,7 +14,7 @@ path = "src/main.rs" | |||
| 14 | 14 | [dependencies] | |
| 15 | 15 | budctl-core = { path = "../budctl-core" } | |
| 16 | 16 | # 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"] } | |
| 18 | 18 | serde = { version = "1.0.229", features = ["derive"] } | |
| 19 | 19 | serde_json = "1.0.154" | |
| 20 | 20 | ||
| @@ -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. | |
| 3 | 7 | ||
| 4 | 8 | use std::f32::consts::{FRAC_PI_2, TAU}; | |
| 5 | 9 | ||
| 6 | 10 | use iced::alignment::Vertical; | |
| 11 | + | use iced::time::{Duration, Instant}; | |
| 7 | 12 | use iced::widget::canvas::{self, Event, Frame, Path}; | |
| 8 | 13 | 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}; | |
| 10 | 15 | ||
| 11 | 16 | use crate::Message; | |
| 12 | 17 | use crate::kept::Kept; | |
| 18 | + | use crate::motion::{self, Mix, Tween}; | |
| 13 | 19 | use crate::theme::Look; | |
| 14 | 20 | ||
| 15 | 21 | /// A battery ring, `size` pixels across: a ring of dots, lit clockwise from the top up to the | |
| 16 | 22 | /// `level` (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it | |
| 17 | 23 | /// 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. | |
| 18 | 27 | pub fn ring<'a>(look: Look, level: Option<u8>, size: f32) -> Element<'a, Message> { | |
| 19 | 28 | let p = look.p; | |
| 20 | 29 | let ring = Ring { | |
| @@ -23,6 +32,7 @@ pub fn ring<'a>(look: Look, level: Option<u8>, size: f32) -> Element<'a, Message | |||
| 23 | 32 | off: p.s3, | |
| 24 | 33 | figure: if level.is_some() { p.fg } else { p.mute }, | |
| 25 | 34 | font: look.dot(), | |
| 35 | + | motion: look.motion, | |
| 26 | 36 | }; | |
| 27 | 37 | iced::widget::canvas(ring).width(size).height(size).into() | |
| 28 | 38 | } | |
| @@ -34,25 +44,71 @@ struct Ring { | |||
| 34 | 44 | off: Color, | |
| 35 | 45 | figure: Color, | |
| 36 | 46 | 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 | + | } | |
| 37 | 78 | } | |
| 38 | 79 | ||
| 39 | 80 | 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 | + | } | |
| 41 | 97 | ||
| 42 | 98 | fn draw( | |
| 43 | 99 | &self, | |
| 44 | − | kept: &Kept<Ring>, | |
| 100 | + | state: &Sweep, | |
| 45 | 101 | renderer: &Renderer, | |
| 46 | 102 | _theme: &Theme, | |
| 47 | 103 | bounds: Rectangle, | |
| 48 | 104 | _cursor: mouse::Cursor, | |
| 49 | 105 | ) -> 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| { | |
| 52 | 109 | let center = frame.center(); | |
| 53 | 110 | let dot = (bounds.width * 0.045).max(3.0); | |
| 54 | 111 | 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)); | |
| 56 | 112 | for n in 0..N { | |
| 57 | 113 | let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2; | |
| 58 | 114 | let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin()); | |
| @@ -100,6 +156,7 @@ pub struct Slider<F> { | |||
| 100 | 156 | pub release: Message, | |
| 101 | 157 | pub on: Color, | |
| 102 | 158 | pub off: Color, | |
| 159 | + | pub motion: bool, | |
| 103 | 160 | } | |
| 104 | 161 | ||
| 105 | 162 | impl<F: Fn(i16, bool) -> Message + 'static> Slider<F> { | |
| @@ -149,13 +206,30 @@ impl<F> Slider<F> { | |||
| 149 | 206 | const DOT_BIG: f32 = 11.0; | |
| 150 | 207 | const DOT: f32 = 5.0; | |
| 151 | 208 | ||
| 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); | |
| 154 | 212 | ||
| 155 | 213 | #[derive(Default)] | |
| 156 | 214 | pub struct Drag { | |
| 157 | 215 | held: bool, | |
| 158 | 216 | 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 | + | } | |
| 159 | 233 | } | |
| 160 | 234 | ||
| 161 | 235 | 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> { | |||
| 168 | 242 | bounds: Rectangle, | |
| 169 | 243 | cursor: mouse::Cursor, | |
| 170 | 244 | ) -> Option<canvas::Action<Message>> { | |
| 245 | + | if let Some(now) = motion::redraw(event) { | |
| 246 | + | return self.follow(state, now); | |
| 247 | + | } | |
| 171 | 248 | let Event::Mouse(event) = event else { | |
| 172 | 249 | return None; | |
| 173 | 250 | }; | |
| @@ -212,11 +289,22 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> { | |||
| 212 | 289 | bounds: Rectangle, | |
| 213 | 290 | _cursor: mouse::Cursor, | |
| 214 | 291 | ) -> 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 | + | ); | |
| 216 | 304 | vec![ | |
| 217 | 305 | state | |
| 218 | 306 | .kept | |
| 219 | − | .draw(renderer, bounds.size(), shows, |frame| self.dots(frame)), | |
| 307 | + | .draw(renderer, bounds.size(), shows, |frame| self.dots(frame, left, grown)), | |
| 220 | 308 | ] | |
| 221 | 309 | } | |
| 222 | 310 | ||
| @@ -230,8 +318,28 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> { | |||
| 230 | 318 | } | |
| 231 | 319 | ||
| 232 | 320 | 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) { | |
| 235 | 343 | let size = frame.size(); | |
| 236 | 344 | let at = |along: f32| { | |
| 237 | 345 | if self.vertical { | |
| @@ -249,7 +357,9 @@ impl<F> Slider<F> { | |||
| 249 | 357 | while value <= self.high { | |
| 250 | 358 | let lit = (from..=to).contains(&value); | |
| 251 | 359 | 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 }) | |
| 253 | 363 | } else if lit { | |
| 254 | 364 | (DOT, self.on) | |
| 255 | 365 | } else { | |
| @@ -264,6 +374,262 @@ impl<F> Slider<F> { | |||
| 264 | 374 | } | |
| 265 | 375 | } | |
| 266 | 376 | ||
| 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 | + | ||
| 267 | 633 | #[cfg(test)] | |
| 268 | 634 | mod tests { | |
| 269 | 635 | use super::*; | |
| @@ -278,9 +644,20 @@ mod tests { | |||
| 278 | 644 | release: Message::SaveBass, | |
| 279 | 645 | on: Color::WHITE, | |
| 280 | 646 | off: Color::BLACK, | |
| 647 | + | motion: false, | |
| 281 | 648 | } | |
| 282 | 649 | } | |
| 283 | 650 | ||
| 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 | + | ||
| 284 | 661 | #[test] | |
| 285 | 662 | fn rings_light_their_share() { | |
| 286 | 663 | assert_eq!(lit_count(0.8, 44), 35); | |
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | //! The Earbud settings page: dual connection and its devices, find the buds, and what they run. | |
| 2 | 2 | ||
| 3 | 3 | 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}; | |
| 6 | 6 | ||
| 7 | 7 | use crate::buds::Buds; | |
| 8 | + | use crate::dots; | |
| 8 | 9 | use crate::icons::Icon; | |
| 9 | 10 | use crate::theme::Look; | |
| 10 | 11 | use crate::ui; | |
| @@ -54,7 +55,18 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem | |||
| 54 | 55 | Icon::Find, | |
| 55 | 56 | "Find my earbuds", | |
| 56 | 57 | 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(), | |
| 58 | 70 | None, | |
| 59 | 71 | marks.focus == Some(Item::Find), | |
| 60 | 72 | ); | |
| @@ -48,10 +48,11 @@ pub fn edited(buds: &Buds, editing: Option<u8>) -> Option<&EqPreset> { | |||
| 48 | 48 | buds.preset(editing?).filter(|preset| preset.custom) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 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. | |
| 53 | 53 | pub fn view<'a>( | |
| 54 | 54 | look: Look, | |
| 55 | + | opened: Look, | |
| 55 | 56 | buds: &'a Buds, | |
| 56 | 57 | editing: Option<u8>, | |
| 57 | 58 | draft: Option<&'a Draft>, | |
| @@ -113,7 +114,7 @@ pub fn view<'a>( | |||
| 113 | 114 | }) | |
| 114 | 115 | .collect(); | |
| 115 | 116 | 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)), | |
| 117 | 118 | None if eq.presets.iter().any(|p| p.custom) => Some( | |
| 118 | 119 | container(ui::note( | |
| 119 | 120 | look, | |
| @@ -206,6 +207,7 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: | |||
| 206 | 207 | release: Message::SaveBands(id), | |
| 207 | 208 | on: p.fill, | |
| 208 | 209 | off: p.s3, | |
| 210 | + | motion: look.motion, | |
| 209 | 211 | }; | |
| 210 | 212 | let band = column![ | |
| 211 | 213 | ui::num(look, &gain(band.gain), 28.0, p.fg), | |
| @@ -13,6 +13,7 @@ mod icons; | |||
| 13 | 13 | mod kept; | |
| 14 | 14 | mod keys; | |
| 15 | 15 | mod log; | |
| 16 | + | mod motion; | |
| 16 | 17 | mod overview; | |
| 17 | 18 | mod settings; | |
| 18 | 19 | mod theme; | |
| @@ -87,6 +88,7 @@ fn main() -> ExitCode { | |||
| 87 | 88 | Page::Overview | |
| 88 | 89 | }); | |
| 89 | 90 | let idle = Duration::from_secs(config.gui.idle_seconds); | |
| 91 | + | let motion = config.gui.motion; | |
| 90 | 92 | let device = options.device.or_else(|| config.device().unwrap_or_default()); | |
| 91 | 93 | if options.theme.is_none() { | |
| 92 | 94 | write_built_in_theme(); | |
| @@ -96,6 +98,7 @@ fn main() -> ExitCode { | |||
| 96 | 98 | let mut app = App::new(theme_file.clone(), device, options.demo); | |
| 97 | 99 | app.page = start_page; | |
| 98 | 100 | app.idle = idle; | |
| 101 | + | app.look.motion = motion; | |
| 99 | 102 | app | |
| 100 | 103 | }; | |
| 101 | 104 | let mut application = iced::application(boot, App::update, App::view); | |
| @@ -295,6 +298,41 @@ enum NameTarget { | |||
| 295 | 298 | Rename(u8), | |
| 296 | 299 | } | |
| 297 | 300 | ||
| 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 | + | ||
| 298 | 336 | /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the | |
| 299 | 337 | /// items with a write on its way. | |
| 300 | 338 | #[derive(Clone, Copy)] | |
| @@ -396,6 +434,10 @@ enum Message { | |||
| 396 | 434 | /// A mouse button went down: the mouse is in use, so the keyboard cursor hides. | |
| 397 | 435 | Pointer, | |
| 398 | 436 | 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, | |
| 399 | 441 | } | |
| 400 | 442 | ||
| 401 | 443 | struct App { | |
| @@ -445,6 +487,18 @@ struct App { | |||
| 445 | 487 | editing: Option<u8>, | |
| 446 | 488 | /// "Turn dual connection off?" is open. | |
| 447 | 489 | 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>, | |
| 448 | 502 | } | |
| 449 | 503 | ||
| 450 | 504 | impl App { | |
| @@ -461,7 +515,10 @@ impl App { | |||
| 461 | 515 | page: Page::Overview, | |
| 462 | 516 | buds, | |
| 463 | 517 | anc_level, | |
| 464 | − | look: Look { p: theme::BUILT_IN }, | |
| 518 | + | look: Look { | |
| 519 | + | p: theme::BUILT_IN, | |
| 520 | + | motion: false, | |
| 521 | + | }, | |
| 465 | 522 | theme: theme::iced_theme(&theme::BUILT_IN), | |
| 466 | 523 | palette: theme::Source::new(theme_file), | |
| 467 | 524 | keys: keys::Keys::default(), | |
| @@ -484,12 +541,26 @@ impl App { | |||
| 484 | 541 | confirm_delete: None, | |
| 485 | 542 | editing: None, | |
| 486 | 543 | 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, | |
| 487 | 550 | }; | |
| 488 | 551 | app.refresh_palette(); | |
| 489 | 552 | app | |
| 490 | 553 | } | |
| 491 | 554 | ||
| 555 | + | /// Acts on `message`, then starts the fades for what it changed. | |
| 492 | 556 | 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> { | |
| 493 | 564 | match message { | |
| 494 | 565 | Message::Open(page) => return self.open(page), | |
| 495 | 566 | Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)), | |
| @@ -579,10 +650,113 @@ impl App { | |||
| 579 | 650 | Message::Key(key, modifiers) => return self.key(&key, modifiers), | |
| 580 | 651 | Message::Pointer => self.cursor_shown = false, | |
| 581 | 652 | Message::CloseHelp => self.help = false, | |
| 653 | + | Message::Frame(now) => self.frame(now), | |
| 654 | + | Message::Noop => {} | |
| 582 | 655 | } | |
| 583 | 656 | Task::none() | |
| 584 | 657 | } | |
| 585 | 658 | ||
| 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 | + | ||
| 586 | 760 | /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds | |
| 587 | 761 | /// answer, the item shows that it is saving and takes no other write. | |
| 588 | 762 | fn write(&mut self, item: Item, command: Command) { | |
| @@ -1273,16 +1447,22 @@ impl App { | |||
| 1273 | 1447 | } | |
| 1274 | 1448 | ||
| 1275 | 1449 | 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)); | |
| 1279 | 1453 | } | |
| 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) | |
| 1282 | 1459 | } | |
| 1283 | 1460 | ||
| 1284 | 1461 | 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)); | |
| 1286 | 1466 | let pending = self.pending(); | |
| 1287 | 1467 | let page = match self.page { | |
| 1288 | 1468 | Page::Overview if self.has_reading() => { | |
| @@ -1292,13 +1472,14 @@ impl App { | |||
| 1292 | 1472 | }; | |
| 1293 | 1473 | overview::view(look, &self.buds, self.anc_level, self.bass_draft, marks) | |
| 1294 | 1474 | } | |
| 1295 | − | Page::Overview => self.connecting(), | |
| 1475 | + | Page::Overview => self.connecting(look), | |
| 1296 | 1476 | Page::Equalizer if self.has_reading() => { | |
| 1297 | 1477 | let marks = Marks { | |
| 1298 | 1478 | focus: self.focus(), | |
| 1299 | 1479 | pending: &pending, | |
| 1300 | 1480 | }; | |
| 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) | |
| 1302 | 1483 | } | |
| 1303 | 1484 | Page::Gestures if self.has_reading() => { | |
| 1304 | 1485 | let marks = Marks { | |
| @@ -1328,8 +1509,9 @@ impl App { | |||
| 1328 | 1509 | .spacing(16) | |
| 1329 | 1510 | .into(), | |
| 1330 | 1511 | }; | |
| 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; | |
| 1333 | 1515 | let content = scrollable( | |
| 1334 | 1516 | container(container(page).max_width(860)) | |
| 1335 | 1517 | .center_x(Length::Fill) | |
| @@ -1354,24 +1536,22 @@ impl App { | |||
| 1354 | 1536 | background: Some(p.bg.into()), | |
| 1355 | 1537 | ..container::Style::default() | |
| 1356 | 1538 | }); | |
| 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() | |
| 1367 | 1548 | } else { | |
| 1368 | 1549 | window.into() | |
| 1369 | 1550 | } | |
| 1370 | 1551 | } | |
| 1371 | 1552 | ||
| 1372 | 1553 | /// 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> { | |
| 1375 | 1555 | let p = look.p; | |
| 1376 | 1556 | let (headline, detail) = match &self.link { | |
| 1377 | 1557 | Link::Failed { | |
| @@ -1393,7 +1573,7 @@ impl App { | |||
| 1393 | 1573 | let state = if failed { | |
| 1394 | 1574 | row![ui::dot(p.err, 10.0), ui::label(look, "no link", 11.0, p.err)] | |
| 1395 | 1575 | } 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)] | |
| 1397 | 1577 | }; | |
| 1398 | 1578 | let words = column![ | |
| 1399 | 1579 | state.spacing(10).align_y(Alignment::Center), | |
| @@ -1409,14 +1589,18 @@ impl App { | |||
| 1409 | 1589 | .into() | |
| 1410 | 1590 | } | |
| 1411 | 1591 | ||
| 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 | + | ||
| 1412 | 1597 | /// 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>> { | |
| 1414 | 1599 | let Link::Failed { message, .. } = &self.link else { | |
| 1415 | 1600 | return None; | |
| 1416 | 1601 | }; | |
| 1417 | 1602 | // Before the first reading, the Overview says it already. | |
| 1418 | 1603 | self.read_at?; | |
| 1419 | − | let look = self.look; | |
| 1420 | 1604 | let p = look.p; | |
| 1421 | 1605 | let content = row![ | |
| 1422 | 1606 | ui::dot(p.err, 10.0), | |
| @@ -1451,7 +1635,12 @@ impl App { | |||
| 1451 | 1635 | let device = column![ | |
| 1452 | 1636 | text(self.buds.name.as_str()).size(18).font(look.semibold()).color(p.fg), | |
| 1453 | 1637 | 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 | + | }, | |
| 1455 | 1644 | ui::label(look, state, 11.0, p.grey), | |
| 1456 | 1645 | space::horizontal() | |
| 1457 | 1646 | ] | |
| @@ -1462,7 +1651,15 @@ impl App { | |||
| 1462 | 1651 | ] | |
| 1463 | 1652 | .spacing(14) | |
| 1464 | 1653 | .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))); | |
| 1465 | 1658 | 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 | + | }; | |
| 1466 | 1663 | pages.push(ui::nav_item(look, page.word(), page == self.page, Message::Open(page))) | |
| 1467 | 1664 | }); | |
| 1468 | 1665 | let hint = container(ui::label(look, "? keys · q quit", 10.5, p.grey)).padding([0, 10]); | |
| @@ -1472,128 +1669,122 @@ impl App { | |||
| 1472 | 1669 | .height(Length::Fill) | |
| 1473 | 1670 | .into() | |
| 1474 | 1671 | } | |
| 1672 | + | } | |
| 1475 | 1673 | ||
| 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, | |
| 1499 | 1681 | "Ring your buds?".to_owned(), | |
| 1500 | 1682 | "Take them out of your ears first: the sound is loud. They ring until you press Stop.", | |
| 1501 | 1683 | Message::ConfirmFind, | |
| 1502 | 1684 | "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, | |
| 1509 | 1702 | "Turn dual connection off?".to_owned(), | |
| 1510 | 1703 | "If two devices are connected to your buds, they may drop one of them.", | |
| 1511 | 1704 | Message::ConfirmDual, | |
| 1512 | 1705 | "Turn off", | |
| 1513 | − | ) | |
| 1706 | + | ), | |
| 1514 | 1707 | } | |
| 1708 | + | } | |
| 1515 | 1709 | ||
| 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 | + | } | |
| 1529 | 1728 | ||
| 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 | + | } | |
| 1575 | 1766 | ||
| 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() | |
| 1597 | 1788 | } | |
| 1598 | 1789 | ||
| 1599 | 1790 | /// What a window event means to the window: focus, a key, a click. | |
| @@ -1971,7 +2162,7 @@ mod tests { | |||
| 1971 | 2162 | message: "control channel busy".to_owned(), | |
| 1972 | 2163 | })); | |
| 1973 | 2164 | 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"); | |
| 1975 | 2166 | ||
| 1976 | 2167 | let _ = commands.try_recv(); | |
| 1977 | 2168 | let _ = app.update(Message::Refresh); | |
| @@ -1987,7 +2178,7 @@ mod tests { | |||
| 1987 | 2178 | assert!(app.pending().is_empty()); | |
| 1988 | 2179 | assert!(switch(&app, "dual")); | |
| 1989 | 2180 | assert!(!app.buds.connected); | |
| 1990 | − | assert!(app.problem().is_some()); | |
| 2181 | + | assert!(app.problem_shown()); | |
| 1991 | 2182 | } | |
| 1992 | 2183 | ||
| 1993 | 2184 | #[test] | |
| @@ -2415,6 +2606,80 @@ mod tests { | |||
| 2415 | 2606 | render(&dir, shots); | |
| 2416 | 2607 | } | |
| 2417 | 2608 | ||
| 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 | + | ||
| 2418 | 2683 | /// The README's screenshots: the demo buds in the built-in palette, written to | |
| 2419 | 2684 | /// `docs/screenshots/` by `cargo test -p budctl-gui readme_screenshots -- --ignored`. | |
| 2420 | 2685 | #[test] | |
| @@ -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 | + | } | |
| @@ -202,6 +202,7 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M | |||
| 202 | 202 | release: Message::SaveBass, | |
| 203 | 203 | on: p.fill, | |
| 204 | 204 | off: p.s3, | |
| 205 | + | motion: look.motion, | |
| 205 | 206 | }; | |
| 206 | 207 | let control = row![container(ui::value(look, &shown)).align_right(76), slider.view(200.0)] | |
| 207 | 208 | .spacing(14) | |
| @@ -216,9 +216,48 @@ const DOT: Font = Font { | |||
| 216 | 216 | #[derive(Debug, Clone, Copy)] | |
| 217 | 217 | pub struct Look { | |
| 218 | 218 | pub p: Palette, | |
| 219 | + | /// Whether things move: off, every change shows at once (`gui.motion`, and the tests). | |
| 220 | + | pub motion: bool, | |
| 219 | 221 | } | |
| 220 | 222 | ||
| 221 | 223 | 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 | + | ||
| 222 | 261 | pub fn medium(self) -> Font { | |
| 223 | 262 | Font { | |
| 224 | 263 | weight: Weight::Medium, | |
| @@ -261,6 +300,27 @@ pub fn iced_theme(p: &Palette) -> iced::Theme { | |||
| 261 | 300 | mod tests { | |
| 262 | 301 | use super::*; | |
| 263 | 302 | ||
| 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 | + | ||
| 264 | 324 | #[test] | |
| 265 | 325 | fn reads_the_aurora_keys() { | |
| 266 | 326 | let palette = parse( | |
| @@ -1,14 +1,18 @@ | |||
| 1 | 1 | //! The building blocks every page uses, in Nothing OS's shapes: caps labels, dot-matrix words, | |
| 2 | 2 | //! round cards, rows with their icon in a circle, pill switches, pill tracks and chips. | |
| 3 | 3 | //! 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. | |
| 4 | 7 | ||
| 5 | − | use iced::alignment::Horizontal; | |
| 6 | 8 | 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}; | |
| 8 | 10 | ||
| 9 | 11 | use crate::Message; | |
| 12 | + | use crate::dots; | |
| 10 | 13 | use crate::icons::{self, Icon}; | |
| 11 | − | use crate::theme::{Look, Palette}; | |
| 14 | + | use crate::motion::{self, Paint}; | |
| 15 | + | use crate::theme::Look; | |
| 12 | 16 | ||
| 13 | 17 | /// Round corners: cards, and the rows in them. | |
| 14 | 18 | const CARD: f32 = 28.0; | |
| @@ -102,7 +106,8 @@ pub fn scrim<'a>(look: Look, content: Element<'a, Message>, width: f32) -> Eleme | |||
| 102 | 106 | container(container(content).width(width)) | |
| 103 | 107 | .center(Length::Fill) | |
| 104 | 108 | .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()), | |
| 106 | 111 | ..container::Style::default() | |
| 107 | 112 | }) | |
| 108 | 113 | .into() | |
| @@ -154,62 +159,45 @@ pub fn list_row<'a>( | |||
| 154 | 159 | p.s2 | |
| 155 | 160 | }; | |
| 156 | 161 | 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]); | |
| 161 | 170 | 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) | |
| 163 | 175 | .on_press_maybe(on_press) | |
| 164 | 176 | .padding(0) | |
| 165 | 177 | .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) | |
| 173 | 180 | } | |
| 174 | 181 | ||
| 175 | 182 | /// `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) | |
| 181 | 185 | } | |
| 182 | 186 | ||
| 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() | |
| 189 | 194 | } | |
| 190 | 195 | } | |
| 191 | 196 | ||
| 192 | − | fn hovered(status: button::Status) -> bool { | |
| 193 | − | matches!(status, button::Status::Hovered | button::Status::Pressed) | |
| 194 | − | } | |
| 195 | − | ||
| 196 | 197 | /// An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the | |
| 197 | 198 | /// right end, with a dot in it. It only shows the state: the row around it takes the click. | |
| 198 | 199 | 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) | |
| 213 | 201 | } | |
| 214 | 202 | ||
| 215 | 203 | /// A chevron: the row opens a page. | |
| @@ -245,27 +233,19 @@ pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool, focuse | |||
| 245 | 233 | .on_press(segment.on_press) | |
| 246 | 234 | .width(Length::Fill) | |
| 247 | 235 | .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)); | |
| 259 | 241 | } | |
| 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 | + | ) | |
| 269 | 249 | } | |
| 270 | 250 | ||
| 271 | 251 | /// 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 | |||
| 282 | 262 | .align_y(Alignment::Center) | |
| 283 | 263 | .into() | |
| 284 | 264 | }; | |
| 285 | − | button(content) | |
| 265 | + | let item = button(content) | |
| 286 | 266 | .on_press(on_press) | |
| 287 | 267 | .width(Length::Fill) | |
| 288 | 268 | .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) | |
| 296 | 272 | } | |
| 297 | 273 | ||
| 298 | 274 | /// 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 | |||
| 305 | 281 | pub fn pill_with<'a>(look: Look, words: &str, filled: bool, focused: bool, on_press: Message) -> Element<'a, Message> { | |
| 306 | 282 | let p = look.p; | |
| 307 | 283 | 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)) | |
| 309 | 285 | .on_press(on_press) | |
| 310 | 286 | .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) | |
| 322 | 291 | } | |
| 323 | 292 | ||
| 324 | 293 | /// A round button with just an icon, like the pencil on a preset's row. | |
| 325 | 294 | pub fn icon_button<'a>(look: Look, icon: Icon, focused: bool, on_press: Message) -> Element<'a, Message> { | |
| 326 | 295 | 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)) | |
| 328 | 297 | .on_press(on_press) | |
| 329 | 298 | .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) | |
| 337 | 303 | } | |
| 338 | 304 | ||
| 339 | 305 | /// A wide chip, like "Refresh". | |
| 340 | 306 | pub fn wide_button<'a>(look: Look, words: &str, on_press: Message) -> Element<'a, Message> { | |
| 341 | 307 | 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)) | |
| 343 | 309 | .on_press(on_press) | |
| 344 | 310 | .width(Length::Fill) | |
| 345 | 311 | .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) | |
| 353 | 314 | } | |
| 354 | 315 | ||
| 355 | 316 | /// A filled circle, like the "connected" light or the live dot. | |