Krolyxon
Commit a345a10

Branches

Refs →

budctla345a10

budctl-gui: the Earbud settings and App settings pages

Earbud settings: "Find my earbuds" rings both buds after a "Ring your
buds?" confirmation (only Enter or the Ring button says yes; the sound
is loud), and Stop asks nothing. The buds keep ringing after the
session closes, so the button stays on Stop until it is pressed. Below,
the buds' name, firmware and audio codec.

App settings: where the colors come from (the palette file, or built
in and why), the keys, and what budctl is.

The worker gained Command::Find (Session::find).

No new dependencies.
Author
krolyxon
Date
2026-10-28 00:01 +0530
Parent
3d0b2eb
Commit
a345a10316477b7fa65337f6dd1dc46fbf13006f

+324 −1

7 files changed

  1. A crates/budctl-gui/src/earbuds.rs +67 −0
  2. M crates/budctl-gui/src/icons.rs +37 −0
  3. M crates/budctl-gui/src/main.rs +123 −1
  4. A crates/budctl-gui/src/settings.rs +60 −0
  5. M crates/budctl-gui/src/theme.rs +5 −0
  6. M crates/budctl-gui/src/ui.rs +24 −0
  7. M crates/budctl-gui/src/worker.rs +8 −0
A crates/budctl-gui/src/earbuds.rs View
@@ -0,0 +1,67 @@
1+//! The Earbud settings page: find the buds, and what they run.
2+
3+use iced::Element;
4+use iced::widget::{column, text};
5+
6+use crate::buds::Buds;
7+use crate::icons::Icon;
8+use crate::theme::Look;
9+use crate::ui;
10+use crate::{Item, Marks, Message};
11+
12+/// What the keyboard cursor can rest on.
13+pub fn items() -> Vec<Item> {
14+ vec![Item::Find]
15+}
16+
17+/// `ringing`: the buds were told to ring and not yet to stop.
18+pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Element<'a, Message> {
19+ let p = look.p;
20+ let about = match (marks.pending.contains(&Item::Find), ringing) {
21+ (true, false) => "Starting…",
22+ (true, true) => "Stopping…",
23+ (false, true) => "Ringing: stop it once you have found them",
24+ (false, false) => "Plays a loud sound from both buds",
25+ };
26+ let find = ui::setting(
27+ look,
28+ Icon::Find,
29+ "Find my earbuds",
30+ Some(about.to_owned()),
31+ ui::pill(look, if ringing { "Stop" } else { "Ring" }, ringing, Message::Find),
32+ None,
33+ marks.focus == Some(Item::Find),
34+ );
35+ let value = |value: Option<&'a str>| -> Element<'a, Message> {
36+ text(value.unwrap_or("Unknown")).size(14).color(p.dim).into()
37+ };
38+ let status = &buds.status;
39+ let facts = vec![
40+ ui::setting(look, Icon::Earbud, "Name", None, value(Some(&buds.name)), None, false),
41+ ui::setting(
42+ look,
43+ Icon::Chip,
44+ "Firmware",
45+ None,
46+ value(status.firmware.as_deref()),
47+ None,
48+ false,
49+ ),
50+ ui::setting(
51+ look,
52+ Icon::Info,
53+ "Audio codec",
54+ None,
55+ value(status.codec.as_deref()),
56+ None,
57+ false,
58+ ),
59+ ];
60+ column![
61+ ui::title(look, "Earbud settings"),
62+ ui::card(look, vec![find]),
63+ column![ui::heading(look, "About these buds"), ui::card(look, facts)].spacing(10),
64+ ]
65+ .spacing(18)
66+ .into()
67+}
M crates/budctl-gui/src/icons.rs View
@@ -24,6 +24,10 @@ pub enum Icon {
2424 Chevron,
2525 Bolt,
2626 Check,
27+ Find,
28+ Info,
29+ Chip,
30+ Keys,
2731 }
2832
2933 /// `icon` at `size` pixels, in `color`.
@@ -240,6 +244,39 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
240244 pen.rect(17.5, 8.0, 4.5, 11.0, 1.2);
241245 }
242246 Icon::Chevron => pen.polyline(&[(9.0, 5.5), (15.5, 12.0), (9.0, 18.5)], false),
247+ Icon::Find => {
248+ pen.circle(12.0, 12.0, 6.5);
249+ pen.dot(12.0, 12.0, 2.0);
250+ for (from, to) in [
251+ ((12.0, 2.0), (12.0, 5.0)),
252+ ((12.0, 19.0), (12.0, 22.0)),
253+ ((2.0, 12.0), (5.0, 12.0)),
254+ ((19.0, 12.0), (22.0, 12.0)),
255+ ] {
256+ pen.line(from, to);
257+ }
258+ }
259+ Icon::Info => {
260+ pen.circle(12.0, 12.0, 9.0);
261+ pen.line((12.0, 11.0), (12.0, 16.5));
262+ pen.dot(12.0, 7.8, 1.2);
263+ }
264+ Icon::Chip => {
265+ pen.rect(6.5, 6.5, 11.0, 11.0, 2.0);
266+ for at in [9.5, 14.5] {
267+ pen.line((at, 2.5), (at, 6.5));
268+ pen.line((at, 17.5), (at, 21.5));
269+ pen.line((2.5, at), (6.5, at));
270+ pen.line((17.5, at), (21.5, at));
271+ }
272+ }
273+ Icon::Keys => {
274+ pen.rect(2.5, 6.0, 19.0, 12.0, 2.5);
275+ for x in [6.5, 10.0, 13.5, 17.0] {
276+ pen.dot(x, 10.0, 0.9);
277+ }
278+ pen.line((8.0, 14.0), (16.0, 14.0));
279+ }
243280 Icon::Check => pen.polyline(&[(4.5, 12.5), (9.5, 17.5), (19.5, 7.0)], false),
244281 }
245282 }
M crates/budctl-gui/src/main.rs View
@@ -5,11 +5,13 @@
55 //! (`keys.rs`). `--demo` shows made-up buds instead.
66
77 mod buds;
8+mod earbuds;
89 mod equalizer;
910 mod gestures;
1011 mod icons;
1112 mod keys;
1213 mod overview;
14+mod settings;
1315 mod theme;
1416 mod ui;
1517 mod worker;
@@ -186,6 +188,8 @@ enum Item {
186188 Gesture(u8),
187189 /// A mode of the hold cycle, by its place among the modes the buds offer.
188190 Cycle(u8),
191+ /// Find my earbuds.
192+ Find,
189193 }
190194
191195 /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
@@ -251,6 +255,11 @@ enum Message {
251255 },
252256 /// Put a mode (by its place) in the hold cycle, or take it out.
253257 Cycle(u8, bool),
258+ /// Ring the buds (after a confirmation), or stop them ringing.
259+ Find,
260+ /// The answer to "Ring the buds?".
261+ ConfirmFind(bool),
262+ ShowHelp,
254263 /// Read the buds again.
255264 Refresh,
256265 Worker(Event),
@@ -291,6 +300,12 @@ struct App {
291300 draft: Option<Draft>,
292301 /// A gesture's action picked and not written yet.
293302 touch_draft: Option<TouchDraft>,
303+ /// The buds were told to ring and not yet to stop.
304+ ringing: bool,
305+ /// "Ring the buds?" is open.
306+ confirm_find: bool,
307+ /// Why the palette file could not be used, if it could not.
308+ palette_problem: Option<String>,
294309 }
295310
296311 impl App {
@@ -324,6 +339,9 @@ impl App {
324339 read_at: None,
325340 draft: None,
326341 touch_draft: None,
342+ ringing: false,
343+ confirm_find: false,
344+ palette_problem: None,
327345 };
328346 app.refresh_palette();
329347 app
@@ -349,6 +367,15 @@ impl App {
349367 }
350368 }
351369 Message::Cycle(mode, on) => self.cycle(mode, on),
370+ Message::Find if self.ringing => self.write(Item::Find, Command::Find(false)),
371+ Message::Find => self.confirm_find = true,
372+ Message::ConfirmFind(ring) => {
373+ self.confirm_find = false;
374+ if ring {
375+ self.write(Item::Find, Command::Find(true));
376+ }
377+ }
378+ Message::ShowHelp => self.help = true,
352379 Message::Refresh => self.refresh(),
353380 Message::Worker(event) => self.answer(event),
354381 Message::Focused => {
@@ -399,6 +426,7 @@ impl App {
399426 touch.hold_cycle = Some(modes);
400427 }
401428 }
429+ Command::Find(ring) => self.ringing = ring,
402430 Command::CustomEq(_, bands) => {
403431 if let Some(preset) = self.buds.preset(item_preset(item)) {
404432 let mut preset = preset.clone();
@@ -507,6 +535,11 @@ impl App {
507535 self.save_touch(row);
508536 }
509537 }
538+ Event::Ringing(ring) => {
539+ self.in_flight.pop_front();
540+ self.ringing = ring;
541+ self.link = Link::Live;
542+ }
510543 Event::HoldCycle(modes) => {
511544 self.in_flight.pop_front();
512545 if let Some(touch) = &mut self.buds.touch {
@@ -689,6 +722,11 @@ impl App {
689722 self.help = false;
690723 return Task::none();
691724 }
725+ if self.confirm_find {
726+ // Only Enter rings the buds; any other key says no.
727+ let ring = matches!(key, Key::Named(keyboard::key::Named::Enter));
728+ return self.update(Message::ConfirmFind(ring));
729+ }
692730 match action {
693731 Action::Quit => iced::exit(),
694732 Action::Help => {
@@ -725,6 +763,7 @@ impl App {
725763 Page::Overview if self.has_reading() => overview::items(&self.buds),
726764 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
727765 Page::Gestures if self.has_reading() => gestures::items(&self.buds),
766+ Page::Earbuds if self.has_reading() => earbuds::items(),
728767 _ => Vec::new(),
729768 }
730769 }
@@ -830,6 +869,7 @@ impl App {
830869 save: true,
831870 })
832871 }
872+ Item::Find => (action != Action::Left).then_some(Message::Find),
833873 Item::Cycle(mode) => {
834874 let touch = self.buds.touch.as_ref()?;
835875 let on = touch
@@ -860,9 +900,13 @@ impl App {
860900 fn refresh_palette(&mut self) {
861901 let palette = match self.palette.refresh() {
862902 None => return,
863− Some(Ok(palette)) => palette,
903+ Some(Ok(palette)) => {
904+ self.palette_problem = None;
905+ palette
906+ }
864907 Some(Err(e)) => {
865908 eprintln!("budctl-gui: {e}; using the built-in colors");
909+ self.palette_problem = Some(e);
866910 theme::BUILT_IN
867911 }
868912 };
@@ -916,6 +960,20 @@ impl App {
916960 };
917961 gestures::view(look, &self.buds, self.touch_draft.as_ref(), marks)
918962 }
963+ Page::Earbuds if self.has_reading() => {
964+ let marks = Marks {
965+ focus: self.focus(),
966+ pending: &pending,
967+ };
968+ earbuds::view(look, &self.buds, self.ringing, marks)
969+ }
970+ Page::Settings => {
971+ let colors = settings::Colors {
972+ file: self.palette.file(),
973+ problem: self.palette_problem.as_deref(),
974+ };
975+ settings::view(look, colors)
976+ }
919977 page => column![
920978 ui::title(look, page.title()),
921979 text("This page comes in a later step.").size(14).color(look.p.dim)
@@ -945,6 +1003,8 @@ impl App {
9451003 let window = row![self.sidebar(), ui::divider(look), content];
9461004 if self.help {
9471005 stack![window, self.help()].into()
1006+ } else if self.confirm_find {
1007+ stack![window, self.confirm_find()].into()
9481008 } else {
9491009 window.into()
9501010 }
@@ -1068,6 +1128,33 @@ impl App {
10681128 .into()
10691129 }
10701130
1131+ /// "Ring the buds?", over a dimmed window: the sound is loud enough to hurt in the ears.
1132+ fn confirm_find(&self) -> Element<'_, Message> {
1133+ let look = self.look;
1134+ let p = look.p;
1135+ let words = column![
1136+ text("Ring your buds?").size(22).font(look.bold()).color(p.fg),
1137+ text("Take them out of your ears first: the sound is loud. They ring until you press Stop.")
1138+ .size(14)
1139+ .color(p.dim),
1140+ ]
1141+ .spacing(8);
1142+ let buttons = row![
1143+ space::horizontal(),
1144+ ui::pill(look, "Cancel", false, Message::ConfirmFind(false)),
1145+ ui::pill(look, "Ring", true, Message::ConfirmFind(true)),
1146+ ]
1147+ .spacing(10);
1148+ let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1149+ let backdrop = container(container(dialog).width(480))
1150+ .center(Length::Fill)
1151+ .style(|_| container::Style {
1152+ background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1153+ ..container::Style::default()
1154+ });
1155+ opaque(backdrop)
1156+ }
1157+
10711158 /// The list of keys, over a dimmed window. A key or a click closes it.
10721159 fn help(&self) -> Element<'_, Message> {
10731160 let look = self.look;
@@ -1527,6 +1614,40 @@ mod tests {
15271614 assert_eq!(app.pending(), [Item::Gesture(1)]);
15281615 }
15291616
1617+ #[test]
1618+ fn ringing_needs_a_yes() {
1619+ let mut app = app();
1620+ press(&mut app, "4 j Enter");
1621+ assert_eq!(app.page, Page::Earbuds);
1622+ assert!(app.confirm_find);
1623+ press(&mut app, "Esc");
1624+ assert!(!app.confirm_find);
1625+ assert!(!app.ringing);
1626+ press(&mut app, "Enter j");
1627+ assert!(!app.ringing, "only Enter says yes");
1628+ press(&mut app, "Enter Enter");
1629+ assert!(app.ringing);
1630+ // Stopping asks nothing.
1631+ press(&mut app, "Enter");
1632+ assert!(!app.confirm_find);
1633+ assert!(!app.ringing);
1634+ }
1635+
1636+ #[test]
1637+ fn live_ringing_waits_for_the_buds() {
1638+ let (mut app, commands) = live();
1639+ let _ = commands.try_recv();
1640+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1641+ let _ = app.update(Message::Find);
1642+ let _ = app.update(Message::ConfirmFind(true));
1643+ assert!(matches!(commands.try_recv(), Ok(Command::Find(true))));
1644+ assert!(!app.ringing);
1645+ let _ = app.update(Message::Worker(Event::Ringing(true)));
1646+ assert!(app.ringing);
1647+ let _ = app.update(Message::Find);
1648+ assert!(matches!(commands.try_recv(), Ok(Command::Find(false))));
1649+ }
1650+
15301651 #[test]
15311652 fn steps() {
15321653 let abc = ['a', 'b', 'c'];
@@ -1580,6 +1701,7 @@ mod tests {
15801701 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
15811702 shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k")));
15821703 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
1704+ shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j Enter")));
15831705 let mut connecting = App::new(palette.clone(), font, None, false);
15841706 connecting.link = Link::Connecting;
15851707 shots.push(("connecting".to_owned(), 800.0, connecting));
A crates/budctl-gui/src/settings.rs View
@@ -0,0 +1,60 @@
1+//! The App settings page: where the colors come from, the keys, and what budctl is.
2+
3+use std::path::Path;
4+
5+use iced::Element;
6+use iced::widget::{column, space, text};
7+
8+use crate::Message;
9+use crate::icons::Icon;
10+use crate::theme::Look;
11+use crate::ui;
12+
13+/// Where the colors come from: the palette file followed, if any, and why it could not be used.
14+pub struct Colors<'a> {
15+ pub file: Option<&'a Path>,
16+ pub problem: Option<&'a str>,
17+}
18+
19+pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
20+ let p = look.p;
21+ let source = match (colors.file, colors.problem) {
22+ (_, Some(problem)) => format!("Built in: {problem}"),
23+ (Some(file), None) if file.exists() => format!("From {}", file.display()),
24+ _ => "Built in".to_owned(),
25+ };
26+ let none = || -> Element<'a, Message> { space().into() };
27+ let palette = ui::setting(look, Icon::Settings, "Colors", Some(source), none(), None, false);
28+ let how = text(
29+ "budctl follows a matugen palette with Aurora's keys (bg, fg, primary, surface…): the file given \
30+ with --theme, else ~/.config/budctl/theme.json. It is read again whenever the window gets focus.",
31+ )
32+ .size(13)
33+ .color(p.dim);
34+ let keys = ui::setting(
35+ look,
36+ Icon::Keys,
37+ "Keys",
38+ Some("Vim-like keys work on every page".to_owned()),
39+ ui::pill(look, "Show keys", false, Message::ShowHelp),
40+ None,
41+ false,
42+ );
43+ let about = ui::setting(
44+ look,
45+ Icon::Info,
46+ concat!("budctl ", env!("CARGO_PKG_VERSION")),
47+ Some("GPL-3.0-or-later. No telemetry, no account: it talks to your buds over Bluetooth only.".to_owned()),
48+ none(),
49+ None,
50+ false,
51+ );
52+ column![
53+ ui::title(look, "App settings"),
54+ column![ui::card(look, vec![palette]), how].spacing(10),
55+ ui::card(look, vec![keys]),
56+ ui::card(look, vec![about]),
57+ ]
58+ .spacing(18)
59+ .into()
60+}
M crates/budctl-gui/src/theme.rs View
@@ -145,6 +145,11 @@ impl Source {
145145 Source { file, stamp: None }
146146 }
147147
148+ /// The palette file followed, if any.
149+ pub fn file(&self) -> Option<&Path> {
150+ self.file.as_deref()
151+ }
152+
148153 /// The palette, if the file changed since the last call: built-in when the file is gone, an
149154 /// error when it cannot be used. `None` when nothing changed.
150155 pub fn refresh(&mut self) -> Option<Result<Palette, String>> {
M crates/budctl-gui/src/ui.rs View
@@ -235,6 +235,30 @@ pub fn nav_item<'a>(look: Look, icon: Icon, label: &'a str, open: bool, on_press
235235 .into()
236236 }
237237
238+/// A small rounded button: outlined, or `filled` with the accent.
239+pub fn pill<'a>(look: Look, label: &'a str, filled: bool, on_press: Message) -> Element<'a, Message> {
240+ let p = look.p;
241+ let color = if filled { p.accent_fg } else { p.fg };
242+ button(text(label).size(13.5).font(look.medium()).color(color))
243+ .on_press(on_press)
244+ .padding([7, 16])
245+ .style(move |_, status| button::Style {
246+ background: if filled {
247+ Some(p.accent.into())
248+ } else {
249+ hovered(status).then_some(p.surface_high.into())
250+ },
251+ text_color: color,
252+ border: Border {
253+ color: if filled { p.accent } else { p.line },
254+ width: 1.0,
255+ radius: 999.0.into(),
256+ },
257+ ..button::Style::default()
258+ })
259+ .into()
260+}
261+
238262 /// A wide button with an outline, like "Disconnect".
239263 pub fn outline_button<'a>(look: Look, label: &'a str, on_press: Message) -> Element<'a, Message> {
240264 let p = look.p;
M crates/budctl-gui/src/worker.rs View
@@ -44,6 +44,8 @@ pub enum Command {
4444 },
4545 /// Set the noise-control modes a hold steps through.
4646 HoldCycle(Vec<String>),
47+ /// Start (`true`) or stop ringing the buds.
48+ Find(bool),
4749 }
4850
4951 #[derive(Debug, Clone)]
@@ -66,6 +68,8 @@ pub enum Event {
6668 Touch(Vec<Gesture>),
6769 /// The answer to [`Command::HoldCycle`]: the modes, as read back.
6870 HoldCycle(Vec<String>),
71+ /// The answer to [`Command::Find`]: whether the buds were told to ring.
72+ Ringing(bool),
6973 /// The answer to a command that failed. The session is closed; the next command opens a
7074 /// new one.
7175 Failed { kind: Kind, message: String },
@@ -203,6 +207,10 @@ impl Worker {
203207 .session()
204208 .and_then(|session| session.set_touch(&side, &gesture, &action, false))
205209 .map(|change| Event::Touch(change.gestures)),
210+ Command::Find(ring) => self
211+ .session()
212+ .and_then(|session| session.find(ring))
213+ .map(|()| Event::Ringing(ring)),
206214 Command::HoldCycle(modes) => {
207215 let modes: Vec<&str> = modes.iter().map(String::as_str).collect();
208216 self.session()