Krolyxon
Commit 84c42fc

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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