budctl93bfdf6
budctl-gui: no more lag, and the keys that need Shift work
The window lagged on every hover, click and scroll. The tiny-skia renderer repaints damaged patches one by one, and it took every canvas drawn afresh as changed on every frame, shape by shape: one hovered row repainted the Overview in 20-36 patches, a scroll step in 50-120. Each patch resets a window-sized clip mask per layer and per canvas, and repaints whole cards through it. - kept.rs: canvases keep their drawing between frames (a canvas::Cache keyed on what they show), so icons, battery rings and sliders only count as changed when they change. Used by icons.rs and dots.rs. - The battery figure is drawn inside the ring's canvas, so the three gauges no longer need a stack layer each. - ui::layer: cards and panels sit on clipped layers of their own. A layer that moves is damaged as one patch, so a scroll paints each card once. - Debug builds optimize the dependencies ([profile.dev.package."*"]): unoptimized, tiny-skia draws about 20 times slower. The GUI tests now run in 2 s instead of 29. Measured headless through iced's own damage and raster path, release build, 1040x800, Overview: hover 25 -> 2 ms, scroll step ~50 (worst 109) -> at most 12 ms (1920x1050: worst 226 -> 23 ms), list of keys 35 -> 0.1 ms. The pages render the same as before, pixel for pixel, but for a clipped half-dot the old battery ring showed. Keys: iced 0.14 hands the subscription `key` with Shift taken off, so ?, G, J, K and gT arrived as /, g, j, k and gt. The window now takes `modified_key`, the key as typed (on_event, with a test that feeds it keys the way iced does). No new dependencies. CHANGELOG and CLAUDE.md (GUI, Speed) updated.
+235 −70
9 files changed
- M CHANGELOG.md +8 −0
- M CLAUDE.md +1 −0
- M Cargo.toml +5 −0
- M crates/budctl-gui/src/dots.rs +77 −36
- M crates/budctl-gui/src/icons.rs +10 −6
- A crates/budctl-gui/src/kept.rs +62 −0
- M crates/budctl-gui/src/main.rs +46 −8
- M crates/budctl-gui/src/overview.rs +2 −9
- M crates/budctl-gui/src/ui.rs +24 −11
| @@ -10,6 +10,14 @@ | |||
| 10 | 10 | - The palette file takes Aurora's Material 3 keys (`primary`, `tertiary`, `surfaceLow`, | |
| 11 | 11 | `outline`…), so `~/.cache/matugen/aurora.json` works as it is. `bg` and `fg` are still read; | |
| 12 | 12 | `surface` and `secondary` are no longer used. | |
| 13 | + | - `budctl-gui` no longer lags. It repainted most of the window many times over for each hover, | |
| 14 | + | click or scroll step; now it repaints only what changed, and a scroll paints each card once. | |
| 15 | + | On the Overview, a hover went from about 25 ms of drawing to 2, a scroll step from about 50 | |
| 16 | + | to at most 12, and the list of keys from 35 to almost nothing. | |
| 17 | + | - The keys that need Shift work in `budctl-gui`: `?` (the list of keys), `G`, `J`, `K` and `gT`. | |
| 18 | + | The window used to see them without Shift (`/`, `g`, `j`, `k`, `gt`). | |
| 19 | + | - Built from source, debug builds (`cargo run`) optimize the dependencies, so the desktop app | |
| 20 | + | runs smoothly there too. | |
| 13 | 21 | ||
| 14 | 22 | ## 0.1.1 (2026-10-28) | |
| 15 | 23 | ||
| @@ -124,6 +124,7 @@ Exit codes: | |||
| 124 | 124 | - The cursor shows only after a key press, and the first key only shows it, so nothing changes where it cannot be seen. A click hides it. | |
| 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 | + | - **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. | |
| 127 | 128 | - **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. | |
| 128 | 129 | - **Screenshots:** the README's come from the demo buds: `cargo test -p budctl-gui readme_screenshots -- --ignored` renders them again into `docs/screenshots/`. | |
| 129 | 130 | ||
| @@ -22,6 +22,11 @@ indexing_slicing = "deny" | |||
| 22 | 22 | cast_possible_truncation = "deny" | |
| 23 | 23 | undocumented_unsafe_blocks = "deny" | |
| 24 | 24 | ||
| 25 | + | # Debug builds (`cargo run`, the tests): the dependencies optimized, our crates not. Unoptimized, | |
| 26 | + | # the GUI's CPU renderer draws a frame about 20 times slower, and the window lags. | |
| 27 | + | [profile.dev.package."*"] | |
| 28 | + | opt-level = 3 | |
| 29 | + | ||
| 25 | 30 | # Release builds: the binaries the install script ships. No debug symbols, and whole-crate | |
| 26 | 31 | # optimization across the dependency tree, for smaller and faster binaries. | |
| 27 | 32 | [profile.release] | |
| @@ -1,55 +1,82 @@ | |||
| 1 | 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. | |
| 2 | + | //! bands. Drawn on a canvas, so they take any theme color, and kept between frames ([`Kept`]). | |
| 3 | 3 | ||
| 4 | 4 | use std::f32::consts::{FRAC_PI_2, TAU}; | |
| 5 | 5 | ||
| 6 | + | use iced::alignment::Vertical; | |
| 6 | 7 | use iced::widget::canvas::{self, Event, Frame, Path}; | |
| 7 | − | use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse}; | |
| 8 | + | use iced::widget::text; | |
| 9 | + | use iced::{Color, Element, Font, Length, Point, Rectangle, Renderer, Theme, mouse}; | |
| 8 | 10 | ||
| 9 | 11 | use crate::Message; | |
| 12 | + | use crate::kept::Kept; | |
| 13 | + | use crate::theme::Look; | |
| 10 | 14 | ||
| 11 | − | /// A ring of `n` dots, `size` pixels across: the fraction `lit` lights clockwise from the top in | |
| 12 | − | /// `on`, the rest stay `off`. `None` lights nothing. | |
| 13 | − | pub fn ring<'a>(lit: Option<f32>, size: f32, on: Color, off: Color) -> Element<'a, Message> { | |
| 14 | − | iced::widget::canvas(Ring { lit, on, off }) | |
| 15 | − | .width(size) | |
| 16 | − | .height(size) | |
| 17 | − | .into() | |
| 15 | + | /// A battery ring, `size` pixels across: a ring of dots, lit clockwise from the top up to the | |
| 16 | + | /// `level` (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it | |
| 17 | + | /// is unknown. The figure is drawn with the dots, so the page needs no layer of its own for it. | |
| 18 | + | pub fn ring<'a>(look: Look, level: Option<u8>, size: f32) -> Element<'a, Message> { | |
| 19 | + | let p = look.p; | |
| 20 | + | let ring = Ring { | |
| 21 | + | level, | |
| 22 | + | on: p.fill, | |
| 23 | + | off: p.s3, | |
| 24 | + | figure: if level.is_some() { p.fg } else { p.mute }, | |
| 25 | + | font: look.dot(), | |
| 26 | + | }; | |
| 27 | + | iced::widget::canvas(ring).width(size).height(size).into() | |
| 18 | 28 | } | |
| 19 | 29 | ||
| 30 | + | #[derive(Clone, Copy, PartialEq)] | |
| 20 | 31 | struct Ring { | |
| 21 | − | lit: Option<f32>, | |
| 32 | + | level: Option<u8>, | |
| 22 | 33 | on: Color, | |
| 23 | 34 | off: Color, | |
| 35 | + | figure: Color, | |
| 36 | + | font: Font, | |
| 24 | 37 | } | |
| 25 | 38 | ||
| 26 | 39 | impl canvas::Program<Message> for Ring { | |
| 27 | − | type State = (); | |
| 40 | + | type State = Kept<Ring>; | |
| 28 | 41 | ||
| 29 | 42 | fn draw( | |
| 30 | 43 | &self, | |
| 31 | − | _state: &(), | |
| 44 | + | kept: &Kept<Ring>, | |
| 32 | 45 | renderer: &Renderer, | |
| 33 | 46 | _theme: &Theme, | |
| 34 | 47 | bounds: Rectangle, | |
| 35 | 48 | _cursor: mouse::Cursor, | |
| 36 | 49 | ) -> Vec<canvas::Geometry> { | |
| 37 | 50 | const N: u16 = 44; | |
| 38 | − | let mut frame = Frame::new(renderer, bounds.size()); | |
| 39 | − | let center = frame.center(); | |
| 40 | − | let dot = (bounds.width * 0.045).max(3.0); | |
| 41 | − | let radius = bounds.width / 2.0 - dot / 2.0 - 1.0; | |
| 42 | − | let lit = self.lit.map_or(0, |f| lit_count(f, N)); | |
| 43 | − | for n in 0..N { | |
| 44 | − | let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2; | |
| 45 | − | let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin()); | |
| 46 | − | let color = if n < lit { self.on } else { self.off }; | |
| 47 | − | frame.fill(&Path::circle(at, dot / 2.0), color); | |
| 48 | − | } | |
| 49 | − | vec![frame.into_geometry()] | |
| 51 | + | let geometry = kept.draw(renderer, bounds.size(), *self, |frame| { | |
| 52 | + | let center = frame.center(); | |
| 53 | + | let dot = (bounds.width * 0.045).max(3.0); | |
| 54 | + | 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 | + | for n in 0..N { | |
| 57 | + | let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2; | |
| 58 | + | let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin()); | |
| 59 | + | let color = if n < lit { self.on } else { self.off }; | |
| 60 | + | frame.fill(&Path::circle(at, dot / 2.0), color); | |
| 61 | + | } | |
| 62 | + | frame.fill_text(canvas::Text { | |
| 63 | + | content: self.level.map_or_else(|| "--".to_owned(), |level| level.to_string()), | |
| 64 | + | position: center, | |
| 65 | + | color: self.figure, | |
| 66 | + | size: FIGURE.into(), | |
| 67 | + | font: self.font, | |
| 68 | + | align_x: text::Alignment::Center, | |
| 69 | + | align_y: Vertical::Center, | |
| 70 | + | ..canvas::Text::default() | |
| 71 | + | }); | |
| 72 | + | }); | |
| 73 | + | vec![geometry] | |
| 50 | 74 | } | |
| 51 | 75 | } | |
| 52 | 76 | ||
| 77 | + | /// The size of a battery ring's figure. Doto is never set under 26 px. | |
| 78 | + | const FIGURE: f32 = 44.0; | |
| 79 | + | ||
| 53 | 80 | /// How many of `n` dots the fraction `f` lights. | |
| 54 | 81 | fn lit_count(f: f32, n: u16) -> u16 { | |
| 55 | 82 | let f = f.clamp(0.0, 1.0); | |
| @@ -122,9 +149,13 @@ impl<F> Slider<F> { | |||
| 122 | 149 | const DOT_BIG: f32 = 11.0; | |
| 123 | 150 | const DOT: f32 = 5.0; | |
| 124 | 151 | ||
| 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); | |
| 154 | + | ||
| 125 | 155 | #[derive(Default)] | |
| 126 | 156 | pub struct Drag { | |
| 127 | 157 | held: bool, | |
| 158 | + | kept: Kept<Shows>, | |
| 128 | 159 | } | |
| 129 | 160 | ||
| 130 | 161 | impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> { | |
| @@ -175,14 +206,33 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> { | |||
| 175 | 206 | ||
| 176 | 207 | fn draw( | |
| 177 | 208 | &self, | |
| 178 | − | _state: &Drag, | |
| 209 | + | state: &Drag, | |
| 179 | 210 | renderer: &Renderer, | |
| 180 | 211 | _theme: &Theme, | |
| 181 | 212 | bounds: Rectangle, | |
| 182 | 213 | _cursor: mouse::Cursor, | |
| 183 | 214 | ) -> Vec<canvas::Geometry> { | |
| 184 | − | let mut frame = Frame::new(renderer, bounds.size()); | |
| 185 | − | let size = bounds.size(); | |
| 215 | + | let shows = (self.low, self.high, self.value, self.vertical, self.on, self.off); | |
| 216 | + | vec![ | |
| 217 | + | state | |
| 218 | + | .kept | |
| 219 | + | .draw(renderer, bounds.size(), shows, |frame| self.dots(frame)), | |
| 220 | + | ] | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | fn mouse_interaction(&self, state: &Drag, bounds: Rectangle, cursor: mouse::Cursor) -> mouse::Interaction { | |
| 224 | + | if state.held || cursor.is_over(bounds) { | |
| 225 | + | mouse::Interaction::Pointer | |
| 226 | + | } else { | |
| 227 | + | mouse::Interaction::default() | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | impl<F> Slider<F> { | |
| 233 | + | /// One dot per step; the value's dot is larger. | |
| 234 | + | fn dots(&self, frame: &mut Frame) { | |
| 235 | + | let size = frame.size(); | |
| 186 | 236 | let at = |along: f32| { | |
| 187 | 237 | if self.vertical { | |
| 188 | 238 | Point::new( | |
| @@ -211,15 +261,6 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> { | |||
| 211 | 261 | None => break, | |
| 212 | 262 | }; | |
| 213 | 263 | } | |
| 214 | − | vec![frame.into_geometry()] | |
| 215 | − | } | |
| 216 | − | ||
| 217 | − | fn mouse_interaction(&self, state: &Drag, bounds: Rectangle, cursor: mouse::Cursor) -> mouse::Interaction { | |
| 218 | − | if state.held || cursor.is_over(bounds) { | |
| 219 | − | mouse::Interaction::Pointer | |
| 220 | − | } else { | |
| 221 | − | mouse::Interaction::default() | |
| 222 | − | } | |
| 223 | 264 | } | |
| 224 | 265 | } | |
| 225 | 266 | ||
| @@ -4,6 +4,8 @@ | |||
| 4 | 4 | use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc}; | |
| 5 | 5 | use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse}; | |
| 6 | 6 | ||
| 7 | + | use crate::kept::Kept; | |
| 8 | + | ||
| 7 | 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 8 | 10 | pub enum Icon { | |
| 9 | 11 | Equalizer, | |
| @@ -38,26 +40,28 @@ pub fn icon<'a, Message: 'a>(icon: Icon, size: f32, color: Color) -> Element<'a, | |||
| 38 | 40 | .into() | |
| 39 | 41 | } | |
| 40 | 42 | ||
| 43 | + | #[derive(Clone, Copy, PartialEq)] | |
| 41 | 44 | struct Glyph { | |
| 42 | 45 | icon: Icon, | |
| 43 | 46 | color: Color, | |
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | 49 | impl<Message> canvas::Program<Message> for Glyph { | |
| 47 | − | type State = (); | |
| 50 | + | type State = Kept<Glyph>; | |
| 48 | 51 | ||
| 49 | 52 | fn draw( | |
| 50 | 53 | &self, | |
| 51 | − | _state: &(), | |
| 54 | + | kept: &Kept<Glyph>, | |
| 52 | 55 | renderer: &Renderer, | |
| 53 | 56 | _theme: &Theme, | |
| 54 | 57 | bounds: Rectangle, | |
| 55 | 58 | _cursor: mouse::Cursor, | |
| 56 | 59 | ) -> Vec<canvas::Geometry> { | |
| 57 | − | let mut frame = Frame::new(renderer, bounds.size()); | |
| 58 | − | let mut pen = Pen::new(&mut frame, Point::ORIGIN, bounds.width, self.color); | |
| 59 | − | draw_icon(&mut pen, self.icon); | |
| 60 | − | vec![frame.into_geometry()] | |
| 60 | + | let geometry = kept.draw(renderer, bounds.size(), *self, |frame| { | |
| 61 | + | let mut pen = Pen::new(frame, Point::ORIGIN, bounds.width, self.color); | |
| 62 | + | draw_icon(&mut pen, self.icon); | |
| 63 | + | }); | |
| 64 | + | vec![geometry] | |
| 61 | 65 | } | |
| 62 | 66 | } | |
| 63 | 67 | ||
| @@ -0,0 +1,62 @@ | |||
| 1 | + | //! Canvas drawings kept between frames. | |
| 2 | + | //! | |
| 3 | + | //! The CPU renderer takes a canvas drawn afresh as changed on every frame, shape by shape, and | |
| 4 | + | //! repaints each shape's patch of the window on its own: with a few dozen icons and dots on a | |
| 5 | + | //! page, one hovered row repaints the window dozens of times over. A kept drawing stays the same | |
| 6 | + | //! drawing until what it shows changes, so only what really changed is painted again. | |
| 7 | + | ||
| 8 | + | use std::cell::Cell; | |
| 9 | + | ||
| 10 | + | use iced::widget::canvas::{Cache, Frame, Geometry}; | |
| 11 | + | use iced::{Renderer, Size}; | |
| 12 | + | ||
| 13 | + | /// A canvas's drawing of `K` (what it shows: its colors and values), drawn again only when `K` | |
| 14 | + | /// or the canvas's size changes. It lives in the canvas's state, so it outlasts the view. | |
| 15 | + | pub struct Kept<K> { | |
| 16 | + | cache: Cache, | |
| 17 | + | shows: Cell<Option<K>>, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | impl<K> Default for Kept<K> { | |
| 21 | + | fn default() -> Self { | |
| 22 | + | Kept { | |
| 23 | + | cache: Cache::new(), | |
| 24 | + | shows: Cell::new(None), | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl<K: Copy + PartialEq> Kept<K> { | |
| 30 | + | /// The drawing of `shows`, `size` large: the kept one, or a new one made by `draw`. | |
| 31 | + | pub fn draw(&self, renderer: &Renderer, size: Size, shows: K, draw: impl FnOnce(&mut Frame)) -> Geometry { | |
| 32 | + | if self.shows.get() != Some(shows) { | |
| 33 | + | self.cache.clear(); | |
| 34 | + | self.shows.set(Some(shows)); | |
| 35 | + | } | |
| 36 | + | self.cache.draw(renderer, size, draw) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[cfg(test)] | |
| 41 | + | mod tests { | |
| 42 | + | use super::*; | |
| 43 | + | use iced::{Font, Pixels}; | |
| 44 | + | ||
| 45 | + | #[test] | |
| 46 | + | fn draws_again_only_when_what_it_shows_changes() { | |
| 47 | + | let renderer = Renderer::new(Font::DEFAULT, Pixels(16.0)); | |
| 48 | + | let kept = Kept::default(); | |
| 49 | + | let small = Size::new(10.0, 10.0); | |
| 50 | + | let mut drawn = 0; | |
| 51 | + | for (size, shows) in [ | |
| 52 | + | (small, 1), | |
| 53 | + | (small, 1), | |
| 54 | + | (small, 2), | |
| 55 | + | (small, 2), | |
| 56 | + | (Size::new(20.0, 10.0), 2), | |
| 57 | + | ] { | |
| 58 | + | let _ = kept.draw(&renderer, size, shows, |_| drawn += 1); | |
| 59 | + | } | |
| 60 | + | assert_eq!(drawn, 3, "drawn for 1, for 2, and for 2 at the new size"); | |
| 61 | + | } | |
| 62 | + | } | |
| @@ -10,6 +10,7 @@ mod earbuds; | |||
| 10 | 10 | mod equalizer; | |
| 11 | 11 | mod gestures; | |
| 12 | 12 | mod icons; | |
| 13 | + | mod kept; | |
| 13 | 14 | mod keys; | |
| 14 | 15 | mod log; | |
| 15 | 16 | mod overview; | |
| @@ -1272,14 +1273,7 @@ impl App { | |||
| 1272 | 1273 | } | |
| 1273 | 1274 | ||
| 1274 | 1275 | fn subscription(&self) -> Subscription<Message> { | |
| 1275 | − | let events = event::listen_with(|event, _status, _window| match event { | |
| 1276 | − | iced::Event::Window(window::Event::Focused) => Some(Message::Focused), | |
| 1277 | − | iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { | |
| 1278 | − | Some(Message::Key(key, modifiers)) | |
| 1279 | − | } | |
| 1280 | − | iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer), | |
| 1281 | − | _ => None, | |
| 1282 | − | }); | |
| 1276 | + | let events = event::listen_with(|event, _status, _window| on_event(event)); | |
| 1283 | 1277 | if self.link == Link::Demo { | |
| 1284 | 1278 | return events; | |
| 1285 | 1279 | } | |
| @@ -1602,6 +1596,22 @@ impl App { | |||
| 1602 | 1596 | } | |
| 1603 | 1597 | } | |
| 1604 | 1598 | ||
| 1599 | + | /// What a window event means to the window: focus, a key, a click. | |
| 1600 | + | fn on_event(event: iced::Event) -> Option<Message> { | |
| 1601 | + | match event { | |
| 1602 | + | iced::Event::Window(window::Event::Focused) => Some(Message::Focused), | |
| 1603 | + | // The key as typed, with Shift applied: Shift+/ is `?` and Shift+g is `G`. (`key` has | |
| 1604 | + | // Shift taken off, and would give `/` and `g`.) | |
| 1605 | + | iced::Event::Keyboard(keyboard::Event::KeyPressed { | |
| 1606 | + | modified_key, | |
| 1607 | + | modifiers, | |
| 1608 | + | .. | |
| 1609 | + | }) => Some(Message::Key(modified_key, modifiers)), | |
| 1610 | + | iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer), | |
| 1611 | + | _ => None, | |
| 1612 | + | } | |
| 1613 | + | } | |
| 1614 | + | ||
| 1605 | 1615 | /// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes | |
| 1606 | 1616 | /// round. With nothing chosen, any of them picks the first. | |
| 1607 | 1617 | fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> { | |
| @@ -1842,6 +1852,34 @@ mod tests { | |||
| 1842 | 1852 | assert!(!app.help); | |
| 1843 | 1853 | } | |
| 1844 | 1854 | ||
| 1855 | + | /// Keys as the window gets them: iced gives the key both without Shift (`key`) and as typed | |
| 1856 | + | /// (`modified_key`). `?` and `G` only exist as typed. | |
| 1857 | + | #[test] | |
| 1858 | + | fn shifted_keys_count_as_typed() { | |
| 1859 | + | let typed = |key: &str, modified: &str| { | |
| 1860 | + | on_event(iced::Event::Keyboard(keyboard::Event::KeyPressed { | |
| 1861 | + | key: Key::Character(key.into()), | |
| 1862 | + | modified_key: Key::Character(modified.into()), | |
| 1863 | + | physical_key: keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified), | |
| 1864 | + | location: keyboard::Location::Standard, | |
| 1865 | + | modifiers: Modifiers::SHIFT, | |
| 1866 | + | text: Some(modified.into()), | |
| 1867 | + | repeat: false, | |
| 1868 | + | })) | |
| 1869 | + | .unwrap() | |
| 1870 | + | }; | |
| 1871 | + | let mut app = app(); | |
| 1872 | + | let _ = app.update(typed("/", "?")); | |
| 1873 | + | assert!(app.help, "Shift+/ opens the list of keys"); | |
| 1874 | + | let _ = app.update(typed("/", "?")); | |
| 1875 | + | assert!(!app.help); | |
| 1876 | + | press(&mut app, "j"); | |
| 1877 | + | let _ = app.update(typed("g", "G")); | |
| 1878 | + | assert_eq!(app.cursor, app.items().len() - 1, "Shift+g goes to the last item"); | |
| 1879 | + | let _ = app.update(typed("j", "J")); | |
| 1880 | + | assert_eq!(app.page, Page::Equalizer, "Shift+j goes to the next page"); | |
| 1881 | + | } | |
| 1882 | + | ||
| 1845 | 1883 | #[test] | |
| 1846 | 1884 | fn live_buds_are_read_first() { | |
| 1847 | 1885 | let (mut app, commands) = live(); | |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! The Overview page: battery, noise control, the switches, and the way to the other pages. | |
| 2 | 2 | ||
| 3 | 3 | use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch}; | |
| 4 | − | use iced::widget::{column, container, row, stack}; | |
| 4 | + | use iced::widget::{column, container, row}; | |
| 5 | 5 | use iced::{Alignment, Element, Length}; | |
| 6 | 6 | ||
| 7 | 7 | use crate::buds::Buds; | |
| @@ -64,14 +64,7 @@ pub fn view<'a>( | |||
| 64 | 64 | fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> { | |
| 65 | 65 | let p = look.p; | |
| 66 | 66 | let gauge = |label: &str, charge: Option<Charge>| -> Element<'a, Message> { | |
| 67 | − | let figure = match charge { | |
| 68 | − | Some(charge) => ui::num(look, &charge.level.to_string(), 44.0, p.fg), | |
| 69 | − | None => ui::num(look, "--", 44.0, p.mute), | |
| 70 | − | }; | |
| 71 | − | let ring = stack![ | |
| 72 | − | dots::ring(charge.map(|c| f32::from(c.level) / 100.0), 150.0, p.fill, p.s3), | |
| 73 | − | container(figure).center(150), | |
| 74 | − | ]; | |
| 67 | + | let ring = dots::ring(look, charge.map(|c| c.level), 150.0); | |
| 75 | 68 | let charging = charge.is_some_and(|c| c.charging); | |
| 76 | 69 | let caption = row![ui::label(look, label, 11.0, p.grey)] | |
| 77 | 70 | .push(charging.then(|| icons::icon(Icon::Bolt, 14.0, p.live))) | |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | //! Everything takes its colors from the [`Look`]. | |
| 4 | 4 | ||
| 5 | 5 | use iced::alignment::Horizontal; | |
| 6 | − | use iced::widget::{Column, Row, button, column, container, row, space, text}; | |
| 6 | + | use iced::widget::{Column, Row, Stack, button, column, container, row, space, text}; | |
| 7 | 7 | use iced::{Alignment, Border, Color, Element, Length}; | |
| 8 | 8 | ||
| 9 | 9 | use crate::Message; | |
| @@ -58,21 +58,34 @@ pub fn note<'a>(look: Look, words: impl text::IntoFragment<'a>) -> Element<'a, M | |||
| 58 | 58 | /// A round card holding rows. | |
| 59 | 59 | pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> { | |
| 60 | 60 | let p = look.p; | |
| 61 | − | container(Column::with_children(rows).spacing(2)) | |
| 62 | − | .padding(6) | |
| 63 | − | .width(Length::Fill) | |
| 64 | − | .style(move |_| surface(p.s1, CARD)) | |
| 65 | − | .into() | |
| 61 | + | layer( | |
| 62 | + | container(Column::with_children(rows).spacing(2)) | |
| 63 | + | .padding(6) | |
| 64 | + | .width(Length::Fill) | |
| 65 | + | .style(move |_| surface(p.s1, CARD)), | |
| 66 | + | ) | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | /// A round card around content of its own. | |
| 69 | 70 | pub fn panel<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> { | |
| 70 | 71 | let p = look.p; | |
| 71 | − | container(content) | |
| 72 | − | .padding(24) | |
| 73 | − | .width(Length::Fill) | |
| 74 | − | .style(move |_| surface(p.s1, CARD)) | |
| 75 | − | .into() | |
| 72 | + | layer( | |
| 73 | + | container(content) | |
| 74 | + | .padding(24) | |
| 75 | + | .width(Length::Fill) | |
| 76 | + | .style(move |_| surface(p.s1, CARD)), | |
| 77 | + | ) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// `content` on a layer of its own, clipped to its bounds. | |
| 81 | + | /// | |
| 82 | + | /// The CPU renderer repaints a page that scrolls patch by patch: one for each word, icon and | |
| 83 | + | /// row that moved, with the whole card under each patch painted again. A layer that moved is | |
| 84 | + | /// one patch, so a card is painted about once per frame instead of dozens of times. | |
| 85 | + | fn layer<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> { | |
| 86 | + | // The stack takes its size from `content` and draws it on a layer of its own, above the | |
| 87 | + | // empty space under it. | |
| 88 | + | Stack::new().push(content).push_under(space()).clip(true).into() | |
| 76 | 89 | } | |
| 77 | 90 | ||
| 78 | 91 | fn surface(color: Color, radius: f32) -> container::Style { | |