Krolyxon
Commit e7b881a

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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