Krolyxon
Commit 19d99c1

Branches

Refs →

budctl19d99c1

budctl-gui: vim keys (GUI step 2)

A keyboard cursor moves over what the page can change: on the
Overview, the noise-control mode, the ANC strength, each switch, and
the rows that open other pages.

- j/k, arrows, Tab: move; gg/G: first/last.
- h/l: previous/next choice, switch off/on, l opens a row's page.
  Enter/Space: toggle, step round the choices, or open.
- J/K, Ctrl+Tab (Ctrl+J/K), gt/gT, 1-5: pages. Esc hides the cursor,
  q (Ctrl+Q) quits, ? shows the keys in the window.

The mouse still comes first: the cursor shows only after a key press,
the first key only shows it (so nothing changes unseen), and a click
hides it. The page scrolls to keep the cursor in view.

keys.rs turns key presses into actions and holds the pending g;
App::change decides what an action does to the item under the cursor.
Both are covered by tests without a display, and pages_render also
renders the cursor and the key list.

No new dependencies.
Author
krolyxon
Date
2026-10-27 23:22 +0530
Parent
59f791a
Commit
19d99c143ebd8bce2a7afcce8d0e030ac470164d

+698 −67

5 files changed

  1. M CLAUDE.md +5 −1
  2. A crates/budctl-gui/src/keys.rs +176 −0
  3. M crates/budctl-gui/src/main.rs +416 −22
  4. M crates/budctl-gui/src/overview.rs +60 −35
  5. M crates/budctl-gui/src/ui.rs +41 −9
M CLAUDE.md View
@@ -119,7 +119,11 @@ Exit codes:
119119
120120 - **Look:** a sidebar (device card, pages) and pages of cards: battery rings, a segmented noise-control pill, rows of switches. The owner's reference is a screenshot of another buds app; match the look, never its code (rule 2).
121121 - **Theme:** a built-in dark palette with a red accent. Optionally, a matugen JSON file with the same keys as the owner's Aurora palette (`~/.cache/matugen/aurora.json`: `bg`, `fg`, `primary`, `surface`…), mapped to the same roles as Aurora's `Theme.qml`. It is read again whenever the window gets focus.
122−- **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top: `j`/`k` move a cursor, `h`/`l` change the value under it. The cursor shows only after a key press.
122+- **Input:** the mouse and touchpad come first. Vim-like keys are a layer on top (`keys.rs`, `?` lists them in the window):
123+ - `j`/`k`, the arrows or Tab move a cursor over what the page can change; `gg`/`G` jump to the first or last item.
124+ - `h`/`l` pick the previous or next choice, or turn a switch off or on, and `l` opens a row's page. Enter and Space toggle, step round the choices, or open.
125+ - `J`/`K`, `Ctrl+Tab`, `gt`/`gT` and `1`–`5` change pages. `Esc` hides the cursor, `q` quits.
126+ - 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.
123127 - **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.
124128
125129 ## Working with Claude
A crates/budctl-gui/src/keys.rs View
@@ -0,0 +1,176 @@
1+//! The keyboard layer: vim-like keys on top of a mouse-first window.
2+//!
3+//! A cursor moves over the things a page can change. It stays hidden until a key is pressed and
4+//! hides again on a click, so mouse users never see it. This module only turns key presses into
5+//! [`Action`]s; `App::update` decides what an action does on the current page.
6+
7+use iced::keyboard::key::Named;
8+use iced::keyboard::{Key, Modifiers};
9+
10+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+pub enum Action {
12+ Down,
13+ Up,
14+ First,
15+ Last,
16+ /// The previous choice, or off.
17+ Left,
18+ /// The next choice, or on, or open what the item leads to.
19+ Right,
20+ /// Toggle, step to the next choice, or open.
21+ Activate,
22+ NextPage,
23+ PreviousPage,
24+ /// A page by its place in the sidebar, from 0.
25+ Page(usize),
26+ /// Show or hide the list of keys.
27+ Help,
28+ /// Close the list of keys, else hide the cursor.
29+ Escape,
30+ Quit,
31+}
32+
33+/// The keys, as the list of keys shows them.
34+pub const HELP: [(&str, &str); 10] = [
35+ ("j k ↓ ↑", "move the cursor"),
36+ ("h l ← →", "previous / next choice, off / on"),
37+ ("Enter Space", "toggle, next choice, or open"),
38+ ("gg G", "first / last"),
39+ ("J K Ctrl+Tab", "next / previous page"),
40+ ("gt gT", "next / previous page"),
41+ ("1 … 5", "go to a page"),
42+ ("Esc", "hide the cursor"),
43+ ("?", "these keys"),
44+ ("q", "quit"),
45+];
46+
47+/// Turns key presses into actions. It remembers a `g`, for `gg`, `gt` and `gT`.
48+#[derive(Debug, Default)]
49+pub struct Keys {
50+ after_g: bool,
51+}
52+
53+impl Keys {
54+ pub fn press(&mut self, key: &Key, modifiers: Modifiers) -> Option<Action> {
55+ let after_g = std::mem::take(&mut self.after_g);
56+ let shift = modifiers.shift();
57+ match key {
58+ Key::Named(named) => Some(match named {
59+ Named::ArrowDown => Action::Down,
60+ Named::ArrowUp => Action::Up,
61+ Named::ArrowLeft => Action::Left,
62+ Named::ArrowRight => Action::Right,
63+ Named::Tab if modifiers.control() && shift => Action::PreviousPage,
64+ Named::Tab if modifiers.control() => Action::NextPage,
65+ Named::Tab if shift => Action::Up,
66+ Named::Tab => Action::Down,
67+ Named::Enter | Named::Space => Action::Activate,
68+ Named::Home => Action::First,
69+ Named::End => Action::Last,
70+ Named::Escape => Action::Escape,
71+ _ => return None,
72+ }),
73+ Key::Character(c) if modifiers.control() => match c.as_str() {
74+ "j" => Some(Action::NextPage),
75+ "k" => Some(Action::PreviousPage),
76+ "q" => Some(Action::Quit),
77+ _ => None,
78+ },
79+ Key::Character(_) if modifiers.alt() || modifiers.logo() => None,
80+ Key::Character(c) => match c.as_str() {
81+ "g" if after_g => Some(Action::First),
82+ "t" if after_g => Some(Action::NextPage),
83+ "T" if after_g => Some(Action::PreviousPage),
84+ "g" => {
85+ self.after_g = true;
86+ None
87+ }
88+ "j" => Some(Action::Down),
89+ "k" => Some(Action::Up),
90+ "h" => Some(Action::Left),
91+ "l" => Some(Action::Right),
92+ "G" => Some(Action::Last),
93+ "J" => Some(Action::NextPage),
94+ "K" => Some(Action::PreviousPage),
95+ " " => Some(Action::Activate),
96+ "?" => Some(Action::Help),
97+ "q" => Some(Action::Quit),
98+ digit => match digit.parse::<usize>() {
99+ Ok(n @ 1..=9) => Some(Action::Page(n - 1)),
100+ _ => None,
101+ },
102+ },
103+ Key::Unidentified => None,
104+ }
105+ }
106+}
107+
108+#[cfg(test)]
109+mod tests {
110+ use super::*;
111+
112+ fn press(keys: &mut Keys, key: &str) -> Option<Action> {
113+ let (key, modifiers) = match key {
114+ "Down" => (Key::Named(Named::ArrowDown), Modifiers::empty()),
115+ "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
116+ "Space" => (Key::Named(Named::Space), Modifiers::empty()),
117+ "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
118+ "Tab" => (Key::Named(Named::Tab), Modifiers::empty()),
119+ "S-Tab" => (Key::Named(Named::Tab), Modifiers::SHIFT),
120+ "C-Tab" => (Key::Named(Named::Tab), Modifiers::CTRL),
121+ "C-S-Tab" => (Key::Named(Named::Tab), Modifiers::CTRL | Modifiers::SHIFT),
122+ "C-j" => (Key::Character("j".into()), Modifiers::CTRL),
123+ "A-j" => (Key::Character("j".into()), Modifiers::ALT),
124+ c if c.chars().next().is_some_and(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
125+ c => (Key::Character(c.into()), Modifiers::empty()),
126+ };
127+ keys.press(&key, modifiers)
128+ }
129+
130+ #[test]
131+ fn single_keys() {
132+ let mut keys = Keys::default();
133+ let cases = [
134+ ("j", Some(Action::Down)),
135+ ("Down", Some(Action::Down)),
136+ ("Tab", Some(Action::Down)),
137+ ("k", Some(Action::Up)),
138+ ("S-Tab", Some(Action::Up)),
139+ ("h", Some(Action::Left)),
140+ ("l", Some(Action::Right)),
141+ ("Enter", Some(Action::Activate)),
142+ ("Space", Some(Action::Activate)),
143+ ("G", Some(Action::Last)),
144+ ("J", Some(Action::NextPage)),
145+ ("K", Some(Action::PreviousPage)),
146+ ("C-Tab", Some(Action::NextPage)),
147+ ("C-S-Tab", Some(Action::PreviousPage)),
148+ ("C-j", Some(Action::NextPage)),
149+ ("3", Some(Action::Page(2))),
150+ ("0", None),
151+ ("?", Some(Action::Help)),
152+ ("Esc", Some(Action::Escape)),
153+ ("q", Some(Action::Quit)),
154+ ("x", None),
155+ ("A-j", None),
156+ ];
157+ for (key, action) in cases {
158+ assert_eq!(press(&mut keys, key), action, "{key}");
159+ }
160+ }
161+
162+ #[test]
163+ fn g_waits_for_the_next_key() {
164+ let mut keys = Keys::default();
165+ assert_eq!(press(&mut keys, "g"), None);
166+ assert_eq!(press(&mut keys, "g"), Some(Action::First));
167+ assert_eq!(press(&mut keys, "g"), None);
168+ assert_eq!(press(&mut keys, "t"), Some(Action::NextPage));
169+ assert_eq!(press(&mut keys, "g"), None);
170+ assert_eq!(press(&mut keys, "T"), Some(Action::PreviousPage));
171+ // Anything else after a g counts on its own, and the g is forgotten.
172+ assert_eq!(press(&mut keys, "g"), None);
173+ assert_eq!(press(&mut keys, "j"), Some(Action::Down));
174+ assert_eq!(press(&mut keys, "t"), None);
175+ }
176+}
M crates/budctl-gui/src/main.rs View
@@ -1,10 +1,12 @@
11 //! `budctl-gui`: control wireless earbuds from Linux, in a window.
22 //!
33 //! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first,
4−//! the live connection later (CLAUDE.md, GUI).
4+//! the live connection later (CLAUDE.md, GUI). The mouse comes first; vim-like keys work on top
5+//! (`keys.rs`).
56
67 mod demo;
78 mod icons;
9+mod keys;
810 mod overview;
911 mod theme;
1012 mod ui;
@@ -13,16 +15,23 @@ use std::path::PathBuf;
1315 use std::process::ExitCode;
1416
1517 use budctl_core::status::{AncLevel, NoiseControl, NoiseMode};
16−use iced::widget::{Column, column, container, row, scrollable, space, text};
17−use iced::{Alignment, Border, Element, Font, Length, Size, Subscription, Theme, event, window};
18+use iced::keyboard::{self, Key, Modifiers};
19+use iced::widget::operation::{self, RelativeOffset};
20+use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
21+use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window};
1822
1923 use icons::Icon;
24+use keys::Action;
2025 use theme::Look;
2126
2227 const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
2328
2429 Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
25−The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).";
30+The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
31+Press ? in the window for the keys.";
32+
33+/// The page's scrolling area, so the cursor can be kept in view.
34+const SCROLL: &str = "page";
2635
2736 fn main() -> ExitCode {
2837 let theme_file = match parse_args(std::env::args().skip(1)) {
@@ -115,6 +124,25 @@ impl Page {
115124 Page::Settings => Icon::Settings,
116125 }
117126 }
127+
128+ /// The next page in the sidebar, or the previous one, going round.
129+ fn step(self, forward: bool) -> Page {
130+ let count = Page::ALL.len();
131+ let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
132+ let to = if forward { at + 1 } else { at + count - 1 };
133+ Page::ALL.get(to % count).copied().unwrap_or(self)
134+ }
135+}
136+
137+/// What the keyboard cursor can rest on.
138+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139+enum Item {
140+ NoiseMode,
141+ AncLevel,
142+ /// A switch, by its key.
143+ Switch(&'static str),
144+ /// A row that opens a page.
145+ Open(Page),
118146 }
119147
120148 #[derive(Debug, Clone)]
@@ -126,6 +154,10 @@ enum Message {
126154 Connect(bool),
127155 /// The window got focus: the palette file may have changed meanwhile.
128156 Focused,
157+ Key(Key, Modifiers),
158+ /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
159+ Pointer,
160+ CloseHelp,
129161 }
130162
131163 struct App {
@@ -136,6 +168,13 @@ struct App {
136168 look: Look,
137169 theme: Theme,
138170 palette: theme::Source,
171+ keys: keys::Keys,
172+ /// Where the keyboard cursor is, among [`App::items`].
173+ cursor: usize,
174+ /// Whether it shows. Only keys show it; a click hides it.
175+ cursor_shown: bool,
176+ /// Whether the list of keys is open.
177+ help: bool,
139178 }
140179
141180 impl App {
@@ -158,14 +197,18 @@ impl App {
158197 },
159198 theme: theme::iced_theme(&theme::BUILT_IN),
160199 palette: theme::Source::new(theme_file),
200+ keys: keys::Keys::default(),
201+ cursor: 0,
202+ cursor_shown: false,
203+ help: false,
161204 };
162205 app.refresh_palette();
163206 app
164207 }
165208
166− fn update(&mut self, message: Message) {
209+ fn update(&mut self, message: Message) -> Task<Message> {
167210 match message {
168− Message::Open(page) => self.page = page,
211+ Message::Open(page) => return self.open(page),
169212 Message::Noise(mode) => {
170213 let level = match mode {
171214 NoiseMode::Anc(level) => {
@@ -186,9 +229,135 @@ impl App {
186229 }
187230 Message::Connect(on) => self.buds.connected = on,
188231 Message::Focused => self.refresh_palette(),
232+ Message::Key(key, modifiers) => return self.key(&key, modifiers),
233+ Message::Pointer => self.cursor_shown = false,
234+ Message::CloseHelp => self.help = false,
235+ }
236+ Task::none()
237+ }
238+
239+ /// Shows `page` from its top, with the cursor on its first item.
240+ fn open(&mut self, page: Page) -> Task<Message> {
241+ self.page = page;
242+ self.cursor = 0;
243+ operation::snap_to(SCROLL, RelativeOffset::START)
244+ }
245+
246+ fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
247+ let Some(action) = self.keys.press(key, modifiers) else {
248+ return Task::none();
249+ };
250+ if self.help {
251+ // The list of keys is in front of everything: a key only closes it.
252+ self.help = false;
253+ return Task::none();
254+ }
255+ match action {
256+ Action::Quit => iced::exit(),
257+ Action::Help => {
258+ self.help = true;
259+ Task::none()
260+ }
261+ Action::Escape => {
262+ self.cursor_shown = false;
263+ Task::none()
264+ }
265+ Action::NextPage => self.open(self.page.step(true)),
266+ Action::PreviousPage => self.open(self.page.step(false)),
267+ Action::Page(n) => match Page::ALL.get(n) {
268+ Some(&page) => self.open(page),
269+ None => Task::none(),
270+ },
271+ Action::Down
272+ | Action::Up
273+ | Action::First
274+ | Action::Last
275+ | Action::Left
276+ | Action::Right
277+ | Action::Activate => self.on_cursor(action),
278+ }
279+ }
280+
281+ /// What the current page offers the keyboard cursor, top to bottom.
282+ fn items(&self) -> Vec<Item> {
283+ match self.page {
284+ Page::Overview => overview::items(&self.buds),
285+ _ => Vec::new(),
286+ }
287+ }
288+
289+ /// The item under the cursor, when the cursor shows.
290+ fn focus(&self) -> Option<Item> {
291+ let items = self.items();
292+ let last = items.len().checked_sub(1)?;
293+ self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
294+ }
295+
296+ fn on_cursor(&mut self, action: Action) -> Task<Message> {
297+ let items = self.items();
298+ let Some(last) = items.len().checked_sub(1) else {
299+ return Task::none();
300+ };
301+ self.cursor = self.cursor.min(last);
302+ if !self.cursor_shown {
303+ // The first key only shows the cursor, so nothing changes where it cannot be seen.
304+ self.cursor_shown = true;
305+ return self.follow();
306+ }
307+ match action {
308+ Action::Down => self.cursor = (self.cursor + 1).min(last),
309+ Action::Up => self.cursor = self.cursor.saturating_sub(1),
310+ Action::First => self.cursor = 0,
311+ Action::Last => self.cursor = last,
312+ _ => {
313+ let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
314+ return match change {
315+ Some(message) => self.update(message),
316+ None => Task::none(),
317+ };
318+ }
319+ }
320+ self.follow()
321+ }
322+
323+ /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
324+ fn change(&self, item: Item, action: Action) -> Option<Message> {
325+ match item {
326+ Item::NoiseMode => {
327+ let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
328+ let now = self.buds.status.noise_control.as_ref()?;
329+ let at = modes.iter().position(|mode| mode.name() == now.mode);
330+ step(&modes, at, action).map(Message::Noise)
331+ }
332+ Item::AncLevel => {
333+ let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
334+ let at = levels.iter().position(|&level| level == self.anc_level);
335+ step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
336+ }
337+ Item::Switch(key) => {
338+ let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
339+ let want = match action {
340+ Action::Left => false,
341+ Action::Right => true,
342+ _ => !on,
343+ };
344+ (want != on).then_some(Message::Switch(key, want))
345+ }
346+ Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
189347 }
190348 }
191349
350+ /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
351+ /// bottom, the others in between.
352+ fn follow(&self) -> Task<Message> {
353+ let count = self.items().len();
354+ if !self.cursor_shown || count < 2 {
355+ return Task::none();
356+ }
357+ let y = self.cursor as f32 / (count - 1) as f32;
358+ operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
359+ }
360+
192361 fn refresh_palette(&mut self) {
193362 let palette = match self.palette.refresh() {
194363 None => return,
@@ -209,6 +378,10 @@ impl App {
209378 fn subscription(&self) -> Subscription<Message> {
210379 event::listen_with(|event, _status, _window| match event {
211380 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
381+ iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
382+ Some(Message::Key(key, modifiers))
383+ }
384+ iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
212385 _ => None,
213386 })
214387 }
@@ -216,7 +389,7 @@ impl App {
216389 fn view(&self) -> Element<'_, Message> {
217390 let look = self.look;
218391 let page = match self.page {
219− Page::Overview => overview::view(look, &self.buds, self.anc_level),
392+ Page::Overview => overview::view(look, &self.buds, self.anc_level, self.focus()),
220393 page => column![
221394 ui::title(look, page.title()),
222395 text("This page comes in a later step.").size(14).color(look.p.dim)
@@ -230,6 +403,7 @@ impl App {
230403 .center_x(Length::Fill)
231404 .padding([30, 36]),
232405 )
406+ .id(SCROLL)
233407 .height(Length::Fill)
234408 .style(move |theme, status| {
235409 let mut style = scrollable::default(theme, status);
@@ -241,7 +415,12 @@ impl App {
241415 }
242416 style
243417 });
244− row![self.sidebar(), ui::divider(look), content].into()
418+ let window = row![self.sidebar(), ui::divider(look), content];
419+ if self.help {
420+ stack![window, self.help()].into()
421+ } else {
422+ window.into()
423+ }
245424 }
246425
247426 fn sidebar(&self) -> Element<'_, Message> {
@@ -291,7 +470,8 @@ impl App {
291470 Message::Open(page),
292471 ))
293472 });
294− container(column![device, pages].spacing(22))
473+ let hint = text("Press ? for keys").size(12.5).color(p.dim);
474+ container(column![device, pages, space::vertical(), hint].spacing(22))
295475 .padding([18, 14])
296476 .width(268)
297477 .height(Length::Fill)
@@ -301,6 +481,39 @@ impl App {
301481 })
302482 .into()
303483 }
484+
485+ /// The list of keys, over a dimmed window. A key or a click closes it.
486+ fn help(&self) -> Element<'_, Message> {
487+ let look = self.look;
488+ let p = look.p;
489+ let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
490+ rows.push(row![
491+ text(keys).size(14).font(look.medium()).color(p.fg).width(170),
492+ text(what).size(14).color(p.dim)
493+ ])
494+ });
495+ let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
496+ let backdrop = container(container(list).width(560))
497+ .center(Length::Fill)
498+ .style(|_| container::Style {
499+ background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
500+ ..container::Style::default()
501+ });
502+ opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
503+ }
504+}
505+
506+/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
507+/// round. With nothing chosen, any of them picks the first.
508+fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
509+ let to = match (action, at) {
510+ (_, None) => 0,
511+ (Action::Left, Some(at)) => at.checked_sub(1)?,
512+ (Action::Right, Some(at)) => at + 1,
513+ (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
514+ _ => return None,
515+ };
516+ choices.get(to).copied()
304517 }
305518
306519 /// The level a [`NoiseControl::level`] names.
@@ -313,28 +526,202 @@ fn anc_level(name: &str) -> Option<AncLevel> {
313526 #[cfg(test)]
314527 mod tests {
315528 use super::*;
529+ use iced::keyboard::key::Named;
316530 use std::fs;
317531 use std::path::Path;
318532
533+ fn app() -> App {
534+ App::new(None, Font::DEFAULT)
535+ }
536+
537+ /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
538+ fn press(app: &mut App, keys: &str) {
539+ for name in keys.split_whitespace() {
540+ let (key, modifiers) = match name {
541+ "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
542+ "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
543+ c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
544+ c => (Key::Character(c.into()), Modifiers::empty()),
545+ };
546+ let _ = app.update(Message::Key(key, modifiers));
547+ }
548+ }
549+
550+ fn mode(app: &App) -> &str {
551+ &app.buds.status.noise_control.as_ref().unwrap().mode
552+ }
553+
554+ fn switch(app: &App, key: &str) -> bool {
555+ app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
556+ }
557+
319558 #[test]
320559 fn noise_cancelling_remembers_its_level() {
321− let mut app = App::new(None, Font::DEFAULT);
322− app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
323− app.update(Message::Noise(NoiseMode::Transparency));
560+ let mut app = app();
561+ let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
562+ let _ = app.update(Message::Noise(NoiseMode::Transparency));
324563 assert_eq!(app.anc_level, AncLevel::Light);
325564 let now = app.buds.status.noise_control.as_ref().unwrap();
326565 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
327− app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
566+ let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
328567 let now = app.buds.status.noise_control.as_ref().unwrap();
329568 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
330569 }
331570
332571 #[test]
333572 fn switches_change_by_key() {
334− let mut app = App::new(None, Font::DEFAULT);
335− app.update(Message::Switch("game", true));
336− let game = app.buds.status.switches.iter().find(|s| s.key == "game").unwrap();
337− assert!(game.on);
573+ let mut app = app();
574+ let _ = app.update(Message::Switch("game", true));
575+ assert!(switch(&app, "game"));
576+ }
577+
578+ #[test]
579+ fn the_overview_items_in_order() {
580+ let mut app = app();
581+ assert_eq!(
582+ app.items(),
583+ [
584+ Item::NoiseMode,
585+ Item::AncLevel,
586+ Item::Switch("game"),
587+ Item::Switch("spatial"),
588+ Item::Switch("bass-boost"),
589+ Item::Switch("wind"),
590+ Item::Switch("dual"),
591+ Item::Open(Page::Equalizer),
592+ Item::Open(Page::Gestures),
593+ Item::Open(Page::Earbuds),
594+ ]
595+ );
596+ let _ = app.update(Message::Noise(NoiseMode::Off));
597+ assert!(!app.items().contains(&Item::AncLevel));
598+ }
599+
600+ #[test]
601+ fn the_first_key_only_shows_the_cursor() {
602+ let mut app = app();
603+ assert_eq!(app.focus(), None);
604+ press(&mut app, "l");
605+ assert_eq!(app.focus(), Some(Item::NoiseMode));
606+ assert_eq!(mode(&app), "anc");
607+ }
608+
609+ #[test]
610+ fn h_and_l_step_through_choices_and_enter_goes_round() {
611+ let mut app = app();
612+ press(&mut app, "j l");
613+ assert_eq!(mode(&app), "transparency");
614+ press(&mut app, "l");
615+ assert_eq!(mode(&app), "transparency");
616+ press(&mut app, "h h h");
617+ assert_eq!(mode(&app), "off");
618+ press(&mut app, "Enter Enter");
619+ assert_eq!(mode(&app), "transparency");
620+ press(&mut app, "Enter");
621+ assert_eq!(mode(&app), "off");
622+ // On the strength: deep at first.
623+ press(&mut app, "l j");
624+ assert_eq!(app.focus(), Some(Item::AncLevel));
625+ press(&mut app, "h h h");
626+ assert_eq!(app.anc_level, AncLevel::Light);
627+ press(&mut app, "Enter");
628+ assert_eq!(app.anc_level, AncLevel::Medium);
629+ assert_eq!(
630+ app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
631+ Some("medium")
632+ );
633+ }
634+
635+ #[test]
636+ fn switches_take_h_l_and_enter() {
637+ let mut app = app();
638+ press(&mut app, "j j j");
639+ assert_eq!(app.focus(), Some(Item::Switch("game")));
640+ press(&mut app, "l");
641+ assert!(switch(&app, "game"));
642+ press(&mut app, "l");
643+ assert!(switch(&app, "game"));
644+ press(&mut app, "h");
645+ assert!(!switch(&app, "game"));
646+ press(&mut app, "Enter");
647+ assert!(switch(&app, "game"));
648+ }
649+
650+ #[test]
651+ fn moving_stops_at_the_ends() {
652+ let mut app = app();
653+ press(&mut app, "k k");
654+ assert_eq!(app.focus(), Some(Item::NoiseMode));
655+ press(&mut app, "G j j");
656+ assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
657+ press(&mut app, "g g");
658+ assert_eq!(app.focus(), Some(Item::NoiseMode));
659+ }
660+
661+ #[test]
662+ fn rows_open_pages_and_the_cursor_starts_over() {
663+ let mut app = app();
664+ press(&mut app, "G G h");
665+ assert_eq!(app.page, Page::Overview);
666+ press(&mut app, "l");
667+ assert_eq!(app.page, Page::Earbuds);
668+ assert_eq!(app.cursor, 0);
669+ assert!(app.cursor_shown);
670+ }
671+
672+ #[test]
673+ fn pages_by_key() {
674+ let mut app = app();
675+ press(&mut app, "J");
676+ assert_eq!(app.page, Page::Equalizer);
677+ press(&mut app, "K K");
678+ assert_eq!(app.page, Page::Settings);
679+ press(&mut app, "1");
680+ assert_eq!(app.page, Page::Overview);
681+ press(&mut app, "g t");
682+ assert_eq!(app.page, Page::Equalizer);
683+ press(&mut app, "g T");
684+ assert_eq!(app.page, Page::Overview);
685+ press(&mut app, "9");
686+ assert_eq!(app.page, Page::Overview);
687+ }
688+
689+ #[test]
690+ fn esc_and_clicks_hide_the_cursor() {
691+ let mut app = app();
692+ press(&mut app, "j j");
693+ assert!(app.focus().is_some());
694+ press(&mut app, "Esc");
695+ assert_eq!(app.focus(), None);
696+ press(&mut app, "j");
697+ let _ = app.update(Message::Pointer);
698+ assert_eq!(app.focus(), None);
699+ // It comes back where it was.
700+ press(&mut app, "j");
701+ assert_eq!(app.focus(), Some(Item::AncLevel));
702+ }
703+
704+ #[test]
705+ fn the_list_of_keys_closes_on_any_key() {
706+ let mut app = app();
707+ press(&mut app, "?");
708+ assert!(app.help);
709+ press(&mut app, "j");
710+ assert!(!app.help);
711+ assert_eq!(app.focus(), None);
712+ press(&mut app, "?");
713+ let _ = app.update(Message::CloseHelp);
714+ assert!(!app.help);
715+ }
716+
717+ #[test]
718+ fn steps() {
719+ let abc = ['a', 'b', 'c'];
720+ assert_eq!(step(&abc, Some(0), Action::Left), None);
721+ assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
722+ assert_eq!(step(&abc, Some(2), Action::Right), None);
723+ assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
724+ assert_eq!(step(&abc, None, Action::Left), Some('a'));
338725 }
339726
340727 #[test]
@@ -355,13 +742,20 @@ mod tests {
355742 let _ = fs::remove_dir_all(&dir);
356743 let font = theme::system_font();
357744 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
358− let shots = Page::ALL
359− .into_iter()
360− .map(|page| (page, 800.0, format!("{page:?}").to_lowercase()))
361− .chain([(Page::Overview, 1240.0, "overview-full".to_owned())]);
362− for (page, height, name) in shots {
745+ let on = |page: Page, keys: &str| {
363746 let mut app = App::new(palette.clone(), font);
364747 app.page = page;
748+ press(&mut app, keys);
749+ app
750+ };
751+ let mut shots: Vec<(String, f32, App)> = Page::ALL
752+ .into_iter()
753+ .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
754+ .collect();
755+ shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
756+ shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
757+ shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
758+ for (name, height, app) in shots {
365759 let settings = iced::Settings {
366760 default_font: font,
367761 ..iced::Settings::default()
M crates/budctl-gui/src/overview.rs View
@@ -8,18 +8,33 @@ use crate::demo::Buds;
88 use crate::icons::{self, Icon, Part, RingColors};
99 use crate::theme::Look;
1010 use crate::ui::{self, Segment};
11−use crate::{Message, Page};
11+use crate::{Item, Message, Page};
1212
13−pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel) -> Element<'a, Message> {
13+/// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them.
14+pub fn items(buds: &Buds) -> Vec<Item> {
15+ let mut items = Vec::new();
16+ if let Some(now) = &buds.status.noise_control {
17+ items.push(Item::NoiseMode);
18+ if now.mode == NoiseMode::Anc(AncLevel::Deep).name() {
19+ items.push(Item::AncLevel);
20+ }
21+ }
22+ items.extend(shown(&buds.status.switches).into_iter().map(|s| Item::Switch(s.key)));
23+ items.extend(MORE.map(|(page, _, _)| Item::Open(page)));
24+ items
25+}
26+
27+/// `focus` is the item under the keyboard cursor, if it shows.
28+pub fn view<'a>(look: Look, buds: &'a Buds, anc_level: AncLevel, focus: Option<Item>) -> Element<'a, Message> {
1429 column![ui::title(look, "Overview"), battery(look, buds)]
1530 .push(
1631 buds.status
1732 .noise_control
1833 .as_ref()
19− .map(|now| noise_control(look, &now.mode, anc_level)),
34+ .map(|now| noise_control(look, &now.mode, anc_level, focus)),
2035 )
21− .push(switches(look, &buds.status.switches))
22− .push(more(look, buds))
36+ .push(switches(look, &buds.status.switches, focus))
37+ .push(more(look, buds, focus))
2338 .spacing(18)
2439 .into()
2540 }
@@ -65,7 +80,7 @@ fn battery<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
6580 ui::card(look, vec![container(gauges).padding([22, 8]).into()])
6681 }
6782
68−fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Element<'a, Message> {
83+fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel, focus: Option<Item>) -> Element<'a, Message> {
6984 let modes = [
7085 (Icon::NoiseOff, "Off", NoiseMode::Off),
7186 (Icon::Anc, "Noise cancelling", NoiseMode::Anc(anc_level)),
@@ -95,11 +110,11 @@ fn noise_control<'a>(look: Look, mode: &str, anc_level: AncLevel) -> Element<'a,
95110 .collect();
96111 let strength = row![
97112 text("Strength").size(13.5).color(look.p.dim).width(Length::Fill),
98− container(ui::segmented(look, levels, false)).width(300)
113+ container(ui::segmented(look, levels, false, focus == Some(Item::AncLevel))).width(300)
99114 ]
100115 .align_y(Alignment::Center)
101116 .padding([0, 8]);
102− column![ui::segmented(look, segments, true)]
117+ column![ui::segmented(look, segments, true, focus == Some(Item::NoiseMode))]
103118 .push((mode == NoiseMode::Anc(anc_level).name()).then_some(strength))
104119 .spacing(12)
105120 .into()
@@ -115,8 +130,8 @@ const SWITCHES: [(&str, Icon, &str); 5] = [
115130 ("dual", Icon::Dual, "Stay connected to two devices at once"),
116131 ];
117132
118−fn switches<'a>(look: Look, switches: &[Switch]) -> Element<'a, Message> {
119− let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key);
133+/// The switches in the order the page shows them.
134+fn shown(switches: &[Switch]) -> Vec<&Switch> {
120135 let rank = |s: &&Switch| {
121136 SWITCHES
122137 .iter()
@@ -125,7 +140,12 @@ fn switches<'a>(look: Look, switches: &[Switch]) -> Element<'a, Message> {
125140 };
126141 let mut shown: Vec<&Switch> = switches.iter().collect();
127142 shown.sort_by_key(rank);
128− let rows = shown
143+ shown
144+}
145+
146+fn switches<'a>(look: Look, switches: &[Switch], focus: Option<Item>) -> Element<'a, Message> {
147+ let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key);
148+ let rows = shown(switches)
129149 .into_iter()
130150 .map(|s| {
131151 let (icon, subtitle) = known(s.key).map_or((Icon::Settings, None), |&(_, icon, subtitle)| {
@@ -138,34 +158,39 @@ fn switches<'a>(look: Look, switches: &[Switch]) -> Element<'a, Message> {
138158 subtitle,
139159 ui::switch(look, s.on),
140160 Message::Switch(s.key, !s.on),
161+ focus == Some(Item::Switch(s.key)),
141162 )
142163 })
143164 .collect();
144165 ui::card(look, rows)
145166 }
146167
147−fn more<'a>(look: Look, buds: &Buds) -> Element<'a, Message> {
148− let open = |icon, title, subtitle: &str, page| {
149− ui::setting(
150− look,
151− icon,
152− title,
153− Some(subtitle.to_owned()),
154− ui::chevron(look),
155− Message::Open(page),
156− )
157− };
158− ui::card(
159− look,
160− vec![
161− open(Icon::Equalizer, "Equalizer", &buds.eq_name, Page::Equalizer),
162− open(Icon::Gestures, "Gestures", "Taps and holds on each bud", Page::Gestures),
163− open(
164− Icon::Earbud,
165− "Earbud settings",
166− "Find my earbuds, firmware",
167− Page::Earbuds,
168− ),
169− ],
170− )
168+/// The rows that open other pages: the page, its icon and title.
169+const MORE: [(Page, Icon, &str); 3] = [
170+ (Page::Equalizer, Icon::Equalizer, "Equalizer"),
171+ (Page::Gestures, Icon::Gestures, "Gestures"),
172+ (Page::Earbuds, Icon::Earbud, "Earbud settings"),
173+];
174+
175+fn more<'a>(look: Look, buds: &Buds, focus: Option<Item>) -> Element<'a, Message> {
176+ let rows = MORE
177+ .into_iter()
178+ .map(|(page, icon, title)| {
179+ let subtitle = match page {
180+ Page::Equalizer => buds.eq_name.clone(),
181+ Page::Gestures => "Taps and holds on each bud".to_owned(),
182+ _ => "Find my earbuds, firmware".to_owned(),
183+ };
184+ ui::setting(
185+ look,
186+ icon,
187+ title,
188+ Some(subtitle),
189+ ui::chevron(look),
190+ Message::Open(page),
191+ focus == Some(Item::Open(page)),
192+ )
193+ })
194+ .collect();
195+ ui::card(look, rows)
171196 }
M crates/budctl-gui/src/ui.rs View
@@ -38,6 +38,24 @@ pub fn card<'a>(look: Look, rows: Vec<Element<'a, Message>>) -> Element<'a, Mess
3838 .into()
3939 }
4040
41+/// A rounded panel like a card, around content of its own.
42+pub fn panel<'a>(look: Look, content: Element<'a, Message>) -> Element<'a, Message> {
43+ let p = look.p;
44+ container(content)
45+ .padding(24)
46+ .width(Length::Fill)
47+ .style(move |_| container::Style {
48+ background: Some(p.surface.into()),
49+ border: Border {
50+ color: p.line,
51+ width: 1.0,
52+ radius: 18.0.into(),
53+ },
54+ ..container::Style::default()
55+ })
56+ .into()
57+}
58+
4159 fn separator<'a>(look: Look) -> Element<'a, Message> {
4260 let p = look.p;
4361 container(rule::horizontal(1).style(move |_| rule::Style {
@@ -51,7 +69,7 @@ fn separator<'a>(look: Look) -> Element<'a, Message> {
5169 }
5270
5371 /// A setting row: icon, title, an optional subtitle, and something on the right. The whole row
54−/// is the click target.
72+/// is the click target. `focused`: the keyboard cursor is on it.
5573 pub fn setting<'a>(
5674 look: Look,
5775 icon: Icon,
@@ -59,6 +77,7 @@ pub fn setting<'a>(
5977 subtitle: Option<String>,
6078 trailing: Element<'a, Message>,
6179 on_press: Message,
80+ focused: bool,
6281 ) -> Element<'a, Message> {
6382 let p = look.p;
6483 let words = column![text(title).size(15).font(look.medium()).color(p.fg)]
@@ -74,14 +93,23 @@ pub fn setting<'a>(
7493 .padding(0)
7594 .width(Length::Fill)
7695 .style(move |_, status| button::Style {
77− background: hovered(status).then_some(p.surface_high.into()),
96+ background: (focused || hovered(status)).then_some(p.surface_high.into()),
7897 text_color: p.fg,
79− border: Border::default().rounded(14),
98+ border: cursor(p, focused, 14.0),
8099 ..button::Style::default()
81100 })
82101 .into()
83102 }
84103
104+/// The keyboard cursor: a 2 px accent frame. No frame without it.
105+fn cursor(p: crate::theme::Palette, focused: bool, radius: f32) -> Border {
106+ Border {
107+ color: p.accent,
108+ width: if focused { 2.0 } else { 0.0 },
109+ radius: radius.into(),
110+ }
111+}
112+
85113 fn hovered(status: button::Status) -> bool {
86114 matches!(status, button::Status::Hovered | button::Status::Pressed)
87115 }
@@ -119,8 +147,8 @@ pub struct Segment<'a> {
119147 }
120148
121149 /// A pill of choices with the selected one filled, like the noise-control modes. `large` puts an
122−/// icon above each label.
123−pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool) -> Element<'a, Message> {
150+/// icon above each label. `focused`: the keyboard cursor is on it.
151+pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool, focused: bool) -> Element<'a, Message> {
124152 let p = look.p;
125153 let mut choices = Row::new().spacing(4);
126154 for segment in segments {
@@ -158,10 +186,14 @@ pub fn segmented<'a>(look: Look, segments: Vec<Segment<'a>>, large: bool) -> Ele
158186 .width(Length::Fill)
159187 .style(move |_| container::Style {
160188 background: Some(p.surface.into()),
161− border: Border {
162− color: p.line,
163− width: 1.0,
164− radius: 999.0.into(),
189+ border: if focused {
190+ cursor(p, true, 999.0)
191+ } else {
192+ Border {
193+ color: p.line,
194+ width: 1.0,
195+ radius: 999.0.into(),
196+ }
165197 },
166198 ..container::Style::default()
167199 })