Krolyxon
Commit 6e8f058

Branches

Refs →

budctl6e8f058

budctl-gui: dual connection on the Earbud settings page

The switch was only the last row of the Overview's switches, below the
fold. The Earbud settings page now starts with a Connection card for
it, and the Overview's Earbud settings row names it. The Overview keeps
its switch.

Turning dual connection off asks first, from either place: with two
devices connected, the buds may drop one (W5d tested only one). Only
Enter or the button says yes. Turning it on asks nothing.

No new dependencies.
Author
krolyxon
Date
2026-10-28 01:57 +0530
Parent
047794f
Commit
6e8f058447bfca866f1a3d3e852af885b505159f

+117 −15

3 files changed

  1. M crates/budctl-gui/src/earbuds.rs +36 −11
  2. M crates/budctl-gui/src/main.rs +80 −3
  3. M crates/budctl-gui/src/overview.rs +1 −1
M crates/budctl-gui/src/earbuds.rs View
@@ -1,4 +1,4 @@
1−//! The Earbud settings page: find the buds, and what they run.
1+//! The Earbud settings page: dual connection, find the buds, and what they run.
22
33 use iced::Element;
44 use iced::widget::{column, text};
@@ -9,14 +9,40 @@ use crate::theme::Look;
99 use crate::ui;
1010 use crate::{Item, Marks, Message};
1111
12−/// What the keyboard cursor can rest on.
13−pub fn items() -> Vec<Item> {
14− vec![Item::Find]
12+/// The switch for dual connection, by its key.
13+pub const DUAL: &str = "dual";
14+
15+/// What the keyboard cursor can rest on, top to bottom.
16+pub fn items(buds: &Buds) -> Vec<Item> {
17+ let dual = buds.status.switches.iter().any(|s| s.key == DUAL);
18+ dual.then_some(Item::Switch(DUAL))
19+ .into_iter()
20+ .chain([Item::Find])
21+ .collect()
1522 }
1623
1724 /// `ringing`: the buds were told to ring and not yet to stop.
1825 pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Element<'a, Message> {
1926 let p = look.p;
27+ let dual = buds.status.switches.iter().find(|s| s.key == DUAL).map(|dual| {
28+ let about = if marks.pending.contains(&Item::Switch(DUAL)) {
29+ "Saving…"
30+ } else if dual.on {
31+ "Your buds stay connected to two devices at once, like your phone and this computer"
32+ } else {
33+ "Your buds connect to one device at a time"
34+ };
35+ let row = ui::setting(
36+ look,
37+ Icon::Dual,
38+ "Dual connection",
39+ Some(about.to_owned()),
40+ ui::switch(look, dual.on),
41+ Some(Message::Switch(DUAL, !dual.on)),
42+ marks.focus == Some(Item::Switch(DUAL)),
43+ );
44+ column![ui::heading(look, "Connection"), ui::card(look, vec![row])].spacing(10)
45+ });
2046 let about = match (marks.pending.contains(&Item::Find), ringing) {
2147 (true, false) => "Starting…",
2248 (true, true) => "Stopping…",
@@ -57,11 +83,10 @@ pub fn view<'a>(look: Look, buds: &'a Buds, ringing: bool, marks: Marks) -> Elem
5783 false,
5884 ),
5985 ];
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()
86+ column![ui::title(look, "Earbud settings")]
87+ .push(dual)
88+ .push(column![ui::heading(look, "Find my earbuds"), ui::card(look, vec![find])].spacing(10))
89+ .push(column![ui::heading(look, "About these buds"), ui::card(look, facts)].spacing(10))
90+ .spacing(18)
91+ .into()
6792 }
M crates/budctl-gui/src/main.rs View
@@ -311,6 +311,8 @@ enum Message {
311311 DeletePreset(u8),
312312 /// The answer to "Delete the preset?".
313313 ConfirmDelete(bool),
314+ /// The answer to "Turn dual connection off?".
315+ ConfirmDual(bool),
314316 ShowHelp,
315317 /// Read the buds again.
316318 Refresh,
@@ -366,6 +368,8 @@ struct App {
366368 confirm_delete: Option<u8>,
367369 /// The custom preset whose bands are open on the Equalizer page.
368370 editing: Option<u8>,
371+ /// "Turn dual connection off?" is open.
372+ confirm_dual: bool,
369373 }
370374
371375 impl App {
@@ -406,6 +410,7 @@ impl App {
406410 prompt: None,
407411 confirm_delete: None,
408412 editing: None,
413+ confirm_dual: false,
409414 };
410415 app.refresh_palette();
411416 app
@@ -415,7 +420,15 @@ impl App {
415420 match message {
416421 Message::Open(page) => return self.open(page),
417422 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
423+ // Turning dual connection off may drop one of two connected devices: ask first.
424+ Message::Switch(earbuds::DUAL, false) => self.confirm_dual = true,
418425 Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
426+ Message::ConfirmDual(yes) => {
427+ self.confirm_dual = false;
428+ if yes {
429+ self.write(Item::Switch(earbuds::DUAL), Command::Switch(earbuds::DUAL, false));
430+ }
431+ }
419432 Message::Preset(id) => self.select(id),
420433 Message::Band { preset, hz, gain, save } => {
421434 self.move_band(preset, hz, gain);
@@ -947,6 +960,11 @@ impl App {
947960 }
948961
949962 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
963+ if self.confirm_dual {
964+ // Only Enter turns it off; any other key says no.
965+ let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
966+ return self.update(Message::ConfirmDual(yes));
967+ }
950968 if self.confirm_delete.is_some() {
951969 // Only Enter deletes; any other key says no.
952970 let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
@@ -1008,7 +1026,7 @@ impl App {
10081026 Page::Overview if self.has_reading() => overview::items(&self.buds),
10091027 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds, self.editing),
10101028 Page::Gestures if self.has_reading() => gestures::items(&self.buds),
1011− Page::Earbuds if self.has_reading() => earbuds::items(),
1029+ Page::Earbuds if self.has_reading() => earbuds::items(&self.buds),
10121030 _ => Vec::new(),
10131031 }
10141032 }
@@ -1271,6 +1289,8 @@ impl App {
12711289 stack![window, self.name_prompt(prompt)].into()
12721290 } else if let Some(id) = self.confirm_delete {
12731291 stack![window, self.confirm_delete(id)].into()
1292+ } else if self.confirm_dual {
1293+ stack![window, self.confirm_dual()].into()
12741294 } else {
12751295 window.into()
12761296 }
@@ -1421,6 +1441,34 @@ impl App {
14211441 opaque(backdrop)
14221442 }
14231443
1444+ /// "Turn dual connection off?", over a dimmed window: with two devices connected, the buds may
1445+ /// drop one.
1446+ fn confirm_dual(&self) -> Element<'_, Message> {
1447+ let look = self.look;
1448+ let p = look.p;
1449+ let words = column![
1450+ text("Turn dual connection off?").size(22).font(look.bold()).color(p.fg),
1451+ text("If two devices are connected to your buds, they may drop one of them.")
1452+ .size(14)
1453+ .color(p.dim),
1454+ ]
1455+ .spacing(8);
1456+ let buttons = row![
1457+ space::horizontal(),
1458+ ui::pill(look, "Cancel", false, Message::ConfirmDual(false)),
1459+ ui::pill(look, "Turn off", true, Message::ConfirmDual(true)),
1460+ ]
1461+ .spacing(10);
1462+ let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1463+ let backdrop = container(container(dialog).width(460))
1464+ .center(Length::Fill)
1465+ .style(|_| container::Style {
1466+ background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1467+ ..container::Style::default()
1468+ });
1469+ opaque(backdrop)
1470+ }
1471+
14241472 /// "Delete the preset?", over a dimmed window: its bands are lost.
14251473 fn confirm_delete(&self, id: u8) -> Element<'_, Message> {
14261474 let look = self.look;
@@ -1994,7 +2042,7 @@ mod tests {
19942042 #[test]
19952043 fn ringing_needs_a_yes() {
19962044 let mut app = app();
1997− press(&mut app, "4 j Enter");
2045+ press(&mut app, "4 j j Enter");
19982046 assert_eq!(app.page, Page::Earbuds);
19992047 assert!(app.confirm_find);
20002048 press(&mut app, "Esc");
@@ -2178,6 +2226,32 @@ mod tests {
21782226 assert!(!bands(&app));
21792227 }
21802228
2229+ #[test]
2230+ fn dual_connection_asks_before_going_off() {
2231+ let mut app = app();
2232+ press(&mut app, "4 j");
2233+ assert_eq!(app.focus(), Some(Item::Switch("dual")), "first on Earbud settings");
2234+ press(&mut app, "Enter");
2235+ assert!(app.confirm_dual);
2236+ press(&mut app, "Esc");
2237+ assert!(!app.confirm_dual);
2238+ assert!(switch(&app, "dual"));
2239+ press(&mut app, "Enter Enter");
2240+ assert!(!switch(&app, "dual"));
2241+ // Turning it on asks nothing.
2242+ press(&mut app, "Enter");
2243+ assert!(!app.confirm_dual);
2244+ assert!(switch(&app, "dual"));
2245+
2246+ let (mut app, commands) = live();
2247+ let _ = commands.try_recv();
2248+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2249+ let _ = app.update(Message::Switch("dual", false));
2250+ assert!(commands.try_recv().is_err());
2251+ let _ = app.update(Message::ConfirmDual(true));
2252+ assert!(matches!(commands.try_recv(), Ok(Command::Switch("dual", false))));
2253+ }
2254+
21812255 #[test]
21822256 fn steps() {
21832257 let abc = ['a', 'b', 'c'];
@@ -2235,7 +2309,7 @@ mod tests {
22352309 shots.push(("equalizer-cursor".to_owned(), 1100.0, editing));
22362310 shots.push(("equalizer-pencil".to_owned(), 800.0, on(Page::Equalizer, "j j j j j j")));
22372311 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
2238− shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j Enter")));
2312+ shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j j Enter")));
22392313 shots.push((
22402314 "overview-bass".to_owned(),
22412315 1340.0,
@@ -2247,6 +2321,9 @@ mod tests {
22472321 let _ = naming.update(Message::SubmitName);
22482322 shots.push(("name-prompt".to_owned(), 800.0, naming));
22492323 shots.push(("equalizer-builtin".to_owned(), 1000.0, on(Page::Equalizer, "j l")));
2324+ let mut dual = on(Page::Earbuds, "j");
2325+ let _ = dual.update(Message::Switch("dual", false));
2326+ shots.push(("dual-confirm".to_owned(), 800.0, dual));
22502327 let mut deleting = on(Page::Equalizer, "");
22512328 let _ = deleting.update(Message::DeletePreset(4));
22522329 shots.push(("delete-confirm".to_owned(), 800.0, deleting));
M crates/budctl-gui/src/overview.rs View
@@ -262,7 +262,7 @@ fn more<'a>(look: Look, buds: &Buds, focus: Option<Item>) -> Element<'a, Message
262262 let subtitle = match page {
263263 Page::Equalizer => buds.eq_name(),
264264 Page::Gestures => "Taps and holds on each bud".to_owned(),
265− _ => "Find my earbuds, firmware".to_owned(),
265+ _ => "Dual connection, find my earbuds, firmware".to_owned(),
266266 };
267267 ui::setting(
268268 look,