Krolyxon
Commit eed1fb9

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

2780 lines · 105 KB · Rust

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