Krolyxon
Commit 93bfdf6

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-29 10:40 +0530
Parent
7c15b41
Commit
93bfdf64177b5a8c65df1535773b789f85783409

+235 −70

9 files changed

  1. M CHANGELOG.md +8 −0
  2. M CLAUDE.md +1 −0
  3. M Cargo.toml +5 −0
  4. M crates/budctl-gui/src/dots.rs +77 −36
  5. M crates/budctl-gui/src/icons.rs +10 −6
  6. A crates/budctl-gui/src/kept.rs +62 −0
  7. M crates/budctl-gui/src/main.rs +46 −8
  8. M crates/budctl-gui/src/overview.rs +2 −9
  9. M crates/budctl-gui/src/ui.rs +24 −11
M CHANGELOG.md View
@@ -10,6 +10,14 @@
1010 - The palette file takes Aurora's Material 3 keys (`primary`, `tertiary`, `surfaceLow`,
1111 `outline`…), so `~/.cache/matugen/aurora.json` works as it is. `bg` and `fg` are still read;
1212 `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.
1321
1422 ## 0.1.1 (2026-10-28)
1523
M CLAUDE.md View
@@ -124,6 +124,7 @@ Exit codes:
124124 - 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.
125125 - **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command (`gui.idle_seconds` in the config). A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI.
126126 - **Files:** writes the built-in palette to `~/.config/budctl/theme.json` when missing (never overwrites it), and logs errors and session events to `~/.local/state/budctl/gui.log` (`log.rs`), replaced each launch. Frames stay on stderr with `--debug`. The CLI logs to stderr only, since status bars run it every few seconds.
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.
127128 - **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.
128129 - **Screenshots:** the README's come from the demo buds: `cargo test -p budctl-gui readme_screenshots -- --ignored` renders them again into `docs/screenshots/`.
129130
M Cargo.toml View
@@ -22,6 +22,11 @@ indexing_slicing = "deny"
2222 cast_possible_truncation = "deny"
2323 undocumented_unsafe_blocks = "deny"
2424
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+
2530 # Release builds: the binaries the install script ships. No debug symbols, and whole-crate
2631 # optimization across the dependency tree, for smaller and faster binaries.
2732 [profile.release]
M crates/budctl-gui/src/dots.rs View
@@ -1,55 +1,82 @@
11 //! 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`]).
33
44 use std::f32::consts::{FRAC_PI_2, TAU};
55
6+use iced::alignment::Vertical;
67 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};
810
911 use crate::Message;
12+use crate::kept::Kept;
13+use crate::theme::Look;
1014
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()
1828 }
1929
30+#[derive(Clone, Copy, PartialEq)]
2031 struct Ring {
21− lit: Option<f32>,
32+ level: Option<u8>,
2233 on: Color,
2334 off: Color,
35+ figure: Color,
36+ font: Font,
2437 }
2538
2639 impl canvas::Program<Message> for Ring {
27− type State = ();
40+ type State = Kept<Ring>;
2841
2942 fn draw(
3043 &self,
31− _state: &(),
44+ kept: &Kept<Ring>,
3245 renderer: &Renderer,
3346 _theme: &Theme,
3447 bounds: Rectangle,
3548 _cursor: mouse::Cursor,
3649 ) -> Vec<canvas::Geometry> {
3750 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]
5074 }
5175 }
5276
77+/// The size of a battery ring's figure. Doto is never set under 26 px.
78+const FIGURE: f32 = 44.0;
79+
5380 /// How many of `n` dots the fraction `f` lights.
5481 fn lit_count(f: f32, n: u16) -> u16 {
5582 let f = f.clamp(0.0, 1.0);
@@ -122,9 +149,13 @@ impl<F> Slider<F> {
122149 const DOT_BIG: f32 = 11.0;
123150 const DOT: f32 = 5.0;
124151
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+
125155 #[derive(Default)]
126156 pub struct Drag {
127157 held: bool,
158+ kept: Kept<Shows>,
128159 }
129160
130161 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> {
175206
176207 fn draw(
177208 &self,
178− _state: &Drag,
209+ state: &Drag,
179210 renderer: &Renderer,
180211 _theme: &Theme,
181212 bounds: Rectangle,
182213 _cursor: mouse::Cursor,
183214 ) -> 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();
186236 let at = |along: f32| {
187237 if self.vertical {
188238 Point::new(
@@ -211,15 +261,6 @@ impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
211261 None => break,
212262 };
213263 }
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− }
223264 }
224265 }
225266
M crates/budctl-gui/src/icons.rs View
@@ -4,6 +4,8 @@
44 use iced::widget::canvas::{self, Frame, LineCap, LineJoin, Path, Stroke, path::Arc};
55 use iced::{Color, Element, Point, Radians, Rectangle, Renderer, Size, Theme, mouse};
66
7+use crate::kept::Kept;
8+
79 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
810 pub enum Icon {
911 Equalizer,
@@ -38,26 +40,28 @@ pub fn icon<'a, Message: 'a>(icon: Icon, size: f32, color: Color) -> Element<'a,
3840 .into()
3941 }
4042
43+#[derive(Clone, Copy, PartialEq)]
4144 struct Glyph {
4245 icon: Icon,
4346 color: Color,
4447 }
4548
4649 impl<Message> canvas::Program<Message> for Glyph {
47− type State = ();
50+ type State = Kept<Glyph>;
4851
4952 fn draw(
5053 &self,
51− _state: &(),
54+ kept: &Kept<Glyph>,
5255 renderer: &Renderer,
5356 _theme: &Theme,
5457 bounds: Rectangle,
5558 _cursor: mouse::Cursor,
5659 ) -> 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]
6165 }
6266 }
6367
A crates/budctl-gui/src/kept.rs View
@@ -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+}
M crates/budctl-gui/src/main.rs View
@@ -10,6 +10,7 @@ mod earbuds;
1010 mod equalizer;
1111 mod gestures;
1212 mod icons;
13+mod kept;
1314 mod keys;
1415 mod log;
1516 mod overview;
@@ -1272,14 +1273,7 @@ impl App {
12721273 }
12731274
12741275 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));
12831277 if self.link == Link::Demo {
12841278 return events;
12851279 }
@@ -1602,6 +1596,22 @@ impl App {
16021596 }
16031597 }
16041598
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+
16051615 /// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
16061616 /// round. With nothing chosen, any of them picks the first.
16071617 fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
@@ -1842,6 +1852,34 @@ mod tests {
18421852 assert!(!app.help);
18431853 }
18441854
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+
18451883 #[test]
18461884 fn live_buds_are_read_first() {
18471885 let (mut app, commands) = live();
M crates/budctl-gui/src/overview.rs View
@@ -1,7 +1,7 @@
11 //! The Overview page: battery, noise control, the switches, and the way to the other pages.
22
33 use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch};
4−use iced::widget::{column, container, row, stack};
4+use iced::widget::{column, container, row};
55 use iced::{Alignment, Element, Length};
66
77 use crate::buds::Buds;
@@ -64,14 +64,7 @@ pub fn view<'a>(
6464 fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
6565 let p = look.p;
6666 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);
7568 let charging = charge.is_some_and(|c| c.charging);
7669 let caption = row![ui::label(look, label, 11.0, p.grey)]
7770 .push(charging.then(|| icons::icon(Icon::Bolt, 14.0, p.live)))
M crates/budctl-gui/src/ui.rs View
@@ -3,7 +3,7 @@
33 //! Everything takes its colors from the [`Look`].
44
55 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};
77 use iced::{Alignment, Border, Color, Element, Length};
88
99 use crate::Message;
@@ -58,21 +58,34 @@ pub fn note<'a>(look: Look, words: impl text::IntoFragment<'a>) -> Element<'a, M
5858 /// A round card holding rows.
5959 pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Message> {
6060 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+ )
6667 }
6768
6869 /// A round card around content of its own.
6970 pub fn panel<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> {
7071 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()
7689 }
7790
7891 fn surface(color: Color, radius: f32) -> container::Style {