Krolyxon
Commit 93bfdf6

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

2467 lines · 93.7 KB · Rust

1//! `budctl-gui`: control wireless earbuds from Linux, in a window.
2//!
3//! A thin layer over budctl-core, like the CLI: `worker.rs` talks to the buds on a thread of its
4//! own, and the window shows what they report. The mouse comes first; vim-like keys work on top
5//! (`keys.rs`). `--demo` shows made-up buds instead.
6
7mod buds;
8mod dots;
9mod earbuds;
10mod equalizer;
11mod gestures;
12mod icons;
13mod kept;
14mod keys;
15mod log;
16mod overview;
17mod settings;
18mod theme;
19mod ui;
20mod worker;
21
22use std::collections::VecDeque;
23use std::path::PathBuf;
24use std::process::ExitCode;
25use std::sync::mpsc::Sender;
26use std::time::{Duration, Instant};
27
28use budctl_core::Kind;
29use budctl_core::bt::BdAddr;
30use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, Switch};
31use iced::keyboard::{self, Key, Modifiers};
32use iced::widget::operation::{self, RelativeOffset};
33use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text, text_input};
34use iced::{Alignment, Border, Element, Length, Size, Subscription, Task, Theme, event, mouse, window};
35
36use keys::Action;
37use theme::Look;
38use worker::{Command, Event, Reading};
39
40const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug]
41
42--device picks the buds when more than one supported pair is connected.
43--demo shows made-up buds, without Bluetooth.
44--debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
45Colors come from --theme, else from ~/.config/budctl/theme.json, else built in. That file is
46written with the built-in colors when missing. It takes the Material 3 keys of a matugen
47palette like Aurora's (primary, tertiary, surfaceLow, outline, …).
48Preferences (default device, idle time, start page): ~/.config/budctl/config.json.
49Each launch logs errors and session events to ~/.local/state/budctl/gui.log.
50Press ? in the window for the keys.";
51
52/// The page's scrolling area, so the cursor can be kept in view.
53const SCROLL: &str = "page";
54/// The name field of the preset-name dialog, so it can take the keyboard.
55const NAME_INPUT: &str = "name";
56
57/// When the window gets focus and the last reading is older than this, the buds are read again:
58/// the phone may have changed something meanwhile.
59const STALE: Duration = Duration::from_secs(30);
60
61fn main() -> ExitCode {
62 let options = match parse_args(std::env::args().skip(1)) {
63 Ok(Args::Run(options)) => options,
64 Ok(Args::Help) => {
65 println!("{USAGE}");
66 return ExitCode::SUCCESS;
67 }
68 Err(message) => {
69 eprintln!("budctl-gui: {message}\n{USAGE}");
70 return ExitCode::from(1);
71 }
72 };
73 if options.debug {
74 budctl_core::debug::enable();
75 }
76 if let Some(file) = log::file()
77 && let Err(e) = log::start(&file)
78 {
79 eprintln!("budctl-gui: cannot write the log {}: {e}", file.display());
80 }
81 let config = load_config();
82 let start_page = Page::named(&config.gui.start_page).unwrap_or_else(|| {
83 warn(format!(
84 "gui.start_page: no page {:?}; opening Overview",
85 config.gui.start_page
86 ));
87 Page::Overview
88 });
89 let idle = Duration::from_secs(config.gui.idle_seconds);
90 let device = options.device.or_else(|| config.device().unwrap_or_default());
91 if options.theme.is_none() {
92 write_built_in_theme();
93 }
94 let theme_file = options.theme.clone().or_else(theme::default_file);
95 let boot = move || {
96 let mut app = App::new(theme_file.clone(), device, options.demo);
97 app.page = start_page;
98 app.idle = idle;
99 app
100 };
101 let mut application = iced::application(boot, App::update, App::view);
102 for font in theme::FONTS {
103 application = application.font(font);
104 }
105 let result = application
106 .title("budctl")
107 .theme(App::theme)
108 .subscription(App::subscription)
109 .default_font(theme::UI)
110 .window(window::Settings {
111 size: Size::new(1040.0, 800.0),
112 min_size: Some(Size::new(760.0, 540.0)),
113 platform_specific: window::settings::PlatformSpecific {
114 application_id: "budctl".to_owned(),
115 ..window::settings::PlatformSpecific::default()
116 },
117 ..window::Settings::default()
118 })
119 .run();
120 match result {
121 Ok(()) => ExitCode::SUCCESS,
122 Err(e) => {
123 eprintln!("budctl-gui: {e}");
124 ExitCode::FAILURE
125 }
126 }
127}
128
129/// A problem worth knowing that does not stop the window: on stderr and in the log.
130fn warn(message: String) {
131 eprintln!("budctl-gui: {message}");
132 log::line(message);
133}
134
135/// The config, with the defaults when it cannot be used.
136fn load_config() -> budctl_core::config::Config {
137 let (config, problem) = budctl_core::config::load();
138 match problem {
139 Some(problem) => warn(format!("{problem}; using the defaults")),
140 None => log::line(format!("config: {config:?}")),
141 }
142 config
143}
144
145/// Write the built-in colors to `~/.config/budctl/theme.json` when there is no such file, as a
146/// start for a matugen template.
147fn write_built_in_theme() {
148 let Some(file) = theme::default_file() else {
149 return;
150 };
151 match budctl_core::config::create(&file, &theme::to_json(&theme::BUILT_IN)) {
152 Ok(()) => log::line(format!("wrote the built-in colors to {}", file.display())),
153 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
154 Err(e) => warn(format!("cannot write {}: {e}", file.display())),
155 }
156}
157
158#[derive(Default)]
159struct Options {
160 theme: Option<PathBuf>,
161 device: Option<BdAddr>,
162 demo: bool,
163 debug: bool,
164}
165
166enum Args {
167 Run(Options),
168 Help,
169}
170
171fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
172 let mut options = Options::default();
173 while let Some(arg) = args.next() {
174 match arg.as_str() {
175 "--theme" => options.theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
176 "--device" => {
177 let address = args.next().ok_or("--device needs an address")?;
178 options.device = Some(address.parse().map_err(|e| format!("--device: {e}"))?);
179 }
180 "--demo" => options.demo = true,
181 "--debug" => options.debug = true,
182 "-h" | "--help" => return Ok(Args::Help),
183 other => return Err(format!("unknown argument {other:?}")),
184 }
185 }
186 Ok(Args::Run(options))
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum Page {
191 Overview,
192 Equalizer,
193 Gestures,
194 Earbuds,
195 Settings,
196}
197
198impl Page {
199 const ALL: [Page; 5] = [
200 Page::Overview,
201 Page::Equalizer,
202 Page::Gestures,
203 Page::Earbuds,
204 Page::Settings,
205 ];
206
207 fn title(self) -> &'static str {
208 match self {
209 Page::Overview => "Overview",
210 Page::Equalizer => "Equalizer",
211 Page::Gestures => "Gestures",
212 Page::Earbuds => "Earbud settings",
213 Page::Settings => "App settings",
214 }
215 }
216
217 /// The page's word in the sidebar, short enough to stand large in dots.
218 fn word(self) -> &'static str {
219 match self {
220 Page::Overview => "overview",
221 Page::Equalizer => "eq",
222 Page::Gestures => "gestures",
223 Page::Earbuds => "buds",
224 Page::Settings => "app",
225 }
226 }
227
228 /// A page by its name in the config: `overview`, `equalizer`, `gestures`, `earbuds`, `settings`.
229 fn named(name: &str) -> Option<Page> {
230 match name {
231 "overview" => Some(Page::Overview),
232 "equalizer" => Some(Page::Equalizer),
233 "gestures" => Some(Page::Gestures),
234 "earbuds" => Some(Page::Earbuds),
235 "settings" => Some(Page::Settings),
236 _ => None,
237 }
238 }
239
240 /// The next page in the sidebar, or the previous one, going round.
241 fn step(self, forward: bool) -> Page {
242 let count = Page::ALL.len();
243 let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
244 let to = if forward { at + 1 } else { at + count - 1 };
245 Page::ALL.get(to % count).copied().unwrap_or(self)
246 }
247}
248
249/// What the keyboard cursor can rest on, and what a write changes.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251enum Item {
252 NoiseMode,
253 AncLevel,
254 /// A switch, by its key.
255 Switch(&'static str),
256 /// A row that opens a page.
257 Open(Page),
258 /// An EQ preset, by its id.
259 Preset(u8),
260 /// One band of a custom EQ preset: the preset's id and the band's frequency.
261 Band(u8, u16),
262 /// A bud's gesture, by its place among the Gestures page's rows.
263 Gesture(u8),
264 /// A mode of the hold cycle, by its place among the modes the buds offer.
265 Cycle(u8),
266 /// Find my earbuds.
267 Find,
268 /// The bass level.
269 Bass,
270 /// "New preset", on the Equalizer page.
271 NewPreset,
272 /// "Rename", on a custom EQ preset's card.
273 Rename(u8),
274 /// The bin on a custom EQ preset's row.
275 Delete(u8),
276 /// The pencil on a custom EQ preset's row: open its bands.
277 Edit(u8),
278 /// "Done", on the open bands.
279 Done,
280}
281
282/// The dialog that asks for a preset's name.
283#[derive(Debug, Clone, PartialEq)]
284struct NamePrompt {
285 target: NameTarget,
286 text: String,
287 /// What is wrong with the name, after a try.
288 problem: Option<String>,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq)]
292enum NameTarget {
293 New,
294 /// A custom preset, by its id.
295 Rename(u8),
296}
297
298/// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
299/// items with a write on its way.
300#[derive(Clone, Copy)]
301struct Marks<'a> {
302 focus: Option<Item>,
303 pending: &'a [Item],
304}
305
306/// A gesture's action picked on the screen and not on the buds yet.
307#[derive(Debug, Clone, PartialEq)]
308struct TouchDraft {
309 /// The gesture, by its place among the Gestures page's rows.
310 row: u8,
311 action: String,
312}
313
314/// Bands of a custom EQ preset moved on the screen and not saved on the buds yet.
315#[derive(Debug, Clone, PartialEq)]
316struct Draft {
317 preset: u8,
318 /// All the preset's bands, as shown.
319 bands: Vec<EqBand>,
320}
321
322/// Where the connection to the buds stands.
323#[derive(Debug, Clone, PartialEq)]
324enum Link {
325 /// Made-up buds (`--demo`): nothing talks to Bluetooth.
326 Demo,
327 /// Looking for the buds, or reading them for the first time.
328 Connecting,
329 /// The buds answered the last command.
330 Live,
331 /// The last command failed.
332 Failed { kind: Kind, message: String },
333}
334
335#[derive(Debug, Clone)]
336enum Message {
337 Open(Page),
338 Noise(NoiseMode),
339 /// A switch's key, and whether to turn it on.
340 Switch(&'static str, bool),
341 /// Select an EQ preset, by its id.
342 Preset(u8),
343 /// Move a band of a custom EQ preset. With `save`, save the preset right away, else when
344 /// [`Message::SaveBands`] comes (the mouse lets go of the slider).
345 Band {
346 preset: u8,
347 hz: u16,
348 gain: i8,
349 save: bool,
350 },
351 /// Save the moved bands of a custom EQ preset.
352 SaveBands(u8),
353 /// Give a gesture (by its row) an action. With `save`, write it right away.
354 Action {
355 row: u8,
356 action: String,
357 save: bool,
358 },
359 /// Put a mode (by its place) in the hold cycle, or take it out.
360 Cycle(u8, bool),
361 /// Ring the buds (after a confirmation), or stop them ringing.
362 Find,
363 /// The answer to "Ring the buds?".
364 ConfirmFind(bool),
365 /// Move the bass level. With `save`, set it on the buds right away, else when
366 /// [`Message::SaveBass`] comes (the mouse lets go of the slider).
367 Bass {
368 level: i8,
369 save: bool,
370 },
371 SaveBass,
372 /// Ask for a new preset's name.
373 NewPreset,
374 /// Ask for a custom preset's (by its id) new name.
375 RenamePreset(u8),
376 NameInput(String),
377 SubmitName,
378 CancelName,
379 /// Open a custom preset's (by its id) bands, or close them if they are open.
380 EditPreset(u8),
381 /// Close the open bands.
382 DoneEditing,
383 /// Delete a custom preset (by its id), after a confirmation.
384 DeletePreset(u8),
385 /// The answer to "Delete the preset?".
386 ConfirmDelete(bool),
387 /// The answer to "Turn dual connection off?".
388 ConfirmDual(bool),
389 ShowHelp,
390 /// Read the buds again.
391 Refresh,
392 Worker(Event),
393 /// The window got focus: the palette file may have changed meanwhile, and so may the buds.
394 Focused,
395 Key(Key, Modifiers),
396 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
397 Pointer,
398 CloseHelp,
399}
400
401struct App {
402 page: Page,
403 buds: buds::Buds,
404 /// The level "Noise cancelling" turns on at: the one last reported or picked.
405 anc_level: AncLevel,
406 look: Look,
407 theme: Theme,
408 palette: theme::Source,
409 keys: keys::Keys,
410 /// Where the keyboard cursor is, among [`App::items`].
411 cursor: usize,
412 /// Whether it shows. Only keys show it; a click hides it.
413 cursor_shown: bool,
414 /// Whether the list of keys is open.
415 help: bool,
416 /// `--device`, else the config's, for the worker.
417 device: Option<BdAddr>,
418 /// How long the worker keeps an idle session open.
419 idle: Duration,
420 link: Link,
421 /// Where commands go, once the worker is up.
422 worker: Option<Sender<Command>>,
423 /// The commands sent and not answered yet, oldest first: the item each one writes, or `None`
424 /// for a read. The worker answers them in order.
425 in_flight: VecDeque<Option<Item>>,
426 /// When the buds were last read.
427 read_at: Option<Instant>,
428 /// Bands moved and not saved yet.
429 draft: Option<Draft>,
430 /// A gesture's action picked and not written yet.
431 touch_draft: Option<TouchDraft>,
432 /// A bass level moved and not set yet.
433 bass_draft: Option<i8>,
434 /// The buds were told to ring and not yet to stop.
435 ringing: bool,
436 /// "Ring the buds?" is open.
437 confirm_find: bool,
438 /// Why the palette file could not be used, if it could not.
439 palette_problem: Option<String>,
440 /// The preset-name dialog, when it is open.
441 prompt: Option<NamePrompt>,
442 /// "Delete the preset?" is open, for this preset.
443 confirm_delete: Option<u8>,
444 /// The custom preset whose bands are open on the Equalizer page.
445 editing: Option<u8>,
446 /// "Turn dual connection off?" is open.
447 confirm_dual: bool,
448}
449
450impl App {
451 fn new(theme_file: Option<PathBuf>, device: Option<BdAddr>, demo: bool) -> Self {
452 let buds = if demo { buds::demo() } else { buds::unknown() };
453 let anc_level = buds
454 .status
455 .noise_control
456 .as_ref()
457 .and_then(|now| now.level.as_deref())
458 .and_then(anc_level)
459 .unwrap_or(AncLevel::Deep);
460 let mut app = App {
461 page: Page::Overview,
462 buds,
463 anc_level,
464 look: Look { p: theme::BUILT_IN },
465 theme: theme::iced_theme(&theme::BUILT_IN),
466 palette: theme::Source::new(theme_file),
467 keys: keys::Keys::default(),
468 cursor: 0,
469 cursor_shown: false,
470 help: false,
471 device,
472 idle: worker::IDLE,
473 link: if demo { Link::Demo } else { Link::Connecting },
474 worker: None,
475 in_flight: VecDeque::new(),
476 read_at: None,
477 draft: None,
478 touch_draft: None,
479 bass_draft: None,
480 ringing: false,
481 confirm_find: false,
482 palette_problem: None,
483 prompt: None,
484 confirm_delete: None,
485 editing: None,
486 confirm_dual: false,
487 };
488 app.refresh_palette();
489 app
490 }
491
492 fn update(&mut self, message: Message) -> Task<Message> {
493 match message {
494 Message::Open(page) => return self.open(page),
495 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
496 // Turning dual connection off may drop one of two connected devices: ask first.
497 Message::Switch(earbuds::DUAL, false) => self.confirm_dual = true,
498 Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
499 Message::ConfirmDual(yes) => {
500 self.confirm_dual = false;
501 if yes {
502 self.write(Item::Switch(earbuds::DUAL), Command::Switch(earbuds::DUAL, false));
503 }
504 }
505 Message::Preset(id) => self.select(id),
506 Message::Band { preset, hz, gain, save } => {
507 self.move_band(preset, hz, gain);
508 if save {
509 self.save_bands(preset);
510 }
511 }
512 Message::SaveBands(preset) => self.save_bands(preset),
513 Message::Action { row, action, save } => {
514 self.touch_draft = Some(TouchDraft { row, action });
515 if save {
516 self.save_touch(row);
517 }
518 }
519 Message::Cycle(mode, on) => self.cycle(mode, on),
520 Message::Find if self.ringing => self.write(Item::Find, Command::Find(false)),
521 Message::Find => self.confirm_find = true,
522 Message::ConfirmFind(ring) => {
523 self.confirm_find = false;
524 if ring {
525 self.write(Item::Find, Command::Find(true));
526 }
527 }
528 Message::ShowHelp => self.help = true,
529 Message::Bass { level, save } => {
530 let (low, high) = self.buds.bass_range.unwrap_or((i8::MIN, i8::MAX));
531 self.bass_draft = Some(level.clamp(low, high));
532 if save {
533 self.save_bass();
534 }
535 }
536 Message::SaveBass => self.save_bass(),
537 Message::NewPreset => return self.ask_name(NameTarget::New, String::new()),
538 Message::RenamePreset(id) => {
539 let name = self.buds.preset(id).map(|p| p.name.clone()).unwrap_or_default();
540 return self.ask_name(NameTarget::Rename(id), name);
541 }
542 Message::NameInput(text) => {
543 if let Some(prompt) = &mut self.prompt {
544 prompt.text = text;
545 prompt.problem = None;
546 }
547 }
548 Message::SubmitName => self.submit_name(),
549 Message::CancelName => self.prompt = None,
550 Message::EditPreset(id) => {
551 self.editing = if self.editing == Some(id) { None } else { Some(id) };
552 }
553 Message::DoneEditing => self.editing = None,
554 Message::DeletePreset(id) => self.confirm_delete = Some(id),
555 Message::ConfirmDelete(yes) => {
556 if let (true, Some(id)) = (yes, self.confirm_delete.take())
557 && let Some(preset) = self.buds.preset(id)
558 {
559 let command = Command::DeleteEq(buds::key(preset));
560 if self.editing == Some(id) {
561 self.editing = None;
562 }
563 self.write(Item::Preset(id), command);
564 }
565 self.confirm_delete = None;
566 }
567 Message::Refresh => self.refresh(),
568 Message::Worker(event) => self.answer(event),
569 Message::Focused => {
570 self.refresh_palette();
571 // The phone may have changed something, or the buds may be back: read again
572 // when the reading is old or the last command failed.
573 let stale = self.read_at.is_some_and(|at| at.elapsed() >= STALE);
574 let failed = matches!(self.link, Link::Failed { .. });
575 if (stale || failed) && self.in_flight.is_empty() {
576 self.refresh();
577 }
578 }
579 Message::Key(key, modifiers) => return self.key(&key, modifiers),
580 Message::Pointer => self.cursor_shown = false,
581 Message::CloseHelp => self.help = false,
582 }
583 Task::none()
584 }
585
586 /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
587 /// answer, the item shows that it is saving and takes no other write.
588 fn write(&mut self, item: Item, command: Command) {
589 if self.link == Link::Demo {
590 match command {
591 Command::Noise(mode) => self.apply_noise(NoiseControl {
592 mode: mode.name().to_owned(),
593 level: match mode {
594 NoiseMode::Anc(level) => Some(level.name().to_owned()),
595 NoiseMode::Off | NoiseMode::Transparency => None,
596 },
597 }),
598 Command::Switch(key, on) => {
599 if let Some(switch) = self.buds.status.switches.iter().find(|s| s.key == key) {
600 let switch = Switch { on, ..switch.clone() };
601 self.apply_switch(switch);
602 }
603 }
604 Command::Eq(preset) => self.buds.set_eq(preset),
605 Command::Touch { side, gesture, action } => {
606 if let Some(entry) = self.buds.touch.as_mut().and_then(|touch| {
607 touch
608 .gestures
609 .iter_mut()
610 .find(|g| g.side == side && g.gesture == gesture && g.group.is_none())
611 }) {
612 entry.action = action;
613 }
614 self.touch_draft = None;
615 }
616 Command::HoldCycle(modes) => {
617 if let Some(touch) = &mut self.buds.touch {
618 touch.hold_cycle = Some(modes);
619 }
620 }
621 Command::Find(ring) => self.ringing = ring,
622 Command::Bass(level) => {
623 self.buds.bass = Some(level);
624 self.bass_draft = None;
625 }
626 Command::CreateEq(name) => {
627 let customs = || {
628 self.buds
629 .eq
630 .iter()
631 .flat_map(|eq| eq.presets.iter())
632 .filter(|p| p.custom)
633 };
634 // As the buds do: the bands of another preset, else the T310's own, all flat.
635 let (bands, range) = customs().next().map_or_else(
636 || (vec![50, 250, 1000, 4000, 8000, 16000], Some((-6, 6))),
637 |model| (model.bands.iter().map(|b| b.hz).collect(), model.range),
638 );
639 let id = customs().map(|p| p.id).max().map_or(4, |id| id.saturating_add(1));
640 self.editing = Some(id);
641 self.buds.set_custom(EqPreset {
642 name,
643 id,
644 custom: true,
645 bands: bands.into_iter().map(|hz| EqBand { hz, gain: 0 }).collect(),
646 range,
647 });
648 }
649 Command::DeleteEq(_) => {
650 let id = item_preset(item);
651 if let Some(eq) = &mut self.buds.eq {
652 eq.presets.retain(|p| !(p.custom && p.id == id));
653 }
654 if self.buds.status.eq.as_deref() == Some(&format!("custom-{id}")) {
655 self.buds.set_eq("pure-raw".to_owned());
656 }
657 self.draft = None;
658 }
659 Command::RenameEq(_, name) => {
660 if let Some(preset) = self.buds.preset(item_preset(item)) {
661 let preset = EqPreset { name, ..preset.clone() };
662 self.buds.set_custom(preset);
663 }
664 }
665 Command::CustomEq(_, bands) => {
666 if let Some(preset) = self.buds.preset(item_preset(item)) {
667 let mut preset = preset.clone();
668 for band in &bands {
669 if let Some(shown) = preset.bands.iter_mut().find(|b| b.hz == band.hz) {
670 shown.gain = band.gain;
671 }
672 }
673 self.buds.set_custom(preset);
674 }
675 self.draft = None;
676 }
677 Command::Read => {}
678 }
679 } else if !self.pending().contains(&item) {
680 self.send(command, Some(item));
681 }
682 }
683
684 /// Reads the buds again; with the made-up buds, starts them over.
685 fn refresh(&mut self) {
686 match self.link {
687 Link::Demo => {
688 self.buds = buds::demo();
689 self.draft = None;
690 self.touch_draft = None;
691 }
692 _ if self.in_flight.contains(&None) => {}
693 _ => {
694 if self.read_at.is_none() {
695 self.link = Link::Connecting;
696 }
697 self.send(Command::Read, None);
698 }
699 }
700 }
701
702 fn send(&mut self, command: Command, item: Option<Item>) {
703 let Some(worker) = &self.worker else {
704 return;
705 };
706 if worker.send(command).is_ok() {
707 self.in_flight.push_back(item);
708 }
709 }
710
711 /// Takes in what the worker reports.
712 fn answer(&mut self, event: Event) {
713 match event {
714 Event::Ready(worker) => {
715 self.worker = Some(worker);
716 self.send(Command::Read, None);
717 }
718 Event::Found(name) => self.buds.name = name,
719 Event::Lost(message) => {
720 self.buds.connected = false;
721 self.link = Link::Failed {
722 kind: Kind::NotFound,
723 message,
724 };
725 }
726 Event::Read(reading) => {
727 self.in_flight.pop_front();
728 self.apply_reading(*reading);
729 }
730 Event::Noise(now) => {
731 self.in_flight.pop_front();
732 self.apply_noise(now);
733 self.link = Link::Live;
734 }
735 Event::Switch(switch) => {
736 self.in_flight.pop_front();
737 self.apply_switch(switch);
738 self.link = Link::Live;
739 }
740 Event::Eq(now) => {
741 self.in_flight.pop_front();
742 self.buds.set_eq(now);
743 self.link = Link::Live;
744 }
745 Event::CustomEq(preset) => {
746 self.in_flight.pop_front();
747 let id = preset.id;
748 self.buds.set_custom(preset);
749 self.link = Link::Live;
750 // Bands moved while this write was on its way go out now.
751 match &self.draft {
752 Some(draft) if draft.preset == id => {
753 if self.buds.preset(id).is_some_and(|saved| saved.bands == draft.bands) {
754 self.draft = None;
755 } else {
756 self.save_bands(id);
757 }
758 }
759 _ => {}
760 }
761 }
762 Event::Touch(gestures) => {
763 let done = self.in_flight.pop_front();
764 if let Some(touch) = &mut self.buds.touch {
765 touch.gestures = gestures;
766 }
767 self.link = Link::Live;
768 // An action picked while this write was on its way goes out now.
769 if let Some(Some(Item::Gesture(row))) = done {
770 self.save_touch(row);
771 }
772 }
773 Event::Presets(eq) => {
774 let done = self.in_flight.pop_front();
775 self.buds.set_presets(eq);
776 self.link = Link::Live;
777 // A new preset opens its bands: it is the one in use.
778 if done == Some(Some(Item::NewPreset)) {
779 self.editing = equalizer::in_use(&self.buds).map(|preset| preset.id);
780 }
781 }
782 Event::Bass(level) => {
783 self.in_flight.pop_front();
784 self.buds.bass = Some(level);
785 self.link = Link::Live;
786 // A level moved while this one was on its way goes out now.
787 self.save_bass();
788 }
789 Event::Ringing(ring) => {
790 self.in_flight.pop_front();
791 self.ringing = ring;
792 self.link = Link::Live;
793 }
794 Event::HoldCycle(modes) => {
795 self.in_flight.pop_front();
796 if let Some(touch) = &mut self.buds.touch {
797 touch.hold_cycle = Some(modes);
798 }
799 self.link = Link::Live;
800 }
801 Event::Failed { kind, message } => {
802 // The buds keep what they had: show that, not what was picked.
803 match self.in_flight.pop_front() {
804 Some(Some(Item::Preset(id))) if self.draft.as_ref().is_some_and(|d| d.preset == id) => {
805 self.draft = None;
806 }
807 Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => {
808 self.touch_draft = None;
809 }
810 Some(Some(Item::Bass)) => self.bass_draft = None,
811 _ => {}
812 }
813 if kind == Kind::NotFound {
814 self.buds.connected = false;
815 }
816 self.link = Link::Failed { kind, message };
817 }
818 }
819 }
820
821 fn apply_reading(&mut self, reading: Reading) {
822 self.buds.eq = reading.eq;
823 self.buds.touch = reading.touch;
824 self.buds.bass_range = reading.bass_range;
825 self.buds.dual = reading.dual;
826 if let Some(level) = reading
827 .status
828 .noise_control
829 .as_ref()
830 .and_then(|now| now.level.as_deref())
831 .and_then(anc_level)
832 {
833 self.anc_level = level;
834 }
835 self.buds.status = reading.status;
836 self.buds.connected = true;
837 self.link = Link::Live;
838 self.read_at = Some(Instant::now());
839 }
840
841 fn apply_noise(&mut self, now: NoiseControl) {
842 if let Some(level) = now.level.as_deref().and_then(anc_level) {
843 self.anc_level = level;
844 }
845 self.buds.status.noise_control = Some(now);
846 }
847
848 fn apply_switch(&mut self, switch: Switch) {
849 if let Some(shown) = self.buds.status.switches.iter_mut().find(|s| s.key == switch.key) {
850 *shown = switch;
851 }
852 }
853
854 /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged. The
855 /// preset in use is left alone.
856 fn select(&mut self, id: u8) {
857 let Some(preset) = self.buds.preset(id) else {
858 return;
859 };
860 if self.buds.status.eq.as_deref() == Some(buds::key(preset).as_str()) {
861 return;
862 }
863 let command = if preset.custom {
864 Command::CustomEq(buds::key(preset), Vec::new())
865 } else {
866 Command::Eq(preset.name.clone())
867 };
868 self.write(Item::Preset(id), command);
869 }
870
871 /// Moves a band on the screen, within the preset's range. Nothing goes to the buds yet.
872 fn move_band(&mut self, id: u8, hz: u16, gain: i8) {
873 let Some(preset) = self.buds.preset(id) else {
874 return;
875 };
876 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
877 let mut bands = match self.draft.take() {
878 Some(draft) if draft.preset == id => draft.bands,
879 _ => preset.bands.clone(),
880 };
881 if let Some(band) = bands.iter_mut().find(|band| band.hz == hz) {
882 band.gain = gain.clamp(low, high);
883 }
884 self.draft = Some(Draft { preset: id, bands });
885 }
886
887 /// Saves the moved bands of a custom preset. If a write to it is on its way, they go out
888 /// when it is answered: the latest bands win.
889 fn save_bands(&mut self, id: u8) {
890 let Some(draft) = self.draft.clone().filter(|draft| draft.preset == id) else {
891 return;
892 };
893 let Some(preset) = self.buds.preset(id) else {
894 return;
895 };
896 if !self.pending().contains(&Item::Preset(id)) {
897 self.write(Item::Preset(id), Command::CustomEq(buds::key(preset), draft.bands));
898 }
899 }
900
901 /// A gesture row: the bud, the gesture, and the actions it takes.
902 fn gesture_row(&self, row: u8) -> Option<(&'static str, String, Vec<String>)> {
903 let touch = self.buds.touch.as_ref()?;
904 let rows = gestures::rows(touch);
905 let found = rows.get(usize::from(row))?;
906 Some((found.side, found.gesture.to_owned(), found.actions.to_vec()))
907 }
908
909 /// The action a gesture row has: the one picked on the screen, else the buds' own.
910 fn gesture_action(&self, row: u8) -> Option<String> {
911 if let Some(draft) = self.touch_draft.as_ref().filter(|draft| draft.row == row) {
912 return Some(draft.action.clone());
913 }
914 let (side, gesture, _) = self.gesture_row(row)?;
915 self.buds.action(side, &gesture).map(str::to_owned)
916 }
917
918 /// Writes the action picked for a gesture row. If a write to it is on its way, the pick
919 /// goes out when that is answered: the latest pick wins.
920 fn save_touch(&mut self, row: u8) {
921 let Some(draft) = self.touch_draft.clone().filter(|draft| draft.row == row) else {
922 return;
923 };
924 let Some((side, gesture, _)) = self.gesture_row(row) else {
925 return;
926 };
927 if self.buds.action(side, &gesture) == Some(draft.action.as_str()) {
928 self.touch_draft = None;
929 } else if !self.pending().contains(&Item::Gesture(row)) {
930 let command = Command::Touch {
931 side: side.to_owned(),
932 gesture,
933 action: draft.action,
934 };
935 self.write(Item::Gesture(row), command);
936 }
937 }
938
939 /// Puts a mode in the hold cycle or takes it out. The buds need two modes to step
940 /// through, so the last two stay.
941 fn cycle(&mut self, mode: u8, on: bool) {
942 let Some(touch) = &self.buds.touch else {
943 return;
944 };
945 let (Some(name), Some(now)) = (touch.cycle_modes.get(usize::from(mode)), &touch.hold_cycle) else {
946 return;
947 };
948 let mut modes: Vec<String> = now.iter().filter(|m| *m != name).cloned().collect();
949 if on {
950 modes.push(name.clone());
951 }
952 let busy = self.pending().iter().any(|item| matches!(item, Item::Cycle(_)));
953 if modes.len() >= 2 && !busy {
954 self.write(Item::Cycle(mode), Command::HoldCycle(modes));
955 }
956 }
957
958 /// Opens the preset-name dialog, with the keyboard in its field.
959 fn ask_name(&mut self, target: NameTarget, text: String) -> Task<Message> {
960 self.prompt = Some(NamePrompt {
961 target,
962 text,
963 problem: None,
964 });
965 operation::focus(NAME_INPUT)
966 }
967
968 /// Creates or renames the preset, if the name will do. What the buds themselves refuse (a
969 /// name too long for them, a fourth preset) comes back as an error.
970 fn submit_name(&mut self) {
971 let Some(prompt) = self.prompt.clone() else {
972 return;
973 };
974 let renaming = match prompt.target {
975 NameTarget::Rename(id) => Some(id),
976 NameTarget::New => None,
977 };
978 let taken = self
979 .buds
980 .custom_names()
981 .any(|(id, name)| name == prompt.text && Some(id) != renaming);
982 let problem = if prompt.text.is_empty() || prompt.text.trim() != prompt.text {
983 Some("A name cannot be empty, or start or end with a space.".to_owned())
984 } else if prompt.text.chars().any(char::is_control) {
985 Some("A name cannot hold control characters.".to_owned())
986 } else if taken {
987 Some(format!("A preset is called {} already.", prompt.text))
988 } else {
989 None
990 };
991 if let Some(problem) = problem {
992 self.prompt = Some(NamePrompt {
993 problem: Some(problem),
994 ..prompt
995 });
996 return;
997 }
998 self.prompt = None;
999 match prompt.target {
1000 NameTarget::New => self.write(Item::NewPreset, Command::CreateEq(prompt.text)),
1001 NameTarget::Rename(id) => {
1002 let unchanged = self.buds.preset(id).is_some_and(|p| p.name == prompt.text);
1003 if let (false, Some(preset)) = (unchanged, self.buds.preset(id)) {
1004 let command = Command::RenameEq(buds::key(preset), prompt.text);
1005 self.write(Item::Preset(id), command);
1006 }
1007 }
1008 }
1009 }
1010
1011 /// Sets the bass level moved on the screen. If a level is on its way, this one goes out
1012 /// when that is answered: the latest level wins.
1013 fn save_bass(&mut self) {
1014 let Some(level) = self.bass_draft else {
1015 return;
1016 };
1017 if self.buds.bass == Some(level) {
1018 self.bass_draft = None;
1019 } else if !self.pending().contains(&Item::Bass) {
1020 self.write(Item::Bass, Command::Bass(level));
1021 }
1022 }
1023
1024 /// The items with a write on its way.
1025 fn pending(&self) -> Vec<Item> {
1026 self.in_flight.iter().flatten().copied().collect()
1027 }
1028
1029 /// Shows `page` from its top, with the cursor on its first item.
1030 fn open(&mut self, page: Page) -> Task<Message> {
1031 self.page = page;
1032 self.cursor = 0;
1033 operation::snap_to(SCROLL, RelativeOffset::START)
1034 }
1035
1036 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
1037 if self.confirm_dual {
1038 // Only Enter turns it off; any other key says no.
1039 let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
1040 return self.update(Message::ConfirmDual(yes));
1041 }
1042 if self.confirm_delete.is_some() {
1043 // Only Enter deletes; any other key says no.
1044 let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
1045 return self.update(Message::ConfirmDelete(yes));
1046 }
1047 if self.prompt.is_some() {
1048 // The name field takes the keys, and Enter in it; only Esc is ours.
1049 if matches!(key, Key::Named(keyboard::key::Named::Escape)) {
1050 self.prompt = None;
1051 }
1052 return Task::none();
1053 }
1054 let Some(action) = self.keys.press(key, modifiers) else {
1055 return Task::none();
1056 };
1057 if self.help {
1058 // The list of keys is in front of everything: a key only closes it.
1059 self.help = false;
1060 return Task::none();
1061 }
1062 if self.confirm_find {
1063 // Only Enter rings the buds; any other key says no.
1064 let ring = matches!(key, Key::Named(keyboard::key::Named::Enter));
1065 return self.update(Message::ConfirmFind(ring));
1066 }
1067 match action {
1068 Action::Quit => iced::exit(),
1069 Action::Help => {
1070 self.help = true;
1071 Task::none()
1072 }
1073 Action::Escape => {
1074 self.cursor_shown = false;
1075 Task::none()
1076 }
1077 Action::Refresh => {
1078 self.refresh();
1079 Task::none()
1080 }
1081 Action::NextPage => self.open(self.page.step(true)),
1082 Action::PreviousPage => self.open(self.page.step(false)),
1083 Action::Page(n) => match Page::ALL.get(n) {
1084 Some(&page) => self.open(page),
1085 None => Task::none(),
1086 },
1087 Action::Down
1088 | Action::Up
1089 | Action::First
1090 | Action::Last
1091 | Action::Left
1092 | Action::Right
1093 | Action::Activate => self.on_cursor(action),
1094 }
1095 }
1096
1097 /// What the current page offers the keyboard cursor, top to bottom.
1098 fn items(&self) -> Vec<Item> {
1099 match self.page {
1100 Page::Overview if self.has_reading() => overview::items(&self.buds),
1101 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds, self.editing),
1102 Page::Gestures if self.has_reading() => gestures::items(&self.buds),
1103 Page::Earbuds if self.has_reading() => earbuds::items(&self.buds),
1104 _ => Vec::new(),
1105 }
1106 }
1107
1108 /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
1109 fn has_reading(&self) -> bool {
1110 self.link == Link::Demo || self.read_at.is_some()
1111 }
1112
1113 /// The item under the cursor, when the cursor shows.
1114 fn focus(&self) -> Option<Item> {
1115 let items = self.items();
1116 let last = items.len().checked_sub(1)?;
1117 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
1118 }
1119
1120 fn on_cursor(&mut self, action: Action) -> Task<Message> {
1121 let items = self.items();
1122 let Some(last) = items.len().checked_sub(1) else {
1123 return Task::none();
1124 };
1125 self.cursor = self.cursor.min(last);
1126 if !self.cursor_shown {
1127 // The first key only shows the cursor, so nothing changes where it cannot be seen.
1128 self.cursor_shown = true;
1129 return self.follow();
1130 }
1131 match action {
1132 Action::Down => self.cursor = (self.cursor + 1).min(last),
1133 Action::Up => self.cursor = self.cursor.saturating_sub(1),
1134 Action::First => self.cursor = 0,
1135 Action::Last => self.cursor = last,
1136 _ => {
1137 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
1138 return match change {
1139 Some(message) => self.update(message),
1140 None => Task::none(),
1141 };
1142 }
1143 }
1144 self.follow()
1145 }
1146
1147 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
1148 fn change(&self, item: Item, action: Action) -> Option<Message> {
1149 match item {
1150 Item::NoiseMode => {
1151 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
1152 let now = self.buds.status.noise_control.as_ref()?;
1153 let at = modes.iter().position(|mode| mode.name() == now.mode);
1154 step(&modes, at, action).map(Message::Noise)
1155 }
1156 Item::AncLevel => {
1157 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
1158 let at = levels.iter().position(|&level| level == self.anc_level);
1159 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
1160 }
1161 Item::Switch(key) => {
1162 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
1163 let want = match action {
1164 Action::Left => false,
1165 Action::Right => true,
1166 _ => !on,
1167 };
1168 (want != on).then_some(Message::Switch(key, want))
1169 }
1170 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
1171 Item::Preset(id) => {
1172 let chosen = self.buds.status.eq.as_deref() == self.buds.preset(id).map(buds::key).as_deref();
1173 (action != Action::Left && !chosen).then_some(Message::Preset(id))
1174 }
1175 Item::Band(id, hz) => {
1176 let preset = self.buds.preset(id)?;
1177 let gain = match &self.draft {
1178 Some(draft) if draft.preset == id => &draft.bands,
1179 _ => &preset.bands,
1180 }
1181 .iter()
1182 .find(|band| band.hz == hz)?
1183 .gain;
1184 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
1185 let to = match action {
1186 Action::Left => gain.checked_sub(1)?.max(low),
1187 Action::Right => gain.checked_add(1)?.min(high),
1188 _ => return None,
1189 };
1190 (to != gain).then_some(Message::Band {
1191 preset: id,
1192 hz,
1193 gain: to,
1194 save: true,
1195 })
1196 }
1197 Item::Gesture(row) => {
1198 let (_, _, actions) = self.gesture_row(row)?;
1199 let now = self.gesture_action(row)?;
1200 let places: Vec<usize> = (0..actions.len()).collect();
1201 let at = actions.iter().position(|a| *a == now);
1202 let to = step(&places, at, action)?;
1203 actions.get(to).map(|action| Message::Action {
1204 row,
1205 action: action.clone(),
1206 save: true,
1207 })
1208 }
1209 Item::Find => (action != Action::Left).then_some(Message::Find),
1210 Item::NewPreset => (action != Action::Left).then_some(Message::NewPreset),
1211 Item::Rename(id) => (action != Action::Left).then_some(Message::RenamePreset(id)),
1212 Item::Delete(id) => (action != Action::Left).then_some(Message::DeletePreset(id)),
1213 Item::Edit(id) => (action != Action::Left).then_some(Message::EditPreset(id)),
1214 Item::Done => (action != Action::Left).then_some(Message::DoneEditing),
1215 Item::Bass => {
1216 let (low, high) = self.buds.bass_range?;
1217 let now = self.bass_draft.or(self.buds.bass);
1218 let to = match (action, now) {
1219 // Not set yet: the first step starts from the middle.
1220 (Action::Left | Action::Right, None) => 0,
1221 (Action::Left, Some(level)) => level.checked_sub(1)?.max(low),
1222 (Action::Right, Some(level)) => level.checked_add(1)?.min(high),
1223 _ => return None,
1224 };
1225 (Some(to) != now).then_some(Message::Bass { level: to, save: true })
1226 }
1227 Item::Cycle(mode) => {
1228 let touch = self.buds.touch.as_ref()?;
1229 let on = touch
1230 .hold_cycle
1231 .as_ref()?
1232 .contains(touch.cycle_modes.get(usize::from(mode))?);
1233 let want = match action {
1234 Action::Left => false,
1235 Action::Right => true,
1236 _ => !on,
1237 };
1238 (want != on).then_some(Message::Cycle(mode, want))
1239 }
1240 }
1241 }
1242
1243 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
1244 /// bottom, the others in between.
1245 fn follow(&self) -> Task<Message> {
1246 let count = self.items().len();
1247 if !self.cursor_shown || count < 2 {
1248 return Task::none();
1249 }
1250 let y = self.cursor as f32 / (count - 1) as f32;
1251 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
1252 }
1253
1254 fn refresh_palette(&mut self) {
1255 let palette = match self.palette.refresh() {
1256 None => return,
1257 Some(Ok(palette)) => {
1258 self.palette_problem = None;
1259 palette
1260 }
1261 Some(Err(e)) => {
1262 warn(format!("{e}; using the built-in colors"));
1263 self.palette_problem = Some(e);
1264 theme::BUILT_IN
1265 }
1266 };
1267 self.look.p = palette;
1268 self.theme = theme::iced_theme(&palette);
1269 }
1270
1271 fn theme(&self) -> Theme {
1272 self.theme.clone()
1273 }
1274
1275 fn subscription(&self) -> Subscription<Message> {
1276 let events = event::listen_with(|event, _status, _window| on_event(event));
1277 if self.link == Link::Demo {
1278 return events;
1279 }
1280 let worker = Subscription::run_with((self.device, self.idle), worker::run).map(Message::Worker);
1281 Subscription::batch([events, worker])
1282 }
1283
1284 fn view(&self) -> Element<'_, Message> {
1285 let look = self.look;
1286 let pending = self.pending();
1287 let page = match self.page {
1288 Page::Overview if self.has_reading() => {
1289 let marks = Marks {
1290 focus: self.focus(),
1291 pending: &pending,
1292 };
1293 overview::view(look, &self.buds, self.anc_level, self.bass_draft, marks)
1294 }
1295 Page::Overview => self.connecting(),
1296 Page::Equalizer if self.has_reading() => {
1297 let marks = Marks {
1298 focus: self.focus(),
1299 pending: &pending,
1300 };
1301 equalizer::view(look, &self.buds, self.editing, self.draft.as_ref(), marks)
1302 }
1303 Page::Gestures if self.has_reading() => {
1304 let marks = Marks {
1305 focus: self.focus(),
1306 pending: &pending,
1307 };
1308 gestures::view(look, &self.buds, self.touch_draft.as_ref(), marks)
1309 }
1310 Page::Earbuds if self.has_reading() => {
1311 let marks = Marks {
1312 focus: self.focus(),
1313 pending: &pending,
1314 };
1315 earbuds::view(look, &self.buds, self.ringing, marks)
1316 }
1317 Page::Settings => {
1318 let colors = settings::Colors {
1319 file: self.palette.file(),
1320 problem: self.palette_problem.as_deref(),
1321 };
1322 settings::view(look, colors)
1323 }
1324 page => column![
1325 ui::title(look, page.title()),
1326 container(ui::note(look, "Connect your buds to see this page.")).padding([0, 18])
1327 ]
1328 .spacing(16)
1329 .into(),
1330 };
1331 let page = column![self.problem(), page].spacing(20);
1332 let p = look.p;
1333 let content = scrollable(
1334 container(container(page).max_width(860))
1335 .center_x(Length::Fill)
1336 .padding([34, 36]),
1337 )
1338 .id(SCROLL)
1339 .height(Length::Fill)
1340 .style(move |theme, status| {
1341 let mut style = scrollable::default(theme, status);
1342 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
1343 rail.background = None;
1344 rail.border = Border::default();
1345 rail.scroller.background = p.s3.into();
1346 rail.scroller.border = Border::default().rounded(4);
1347 }
1348 style
1349 });
1350 let window = container(row![self.sidebar(), content])
1351 .width(Length::Fill)
1352 .height(Length::Fill)
1353 .style(move |_| container::Style {
1354 background: Some(p.bg.into()),
1355 ..container::Style::default()
1356 });
1357 if self.help {
1358 stack![window, self.help()].into()
1359 } else if self.confirm_find {
1360 stack![window, self.confirm_find()].into()
1361 } else if let Some(prompt) = &self.prompt {
1362 stack![window, self.name_prompt(prompt)].into()
1363 } else if let Some(id) = self.confirm_delete {
1364 stack![window, self.confirm_delete(id)].into()
1365 } else if self.confirm_dual {
1366 stack![window, self.confirm_dual()].into()
1367 } else {
1368 window.into()
1369 }
1370 }
1371
1372 /// The Overview before the first reading: connecting, or why it did not work.
1373 fn connecting(&self) -> Element<'_, Message> {
1374 let look = self.look;
1375 let p = look.p;
1376 let (headline, detail) = match &self.link {
1377 Link::Failed {
1378 kind: Kind::NotFound,
1379 message,
1380 } => ("Your buds are not connected", message.as_str()),
1381 Link::Failed {
1382 kind: Kind::Busy,
1383 message,
1384 } => ("The control channel is busy", message.as_str()),
1385 Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
1386 _ => (
1387 "Connecting to your buds…",
1388 "budctl looks for the paired buds it supports and reads them.",
1389 ),
1390 };
1391 let failed = matches!(self.link, Link::Failed { .. });
1392 let retry = failed.then(|| container(ui::wide_button(look, "Try again", Message::Refresh)).width(180));
1393 let state = if failed {
1394 row![ui::dot(p.err, 10.0), ui::label(look, "no link", 11.0, p.err)]
1395 } else {
1396 row![ui::dot(p.live, 10.0), ui::label(look, "searching", 11.0, p.live)]
1397 };
1398 let words = column![
1399 state.spacing(10).align_y(Alignment::Center),
1400 ui::headline(look, headline),
1401 ui::note(look, detail)
1402 ]
1403 .spacing(10);
1404 column![
1405 ui::title(look, "overview · battery, noise control, sound"),
1406 ui::panel(look, column![words].push(retry).spacing(22).into())
1407 ]
1408 .spacing(20)
1409 .into()
1410 }
1411
1412 /// A failure after the buds were read once: what went wrong, above the page.
1413 fn problem(&self) -> Option<Element<'_, Message>> {
1414 let Link::Failed { message, .. } = &self.link else {
1415 return None;
1416 };
1417 // Before the first reading, the Overview says it already.
1418 self.read_at?;
1419 let look = self.look;
1420 let p = look.p;
1421 let content = row![
1422 ui::dot(p.err, 10.0),
1423 container(text(message.as_str()).size(14).color(p.err)).width(Length::Fill),
1424 container(ui::wide_button(look, "Try again", Message::Refresh)).width(150)
1425 ]
1426 .spacing(14)
1427 .align_y(Alignment::Center);
1428 Some(
1429 container(content)
1430 .padding([12, 22])
1431 .width(Length::Fill)
1432 .style(move |_| container::Style {
1433 background: Some(p.s1.into()),
1434 border: Border::default().rounded(999),
1435 ..container::Style::default()
1436 })
1437 .into(),
1438 )
1439 }
1440
1441 fn sidebar(&self) -> Element<'_, Message> {
1442 let look = self.look;
1443 let p = look.p;
1444 let (light, state) = match &self.link {
1445 Link::Demo | Link::Live => (p.fill, "connected"),
1446 Link::Connecting => (p.live, "connecting…"),
1447 Link::Failed { kind: Kind::Busy, .. } => (p.err, "busy"),
1448 Link::Failed { .. } if !self.buds.connected => (p.mute, "not connected"),
1449 Link::Failed { .. } => (p.err, "error"),
1450 };
1451 let device = column![
1452 text(self.buds.name.as_str()).size(18).font(look.semibold()).color(p.fg),
1453 row![
1454 ui::dot(light, 8.0),
1455 ui::label(look, state, 11.0, p.grey),
1456 space::horizontal()
1457 ]
1458 .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
1459 .spacing(8)
1460 .align_y(Alignment::Center),
1461 ui::wide_button(look, "Refresh", Message::Refresh),
1462 ]
1463 .spacing(14)
1464 .padding([0, 10]);
1465 let pages = Page::ALL.into_iter().fold(Column::new().spacing(2), |pages, page| {
1466 pages.push(ui::nav_item(look, page.word(), page == self.page, Message::Open(page)))
1467 });
1468 let hint = container(ui::label(look, "? keys · q quit", 10.5, p.grey)).padding([0, 10]);
1469 container(column![device, pages, space::vertical(), hint].spacing(34))
1470 .padding([34, 18])
1471 .width(272)
1472 .height(Length::Fill)
1473 .into()
1474 }
1475
1476 /// A dialog over the dimmed window: heading, words, and Cancel beside the main action.
1477 fn dialog<'a>(
1478 &self,
1479 heading: String,
1480 words: &'a str,
1481 answer: fn(bool) -> Message,
1482 action: &'a str,
1483 ) -> Element<'a, Message> {
1484 let look = self.look;
1485 let buttons = row![
1486 space::horizontal(),
1487 ui::pill(look, "Cancel", false, answer(false)),
1488 ui::pill(look, action, true, answer(true)),
1489 ]
1490 .spacing(10);
1491 let body = column![ui::headline(look, heading), ui::note(look, words)].spacing(10);
1492 let dialog = ui::panel(look, column![body, buttons].spacing(26).into());
1493 opaque(ui::scrim(look, dialog, 480.0))
1494 }
1495
1496 /// "Ring the buds?": the sound is loud enough to hurt in the ears.
1497 fn confirm_find(&self) -> Element<'_, Message> {
1498 self.dialog(
1499 "Ring your buds?".to_owned(),
1500 "Take them out of your ears first: the sound is loud. They ring until you press Stop.",
1501 Message::ConfirmFind,
1502 "Ring",
1503 )
1504 }
1505
1506 /// "Turn dual connection off?": with two devices connected, the buds may drop one.
1507 fn confirm_dual(&self) -> Element<'_, Message> {
1508 self.dialog(
1509 "Turn dual connection off?".to_owned(),
1510 "If two devices are connected to your buds, they may drop one of them.",
1511 Message::ConfirmDual,
1512 "Turn off",
1513 )
1514 }
1515
1516 /// "Delete the preset?": its bands are lost.
1517 fn confirm_delete(&self, id: u8) -> Element<'_, Message> {
1518 let name = self
1519 .buds
1520 .preset(id)
1521 .map_or("this preset", |preset| preset.name.as_str());
1522 self.dialog(
1523 format!("Delete {name}?"),
1524 "Its bands are lost. The buds keep their other presets.",
1525 Message::ConfirmDelete,
1526 "Delete",
1527 )
1528 }
1529
1530 /// The dialog that asks for a preset's name, over a dimmed window.
1531 fn name_prompt<'a>(&'a self, prompt: &'a NamePrompt) -> Element<'a, Message> {
1532 let look = self.look;
1533 let p = look.p;
1534 let (heading, action) = match prompt.target {
1535 NameTarget::New => ("New preset".to_owned(), "Create"),
1536 NameTarget::Rename(id) => {
1537 let name = self.buds.preset(id).map_or("the preset", |preset| preset.name.as_str());
1538 (format!("Rename {name}"), "Rename")
1539 }
1540 };
1541 let field = text_input("Name", &prompt.text)
1542 .id(NAME_INPUT)
1543 .on_input(Message::NameInput)
1544 .on_submit(Message::SubmitName)
1545 .size(16)
1546 .padding([12, 20])
1547 .style(move |_, _| text_input::Style {
1548 background: p.s2.into(),
1549 border: Border {
1550 color: p.fill,
1551 width: 1.0,
1552 radius: 999.0.into(),
1553 },
1554 icon: p.grey,
1555 placeholder: p.grey,
1556 value: p.fg,
1557 selection: p.container,
1558 });
1559 let problem = prompt
1560 .problem
1561 .as_deref()
1562 .map(|problem| container(text(problem).size(13).color(p.err)).padding([0, 20]));
1563 let buttons = row![
1564 space::horizontal(),
1565 ui::pill(look, "Cancel", false, Message::CancelName),
1566 ui::pill(look, action, true, Message::SubmitName),
1567 ]
1568 .spacing(10);
1569 let dialog = column![ui::headline(look, heading), field]
1570 .push(problem)
1571 .push(buttons)
1572 .spacing(18);
1573 opaque(ui::scrim(look, ui::panel(look, dialog.into()), 480.0))
1574 }
1575
1576 /// The list of keys, over a dimmed window. A key or a click closes it.
1577 fn help(&self) -> Element<'_, Message> {
1578 let look = self.look;
1579 let p = look.p;
1580 let rows = keys::HELP
1581 .iter()
1582 .fold(Column::new().spacing(10), |rows, &(keys, what)| {
1583 rows.push(
1584 row![
1585 text(keys).size(13).font(look.mono()).color(p.fg).width(180),
1586 text(what).size(14).color(p.fg_dim)
1587 ]
1588 .align_y(Alignment::Center),
1589 )
1590 });
1591 let heading = row![ui::num(look, "keys", 40.0, p.fg), ui::dot(p.live, 12.0)]
1592 .spacing(12)
1593 .align_y(Alignment::Center);
1594 let list = ui::panel(look, column![heading, rows].spacing(22).into());
1595 opaque(mouse_area(ui::scrim(look, list, 580.0)).on_press(Message::CloseHelp))
1596 }
1597}
1598
1599/// What a window event means to the window: focus, a key, a click.
1600fn 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
1615/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
1616/// round. With nothing chosen, any of them picks the first.
1617fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
1618 let to = match (action, at) {
1619 (_, None) => 0,
1620 (Action::Left, Some(at)) => at.checked_sub(1)?,
1621 (Action::Right, Some(at)) => at + 1,
1622 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
1623 _ => return None,
1624 };
1625 choices.get(to).copied()
1626}
1627
1628/// The level a [`NoiseControl::level`] names.
1629fn anc_level(name: &str) -> Option<AncLevel> {
1630 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
1631 .into_iter()
1632 .find(|level| level.name() == name)
1633}
1634
1635/// The preset a write to `item` is for.
1636fn item_preset(item: Item) -> u8 {
1637 match item {
1638 Item::Preset(id) | Item::Band(id, _) => id,
1639 _ => 0,
1640 }
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645 use super::*;
1646 use iced::keyboard::key::Named;
1647 use std::fs;
1648 use std::path::Path;
1649 use std::sync::mpsc::{self, Receiver};
1650
1651 /// With the made-up buds.
1652 fn app() -> App {
1653 App::new(None, None, true)
1654 }
1655
1656 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
1657 fn live() -> (App, Receiver<Command>) {
1658 let mut app = App::new(None, None, false);
1659 let (worker, commands) = mpsc::channel();
1660 let _ = app.update(Message::Worker(Event::Ready(worker)));
1661 (app, commands)
1662 }
1663
1664 fn reading() -> Reading {
1665 Reading {
1666 status: buds::demo().status,
1667 eq: buds::demo().eq,
1668 touch: buds::demo().touch,
1669 bass_range: buds::demo().bass_range,
1670 dual: buds::demo().dual,
1671 }
1672 }
1673
1674 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
1675 fn press(app: &mut App, keys: &str) {
1676 for name in keys.split_whitespace() {
1677 let (key, modifiers) = match name {
1678 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
1679 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
1680 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
1681 c => (Key::Character(c.into()), Modifiers::empty()),
1682 };
1683 let _ = app.update(Message::Key(key, modifiers));
1684 }
1685 }
1686
1687 fn mode(app: &App) -> &str {
1688 &app.buds.status.noise_control.as_ref().unwrap().mode
1689 }
1690
1691 fn switch(app: &App, key: &str) -> bool {
1692 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
1693 }
1694
1695 #[test]
1696 fn noise_cancelling_remembers_its_level() {
1697 let mut app = app();
1698 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
1699 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1700 assert_eq!(app.anc_level, AncLevel::Light);
1701 let now = app.buds.status.noise_control.as_ref().unwrap();
1702 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
1703 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
1704 let now = app.buds.status.noise_control.as_ref().unwrap();
1705 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
1706 }
1707
1708 #[test]
1709 fn switches_change_by_key() {
1710 let mut app = app();
1711 let _ = app.update(Message::Switch("game", true));
1712 assert!(switch(&app, "game"));
1713 }
1714
1715 #[test]
1716 fn the_overview_items_in_order() {
1717 let mut app = app();
1718 assert_eq!(
1719 app.items(),
1720 [
1721 Item::NoiseMode,
1722 Item::AncLevel,
1723 Item::Switch("game"),
1724 Item::Switch("spatial"),
1725 Item::Switch("bass-boost"),
1726 Item::Bass,
1727 Item::Switch("wind"),
1728 Item::Switch("dual"),
1729 Item::Open(Page::Equalizer),
1730 Item::Open(Page::Gestures),
1731 Item::Open(Page::Earbuds),
1732 ]
1733 );
1734 let _ = app.update(Message::Noise(NoiseMode::Off));
1735 assert!(!app.items().contains(&Item::AncLevel));
1736 }
1737
1738 #[test]
1739 fn the_first_key_only_shows_the_cursor() {
1740 let mut app = app();
1741 assert_eq!(app.focus(), None);
1742 press(&mut app, "l");
1743 assert_eq!(app.focus(), Some(Item::NoiseMode));
1744 assert_eq!(mode(&app), "anc");
1745 }
1746
1747 #[test]
1748 fn h_and_l_step_through_choices_and_enter_goes_round() {
1749 let mut app = app();
1750 press(&mut app, "j l");
1751 assert_eq!(mode(&app), "transparency");
1752 press(&mut app, "l");
1753 assert_eq!(mode(&app), "transparency");
1754 press(&mut app, "h h h");
1755 assert_eq!(mode(&app), "off");
1756 press(&mut app, "Enter Enter");
1757 assert_eq!(mode(&app), "transparency");
1758 press(&mut app, "Enter");
1759 assert_eq!(mode(&app), "off");
1760 // On the strength: deep at first.
1761 press(&mut app, "l j");
1762 assert_eq!(app.focus(), Some(Item::AncLevel));
1763 press(&mut app, "h h h");
1764 assert_eq!(app.anc_level, AncLevel::Light);
1765 press(&mut app, "Enter");
1766 assert_eq!(app.anc_level, AncLevel::Medium);
1767 assert_eq!(
1768 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
1769 Some("medium")
1770 );
1771 }
1772
1773 #[test]
1774 fn switches_take_h_l_and_enter() {
1775 let mut app = app();
1776 press(&mut app, "j j j");
1777 assert_eq!(app.focus(), Some(Item::Switch("game")));
1778 press(&mut app, "l");
1779 assert!(switch(&app, "game"));
1780 press(&mut app, "l");
1781 assert!(switch(&app, "game"));
1782 press(&mut app, "h");
1783 assert!(!switch(&app, "game"));
1784 press(&mut app, "Enter");
1785 assert!(switch(&app, "game"));
1786 }
1787
1788 #[test]
1789 fn moving_stops_at_the_ends() {
1790 let mut app = app();
1791 press(&mut app, "k k");
1792 assert_eq!(app.focus(), Some(Item::NoiseMode));
1793 press(&mut app, "G j j");
1794 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
1795 press(&mut app, "g g");
1796 assert_eq!(app.focus(), Some(Item::NoiseMode));
1797 }
1798
1799 #[test]
1800 fn rows_open_pages_and_the_cursor_starts_over() {
1801 let mut app = app();
1802 press(&mut app, "G G h");
1803 assert_eq!(app.page, Page::Overview);
1804 press(&mut app, "l");
1805 assert_eq!(app.page, Page::Earbuds);
1806 assert_eq!(app.cursor, 0);
1807 assert!(app.cursor_shown);
1808 }
1809
1810 #[test]
1811 fn pages_by_key() {
1812 let mut app = app();
1813 press(&mut app, "J");
1814 assert_eq!(app.page, Page::Equalizer);
1815 press(&mut app, "K K");
1816 assert_eq!(app.page, Page::Settings);
1817 press(&mut app, "1");
1818 assert_eq!(app.page, Page::Overview);
1819 press(&mut app, "g t");
1820 assert_eq!(app.page, Page::Equalizer);
1821 press(&mut app, "g T");
1822 assert_eq!(app.page, Page::Overview);
1823 press(&mut app, "9");
1824 assert_eq!(app.page, Page::Overview);
1825 }
1826
1827 #[test]
1828 fn esc_and_clicks_hide_the_cursor() {
1829 let mut app = app();
1830 press(&mut app, "j j");
1831 assert!(app.focus().is_some());
1832 press(&mut app, "Esc");
1833 assert_eq!(app.focus(), None);
1834 press(&mut app, "j");
1835 let _ = app.update(Message::Pointer);
1836 assert_eq!(app.focus(), None);
1837 // It comes back where it was.
1838 press(&mut app, "j");
1839 assert_eq!(app.focus(), Some(Item::AncLevel));
1840 }
1841
1842 #[test]
1843 fn the_list_of_keys_closes_on_any_key() {
1844 let mut app = app();
1845 press(&mut app, "?");
1846 assert!(app.help);
1847 press(&mut app, "j");
1848 assert!(!app.help);
1849 assert_eq!(app.focus(), None);
1850 press(&mut app, "?");
1851 let _ = app.update(Message::CloseHelp);
1852 assert!(!app.help);
1853 }
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
1883 #[test]
1884 fn live_buds_are_read_first() {
1885 let (mut app, commands) = live();
1886 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1887 assert_eq!(app.link, Link::Connecting);
1888 assert!(app.items().is_empty(), "nothing to move over before the first reading");
1889 // A second refresh while the read is on its way sends nothing.
1890 press(&mut app, "r");
1891 assert!(commands.try_recv().is_err());
1892
1893 let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1894 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1895 assert_eq!(app.link, Link::Live);
1896 assert_eq!(app.buds.name, "realme Buds T310");
1897 assert!(app.buds.connected);
1898 assert_eq!(app.items().len(), 11);
1899 }
1900
1901 #[test]
1902 fn focus_tries_again_after_a_failure() {
1903 let (mut app, commands) = live();
1904 let _ = commands.try_recv();
1905 let _ = app.update(Message::Worker(Event::Failed {
1906 kind: Kind::NotFound,
1907 message: "realme Buds T310 is paired but not connected".to_owned(),
1908 }));
1909 let _ = app.update(Message::Focused);
1910 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1911 assert_eq!(app.link, Link::Connecting);
1912 // A fresh reading is not read again on focus.
1913 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1914 let _ = app.update(Message::Focused);
1915 assert!(commands.try_recv().is_err());
1916 }
1917
1918 #[test]
1919 fn the_preset_in_use_is_not_written_again() {
1920 let (mut app, commands) = live();
1921 let _ = commands.try_recv();
1922 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1923 let _ = app.update(Message::Preset(4));
1924 assert!(commands.try_recv().is_err(), "Custom1 is in use already");
1925 let _ = app.update(Message::Preset(3));
1926 assert!(matches!(commands.try_recv(), Ok(Command::Eq(name)) if name == "pure-bass"));
1927 }
1928
1929 #[test]
1930 fn live_writes_wait_for_the_buds() {
1931 let (mut app, commands) = live();
1932 let _ = commands.try_recv();
1933 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1934
1935 let _ = app.update(Message::Switch("game", true));
1936 assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1937 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1938 assert_eq!(app.pending(), [Item::Switch("game")]);
1939 // While it is on its way, the same switch takes no other write.
1940 press(&mut app, "j j j Enter");
1941 assert!(commands.try_recv().is_err());
1942
1943 let game = Switch {
1944 key: "game",
1945 label: "Game mode",
1946 on: true,
1947 };
1948 let _ = app.update(Message::Worker(Event::Switch(game)));
1949 assert!(switch(&app, "game"));
1950 assert!(app.pending().is_empty());
1951
1952 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1953 assert!(matches!(
1954 commands.try_recv(),
1955 Ok(Command::Noise(NoiseMode::Transparency))
1956 ));
1957 assert_eq!(mode(&app), "anc");
1958 let now = NoiseControl {
1959 mode: "transparency".to_owned(),
1960 level: None,
1961 };
1962 let _ = app.update(Message::Worker(Event::Noise(now)));
1963 assert_eq!(mode(&app), "transparency");
1964 }
1965
1966 #[test]
1967 fn failures_say_why_and_clear_the_write() {
1968 let (mut app, commands) = live();
1969 let _ = app.update(Message::Worker(Event::Failed {
1970 kind: Kind::Busy,
1971 message: "control channel busy".to_owned(),
1972 }));
1973 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1974 assert!(app.problem().is_none(), "the Overview says it, not a banner");
1975
1976 let _ = commands.try_recv();
1977 let _ = app.update(Message::Refresh);
1978 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1979 assert_eq!(app.link, Link::Connecting);
1980 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1981
1982 let _ = app.update(Message::Switch("dual", false));
1983 let _ = app.update(Message::Worker(Event::Failed {
1984 kind: Kind::NotFound,
1985 message: "the buds closed the control link".to_owned(),
1986 }));
1987 assert!(app.pending().is_empty());
1988 assert!(switch(&app, "dual"));
1989 assert!(!app.buds.connected);
1990 assert!(app.problem().is_some());
1991 }
1992
1993 #[test]
1994 fn presets_and_bands_by_key() {
1995 let mut app = app();
1996 press(&mut app, "J");
1997 assert_eq!(app.page, Page::Equalizer);
1998 press(&mut app, "j l");
1999 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
2000 assert_eq!(app.buds.eq_name(), "Pure raw");
2001 // Custom1's pencil, after the four built-in presets and Custom1's row, opens its bands.
2002 press(&mut app, "j j j j j Enter");
2003 assert_eq!(app.editing, Some(4));
2004 assert_eq!(
2005 app.buds.status.eq.as_deref(),
2006 Some("pure-raw"),
2007 "opening selects nothing"
2008 );
2009 press(&mut app, "G");
2010 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
2011 // Moving a band saves the preset, which puts it in use.
2012 press(&mut app, "l");
2013 assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
2014 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
2015 assert_eq!(last(&app), 3);
2016 press(&mut app, "l l l l l");
2017 assert_eq!(last(&app), 6, "it stops at the top of the range");
2018 }
2019
2020 #[test]
2021 fn band_moves_wait_for_the_write_before_them() {
2022 let (mut app, commands) = live();
2023 let _ = commands.try_recv();
2024 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2025 let _ = app.update(Message::EditPreset(4));
2026 press(&mut app, "J G G l");
2027 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
2028 assert!(matches!(
2029 commands.try_recv(),
2030 Ok(Command::CustomEq(name, bands)) if name == "custom-4" && gain(&bands) == Some(3)
2031 ));
2032 // While that write is on its way, moves only change the screen.
2033 press(&mut app, "l l");
2034 assert!(commands.try_recv().is_err());
2035 assert_eq!(app.draft.as_ref().and_then(|draft| gain(&draft.bands)), Some(5));
2036 // The answer comes: the latest bands go out at once.
2037 let mut saved = buds::demo().preset(4).unwrap().clone();
2038 if let Some(band) = saved.bands.last_mut() {
2039 band.gain = 3;
2040 }
2041 let _ = app.update(Message::Worker(Event::CustomEq(saved.clone())));
2042 assert!(matches!(
2043 commands.try_recv(),
2044 Ok(Command::CustomEq(_, bands)) if gain(&bands) == Some(5)
2045 ));
2046 assert_eq!(app.pending(), [Item::Preset(4)]);
2047 // A failure drops the moves: the screen shows what the buds kept.
2048 let _ = app.update(Message::Worker(Event::Failed {
2049 kind: Kind::Failed,
2050 message: "no reply".to_owned(),
2051 }));
2052 assert_eq!(app.draft, None);
2053 assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3));
2054 }
2055
2056 fn cycle(app: &App) -> Vec<String> {
2057 app.buds.touch.as_ref().unwrap().hold_cycle.clone().unwrap()
2058 }
2059
2060 #[test]
2061 fn gestures_and_the_hold_cycle_by_key() {
2062 let mut app = app();
2063 press(&mut app, "3 j j");
2064 assert_eq!(app.page, Page::Gestures);
2065 assert_eq!(app.focus(), Some(Item::Gesture(1)));
2066 press(&mut app, "l");
2067 assert_eq!(app.buds.action("left", "triple-tap"), Some("next"));
2068 press(&mut app, "h h h");
2069 assert_eq!(
2070 app.buds.action("left", "triple-tap"),
2071 Some("none"),
2072 "it stops at the first"
2073 );
2074
2075 press(&mut app, "G k l");
2076 assert_eq!(app.focus(), Some(Item::Cycle(1)));
2077 assert_eq!(cycle(&app), ["off", "anc", "transparency"]);
2078 press(&mut app, "h");
2079 assert_eq!(cycle(&app), ["off", "anc"]);
2080 press(&mut app, "j h");
2081 assert_eq!(cycle(&app), ["off", "anc"], "a hold needs two modes");
2082 }
2083
2084 #[test]
2085 fn gesture_picks_wait_for_the_write_before_them() {
2086 let (mut app, commands) = live();
2087 let _ = commands.try_recv();
2088 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2089 press(&mut app, "3 j j l");
2090 assert!(matches!(
2091 commands.try_recv(),
2092 Ok(Command::Touch { side, gesture, action })
2093 if side == "left" && gesture == "triple-tap" && action == "next"
2094 ));
2095 // While that write is on its way, picks only change the screen.
2096 press(&mut app, "l l");
2097 assert!(commands.try_recv().is_err());
2098 assert_eq!(app.gesture_action(1).as_deref(), Some("volume-up"));
2099 // The answer comes: the latest pick goes out at once.
2100 let mut table = reading().touch.unwrap().gestures;
2101 table[1].action = "next".to_owned();
2102 let _ = app.update(Message::Worker(Event::Touch(table)));
2103 assert!(matches!(
2104 commands.try_recv(),
2105 Ok(Command::Touch { action, .. }) if action == "volume-up"
2106 ));
2107 assert_eq!(app.pending(), [Item::Gesture(1)]);
2108 }
2109
2110 #[test]
2111 fn ringing_needs_a_yes() {
2112 let mut app = app();
2113 press(&mut app, "4 j j Enter");
2114 assert_eq!(app.page, Page::Earbuds);
2115 assert!(app.confirm_find);
2116 press(&mut app, "Esc");
2117 assert!(!app.confirm_find);
2118 assert!(!app.ringing);
2119 press(&mut app, "Enter j");
2120 assert!(!app.ringing, "only Enter says yes");
2121 press(&mut app, "Enter Enter");
2122 assert!(app.ringing);
2123 // Stopping asks nothing.
2124 press(&mut app, "Enter");
2125 assert!(!app.confirm_find);
2126 assert!(!app.ringing);
2127 }
2128
2129 #[test]
2130 fn live_ringing_waits_for_the_buds() {
2131 let (mut app, commands) = live();
2132 let _ = commands.try_recv();
2133 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2134 let _ = app.update(Message::Find);
2135 let _ = app.update(Message::ConfirmFind(true));
2136 assert!(matches!(commands.try_recv(), Ok(Command::Find(true))));
2137 assert!(!app.ringing);
2138 let _ = app.update(Message::Worker(Event::Ringing(true)));
2139 assert!(app.ringing);
2140 let _ = app.update(Message::Find);
2141 assert!(matches!(commands.try_recv(), Ok(Command::Find(false))));
2142 }
2143
2144 #[test]
2145 fn the_bass_level_by_key() {
2146 let mut app = app();
2147 press(&mut app, "j j j j j j");
2148 assert_eq!(app.focus(), Some(Item::Bass), "under bass boost, on the Overview");
2149 assert_eq!(app.buds.bass, None, "the buds do not say");
2150 press(&mut app, "l");
2151 assert_eq!(app.buds.bass, Some(0), "the first step starts from the middle");
2152 press(&mut app, "l l");
2153 assert_eq!(app.buds.bass, Some(2));
2154 press(&mut app, "h h h h h h h h");
2155 assert_eq!(app.buds.bass, Some(-5), "it stops at the bottom");
2156 }
2157
2158 #[test]
2159 fn bass_moves_wait_for_the_level_before_them() {
2160 let (mut app, commands) = live();
2161 let _ = commands.try_recv();
2162 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2163 let _ = app.update(Message::Bass { level: 2, save: false });
2164 assert!(
2165 commands.try_recv().is_err(),
2166 "a drag sends nothing until the mouse lets go"
2167 );
2168 let _ = app.update(Message::SaveBass);
2169 assert!(matches!(commands.try_recv(), Ok(Command::Bass(2))));
2170 let _ = app.update(Message::Bass { level: 4, save: true });
2171 assert!(commands.try_recv().is_err());
2172 let _ = app.update(Message::Worker(Event::Bass(2)));
2173 assert!(matches!(commands.try_recv(), Ok(Command::Bass(4))));
2174 let _ = app.update(Message::Worker(Event::Bass(4)));
2175 assert_eq!(app.buds.bass, Some(4));
2176 assert_eq!(app.bass_draft, None);
2177 assert!(commands.try_recv().is_err());
2178 }
2179
2180 #[test]
2181 fn presets_are_created_and_renamed() {
2182 let mut app = app();
2183 let _ = app.update(Message::NewPreset);
2184 assert!(app.prompt.is_some());
2185 // Typing goes to the field, not to the cursor.
2186 press(&mut app, "j j");
2187 assert_eq!(app.focus(), None);
2188 let _ = app.update(Message::NameInput(" Gym".to_owned()));
2189 let _ = app.update(Message::SubmitName);
2190 assert!(app.prompt.as_ref().is_some_and(|p| p.problem.is_some()));
2191 let _ = app.update(Message::NameInput("Custom1".to_owned()));
2192 let _ = app.update(Message::SubmitName);
2193 assert!(app.prompt.as_ref().is_some_and(|p| p.problem.is_some()), "taken");
2194 let _ = app.update(Message::NameInput("Gym".to_owned()));
2195 let _ = app.update(Message::SubmitName);
2196 assert_eq!(app.prompt, None);
2197 assert_eq!(app.buds.eq_name(), "Gym", "a new preset is put to use");
2198 let gym = app
2199 .buds
2200 .custom_names()
2201 .find(|(_, name)| *name == "Gym")
2202 .map(|(id, _)| id)
2203 .unwrap();
2204 assert_eq!(app.editing, Some(gym), "and its bands open");
2205
2206 let _ = app.update(Message::RenamePreset(gym));
2207 assert_eq!(app.prompt.as_ref().map(|p| p.text.as_str()), Some("Gym"));
2208 let _ = app.update(Message::NameInput("Run".to_owned()));
2209 let _ = app.update(Message::SubmitName);
2210 assert_eq!(app.buds.preset(gym).map(|p| p.name.as_str()), Some("Run"));
2211
2212 let _ = app.update(Message::RenamePreset(gym));
2213 press(&mut app, "Esc");
2214 assert_eq!(app.prompt, None);
2215
2216 // With every custom preset gone, a new one still gets bands.
2217 for id in [4, gym] {
2218 let _ = app.update(Message::DeletePreset(id));
2219 let _ = app.update(Message::ConfirmDelete(true));
2220 }
2221 assert_eq!(app.buds.custom_names().count(), 0);
2222 let _ = app.update(Message::NewPreset);
2223 let _ = app.update(Message::NameInput("Fresh".to_owned()));
2224 let _ = app.update(Message::SubmitName);
2225 let fresh = equalizer::in_use(&app.buds).unwrap();
2226 assert_eq!((fresh.name.as_str(), fresh.bands.len()), ("Fresh", 6));
2227 }
2228
2229 #[test]
2230 fn live_presets_are_created_and_renamed_by_the_buds() {
2231 let (mut app, commands) = live();
2232 let _ = commands.try_recv();
2233 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2234 let _ = app.update(Message::NewPreset);
2235 let _ = app.update(Message::NameInput("Gym".to_owned()));
2236 let _ = app.update(Message::SubmitName);
2237 assert!(matches!(commands.try_recv(), Ok(Command::CreateEq(name)) if name == "Gym"));
2238 assert_eq!(app.pending(), [Item::NewPreset]);
2239 let mut presets = buds::demo().eq.unwrap();
2240 let mut gym = presets.presets.last().unwrap().clone();
2241 gym.id = 5;
2242 gym.name = "Gym".to_owned();
2243 presets.presets.push(gym);
2244 presets.current = Some("custom-5".to_owned());
2245 let _ = app.update(Message::Worker(Event::Presets(presets)));
2246 assert_eq!(app.buds.eq_name(), "Gym");
2247 assert_eq!(app.editing, Some(5));
2248 assert!(app.pending().is_empty());
2249
2250 let _ = app.update(Message::RenamePreset(4));
2251 let _ = app.update(Message::NameInput("Run".to_owned()));
2252 let _ = app.update(Message::SubmitName);
2253 assert!(matches!(
2254 commands.try_recv(),
2255 Ok(Command::RenameEq(preset, name)) if preset == "custom-4" && name == "Run"
2256 ));
2257 }
2258
2259 #[test]
2260 fn presets_are_deleted_after_a_yes() {
2261 let mut app = app();
2262 let _ = app.update(Message::DeletePreset(4));
2263 assert_eq!(app.confirm_delete, Some(4));
2264 press(&mut app, "j");
2265 assert_eq!(app.confirm_delete, None, "any other key says no");
2266 assert!(app.buds.preset(4).is_some());
2267 let _ = app.update(Message::DeletePreset(4));
2268 press(&mut app, "Enter");
2269 assert!(app.buds.preset(4).is_none());
2270 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
2271
2272 let (mut app, commands) = live();
2273 let _ = commands.try_recv();
2274 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2275 let _ = app.update(Message::DeletePreset(4));
2276 let _ = app.update(Message::ConfirmDelete(true));
2277 assert!(matches!(commands.try_recv(), Ok(Command::DeleteEq(preset)) if preset == "custom-4"));
2278 assert_eq!(app.pending(), [Item::Preset(4)]);
2279 }
2280
2281 #[test]
2282 fn the_pencil_opens_the_bands_and_done_closes_them() {
2283 let mut app = app();
2284 press(&mut app, "J");
2285 let bands = |app: &App| app.items().iter().any(|item| matches!(item, Item::Band(..)));
2286 assert!(!bands(&app));
2287 let _ = app.update(Message::EditPreset(4));
2288 assert!(bands(&app));
2289 let _ = app.update(Message::DoneEditing);
2290 assert!(!bands(&app));
2291 // The pencil again closes them too.
2292 let _ = app.update(Message::EditPreset(4));
2293 let _ = app.update(Message::EditPreset(4));
2294 assert!(!bands(&app));
2295 }
2296
2297 #[test]
2298 fn dual_connection_asks_before_going_off() {
2299 let mut app = app();
2300 press(&mut app, "4 j");
2301 assert_eq!(app.focus(), Some(Item::Switch("dual")), "first on Earbud settings");
2302 press(&mut app, "Enter");
2303 assert!(app.confirm_dual);
2304 press(&mut app, "Esc");
2305 assert!(!app.confirm_dual);
2306 assert!(switch(&app, "dual"));
2307 press(&mut app, "Enter Enter");
2308 assert!(!switch(&app, "dual"));
2309 // Turning it on asks nothing.
2310 press(&mut app, "Enter");
2311 assert!(!app.confirm_dual);
2312 assert!(switch(&app, "dual"));
2313
2314 let (mut app, commands) = live();
2315 let _ = commands.try_recv();
2316 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2317 let _ = app.update(Message::Switch("dual", false));
2318 assert!(commands.try_recv().is_err());
2319 let _ = app.update(Message::ConfirmDual(true));
2320 assert!(matches!(commands.try_recv(), Ok(Command::Switch("dual", false))));
2321 }
2322
2323 #[test]
2324 fn steps() {
2325 let abc = ['a', 'b', 'c'];
2326 assert_eq!(step(&abc, Some(0), Action::Left), None);
2327 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
2328 assert_eq!(step(&abc, Some(2), Action::Right), None);
2329 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
2330 assert_eq!(step(&abc, None, Action::Left), Some('a'));
2331 }
2332
2333 #[test]
2334 fn arguments() {
2335 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
2336 assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
2337 assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
2338 assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
2339 assert!(matches!(
2340 args(&["--theme", "x.json"]),
2341 Ok(Args::Run(Options { theme: Some(_), .. }))
2342 ));
2343 assert!(matches!(
2344 args(&["--device", "88:0E:85:46:FE:5C"]),
2345 Ok(Args::Run(Options { device: Some(_), .. }))
2346 ));
2347 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
2348 assert!(args(&["--theme"]).is_err());
2349 assert!(args(&["--device", "nope"]).is_err());
2350 assert!(args(&["--bogus"]).is_err());
2351 }
2352
2353 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
2354 /// display. `BUDCTL_THEME=<file>` renders with that palette.
2355 #[test]
2356 fn pages_render() {
2357 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
2358 let _ = fs::remove_dir_all(&dir);
2359 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
2360 let on = |page: Page, keys: &str| {
2361 let mut app = App::new(palette.clone(), None, true);
2362 app.page = page;
2363 press(&mut app, keys);
2364 app
2365 };
2366 let mut shots: Vec<(String, f32, App)> = Page::ALL
2367 .into_iter()
2368 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
2369 .collect();
2370 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
2371 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
2372 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
2373 let mut editing = on(Page::Equalizer, "");
2374 let _ = editing.update(Message::EditPreset(4));
2375 press(&mut editing, "G G k k");
2376 shots.push(("equalizer-cursor".to_owned(), 1100.0, editing));
2377 shots.push(("equalizer-pencil".to_owned(), 800.0, on(Page::Equalizer, "j j j j j j")));
2378 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
2379 shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j j Enter")));
2380 shots.push((
2381 "overview-bass".to_owned(),
2382 1340.0,
2383 on(Page::Overview, "j j j j j j l l"),
2384 ));
2385 let mut naming = on(Page::Equalizer, "");
2386 let _ = naming.update(Message::RenamePreset(4));
2387 let _ = naming.update(Message::NameInput("Custom1 ".to_owned()));
2388 let _ = naming.update(Message::SubmitName);
2389 shots.push(("name-prompt".to_owned(), 800.0, naming));
2390 shots.push(("equalizer-builtin".to_owned(), 1000.0, on(Page::Equalizer, "j l")));
2391 let mut dual = on(Page::Earbuds, "j");
2392 let _ = dual.update(Message::Switch("dual", false));
2393 shots.push(("dual-confirm".to_owned(), 800.0, dual));
2394 let mut deleting = on(Page::Equalizer, "");
2395 let _ = deleting.update(Message::DeletePreset(4));
2396 shots.push(("delete-confirm".to_owned(), 800.0, deleting));
2397 let mut connecting = App::new(palette.clone(), None, false);
2398 connecting.link = Link::Connecting;
2399 shots.push(("connecting".to_owned(), 800.0, connecting));
2400 let mut busy = App::new(palette.clone(), None, false);
2401 busy.answer(Event::Failed {
2402 kind: Kind::Busy,
2403 message: "control channel busy: is another app connected?".to_owned(),
2404 });
2405 shots.push(("busy".to_owned(), 800.0, busy));
2406 let (mut saving, _commands) = live();
2407 saving.answer(Event::Read(Box::new(reading())));
2408 saving.answer(Event::Failed {
2409 kind: Kind::Failed,
2410 message: "the buds did not answer: no reply to command 0x0403".to_owned(),
2411 });
2412 let _ = saving.update(Message::Switch("spatial", false));
2413 let _ = saving.update(Message::Noise(NoiseMode::Off));
2414 shots.push(("saving".to_owned(), 1240.0, saving));
2415 render(&dir, shots);
2416 }
2417
2418 /// The README's screenshots: the demo buds in the built-in palette, written to
2419 /// `docs/screenshots/` by `cargo test -p budctl-gui readme_screenshots -- --ignored`.
2420 #[test]
2421 #[ignore = "writes into docs/; run it when the look changes"]
2422 fn readme_screenshots() {
2423 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
2424 let scratch = root.join("target/readme-screenshots");
2425 let _ = fs::remove_dir_all(&scratch);
2426 let on = |page: Page, keys: &str| {
2427 let mut app = App::new(None, None, true);
2428 app.page = page;
2429 press(&mut app, keys);
2430 app
2431 };
2432 let mut equalizer = on(Page::Equalizer, "");
2433 let _ = equalizer.update(Message::EditPreset(4));
2434 let shots = vec![
2435 ("overview".to_owned(), 1140.0, on(Page::Overview, "")),
2436 ("equalizer".to_owned(), 900.0, equalizer),
2437 ("gestures".to_owned(), 840.0, on(Page::Gestures, "")),
2438 ("earbuds".to_owned(), 800.0, on(Page::Earbuds, "")),
2439 ("keys".to_owned(), 800.0, on(Page::Overview, "?")),
2440 ];
2441 let names: Vec<String> = shots.iter().map(|(name, _, _)| name.clone()).collect();
2442 render(&scratch, shots);
2443 let out = root.join("docs/screenshots");
2444 fs::create_dir_all(&out).unwrap();
2445 for name in names {
2446 fs::rename(
2447 scratch.join(format!("{name}-tiny-skia.png")),
2448 out.join(format!("{name}.png")),
2449 )
2450 .unwrap();
2451 }
2452 }
2453
2454 /// Renders each (name, window height, app) 1040 px wide, to `<dir>/<name>-tiny-skia.png`.
2455 fn render(dir: &Path, shots: Vec<(String, f32, App)>) {
2456 for (name, height, app) in shots {
2457 let settings = iced::Settings {
2458 default_font: theme::UI,
2459 fonts: theme::FONTS.into_iter().map(Into::into).collect(),
2460 ..iced::Settings::default()
2461 };
2462 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
2463 let snapshot = ui.snapshot(&app.theme).unwrap();
2464 assert!(snapshot.matches_image(dir.join(name)).unwrap());
2465 }
2466 }
2467}