Krolyxon
Commit 6d9ef16

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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