Krolyxon
Commit 65fed26

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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