Krolyxon
Commit 8ec1f82

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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