Krolyxon
Commit a345a10

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

1733 lines · 63.5 KB · Rust

1//! `budctl-gui`: control wireless earbuds from Linux, in a window.
2//!
3//! A thin layer over budctl-core, like the CLI: `worker.rs` talks to the buds on a thread of its
4//! own, and the window shows what they report. The mouse comes first; vim-like keys work on top
5//! (`keys.rs`). `--demo` shows made-up buds instead.
6
7mod buds;
8mod earbuds;
9mod equalizer;
10mod gestures;
11mod icons;
12mod keys;
13mod overview;
14mod settings;
15mod theme;
16mod ui;
17mod worker;
18
19use std::collections::VecDeque;
20use std::path::PathBuf;
21use std::process::ExitCode;
22use std::sync::mpsc::Sender;
23use std::time::{Duration, Instant};
24
25use budctl_core::Kind;
26use budctl_core::bt::BdAddr;
27use budctl_core::status::{AncLevel, EqBand, NoiseControl, NoiseMode, Switch};
28use iced::keyboard::{self, Key, Modifiers};
29use iced::widget::operation::{self, RelativeOffset};
30use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
31use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window};
32
33use icons::Icon;
34use keys::Action;
35use theme::Look;
36use worker::{Command, Event, Reading};
37
38const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug]
39
40--device picks the buds when more than one supported pair is connected.
41--demo shows made-up buds, without Bluetooth.
42--debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
43Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
44The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
45Press ? in the window for the keys.";
46
47/// The page's scrolling area, so the cursor can be kept in view.
48const SCROLL: &str = "page";
49
50/// When the window gets focus and the last reading is older than this, the buds are read again:
51/// the phone may have changed something meanwhile.
52const STALE: Duration = Duration::from_secs(30);
53
54fn main() -> ExitCode {
55 let options = match parse_args(std::env::args().skip(1)) {
56 Ok(Args::Run(options)) => options,
57 Ok(Args::Help) => {
58 println!("{USAGE}");
59 return ExitCode::SUCCESS;
60 }
61 Err(message) => {
62 eprintln!("budctl-gui: {message}\n{USAGE}");
63 return ExitCode::from(1);
64 }
65 };
66 if options.debug {
67 budctl_core::debug::enable();
68 }
69 let theme_file = options.theme.clone().or_else(theme::default_file);
70 let font = theme::system_font();
71 let boot = move || App::new(theme_file.clone(), font, options.device, options.demo);
72 let result = iced::application(boot, App::update, App::view)
73 .title("budctl")
74 .theme(App::theme)
75 .subscription(App::subscription)
76 .default_font(font)
77 .window(window::Settings {
78 size: Size::new(1040.0, 800.0),
79 min_size: Some(Size::new(760.0, 540.0)),
80 platform_specific: window::settings::PlatformSpecific {
81 application_id: "budctl".to_owned(),
82 ..window::settings::PlatformSpecific::default()
83 },
84 ..window::Settings::default()
85 })
86 .run();
87 match result {
88 Ok(()) => ExitCode::SUCCESS,
89 Err(e) => {
90 eprintln!("budctl-gui: {e}");
91 ExitCode::FAILURE
92 }
93 }
94}
95
96#[derive(Default)]
97struct Options {
98 theme: Option<PathBuf>,
99 device: Option<BdAddr>,
100 demo: bool,
101 debug: bool,
102}
103
104enum Args {
105 Run(Options),
106 Help,
107}
108
109fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
110 let mut options = Options::default();
111 while let Some(arg) = args.next() {
112 match arg.as_str() {
113 "--theme" => options.theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
114 "--device" => {
115 let address = args.next().ok_or("--device needs an address")?;
116 options.device = Some(address.parse().map_err(|e| format!("--device: {e}"))?);
117 }
118 "--demo" => options.demo = true,
119 "--debug" => options.debug = true,
120 "-h" | "--help" => return Ok(Args::Help),
121 other => return Err(format!("unknown argument {other:?}")),
122 }
123 }
124 Ok(Args::Run(options))
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum Page {
129 Overview,
130 Equalizer,
131 Gestures,
132 Earbuds,
133 Settings,
134}
135
136impl Page {
137 const ALL: [Page; 5] = [
138 Page::Overview,
139 Page::Equalizer,
140 Page::Gestures,
141 Page::Earbuds,
142 Page::Settings,
143 ];
144
145 fn title(self) -> &'static str {
146 match self {
147 Page::Overview => "Overview",
148 Page::Equalizer => "Equalizer",
149 Page::Gestures => "Gestures",
150 Page::Earbuds => "Earbud settings",
151 Page::Settings => "App settings",
152 }
153 }
154
155 fn icon(self) -> Icon {
156 match self {
157 Page::Overview => Icon::Overview,
158 Page::Equalizer => Icon::Equalizer,
159 Page::Gestures => Icon::Gestures,
160 Page::Earbuds => Icon::Earbud,
161 Page::Settings => Icon::Settings,
162 }
163 }
164
165 /// The next page in the sidebar, or the previous one, going round.
166 fn step(self, forward: bool) -> Page {
167 let count = Page::ALL.len();
168 let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
169 let to = if forward { at + 1 } else { at + count - 1 };
170 Page::ALL.get(to % count).copied().unwrap_or(self)
171 }
172}
173
174/// What the keyboard cursor can rest on, and what a write changes.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum Item {
177 NoiseMode,
178 AncLevel,
179 /// A switch, by its key.
180 Switch(&'static str),
181 /// A row that opens a page.
182 Open(Page),
183 /// An EQ preset, by its id.
184 Preset(u8),
185 /// One band of a custom EQ preset: the preset's id and the band's frequency.
186 Band(u8, u16),
187 /// A bud's gesture, by its place among the Gestures page's rows.
188 Gesture(u8),
189 /// A mode of the hold cycle, by its place among the modes the buds offer.
190 Cycle(u8),
191 /// Find my earbuds.
192 Find,
193}
194
195/// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the
196/// items with a write on its way.
197#[derive(Clone, Copy)]
198struct Marks<'a> {
199 focus: Option<Item>,
200 pending: &'a [Item],
201}
202
203/// A gesture's action picked on the screen and not on the buds yet.
204#[derive(Debug, Clone, PartialEq)]
205struct TouchDraft {
206 /// The gesture, by its place among the Gestures page's rows.
207 row: u8,
208 action: String,
209}
210
211/// Bands of a custom EQ preset moved on the screen and not saved on the buds yet.
212#[derive(Debug, Clone, PartialEq)]
213struct Draft {
214 preset: u8,
215 /// All the preset's bands, as shown.
216 bands: Vec<EqBand>,
217}
218
219/// Where the connection to the buds stands.
220#[derive(Debug, Clone, PartialEq)]
221enum Link {
222 /// Made-up buds (`--demo`): nothing talks to Bluetooth.
223 Demo,
224 /// Looking for the buds, or reading them for the first time.
225 Connecting,
226 /// The buds answered the last command.
227 Live,
228 /// The last command failed.
229 Failed { kind: Kind, message: String },
230}
231
232#[derive(Debug, Clone)]
233enum Message {
234 Open(Page),
235 Noise(NoiseMode),
236 /// A switch's key, and whether to turn it on.
237 Switch(&'static str, bool),
238 /// Select an EQ preset, by its id.
239 Preset(u8),
240 /// Move a band of a custom EQ preset. With `save`, save the preset right away, else when
241 /// [`Message::SaveBands`] comes (the mouse lets go of the slider).
242 Band {
243 preset: u8,
244 hz: u16,
245 gain: i8,
246 save: bool,
247 },
248 /// Save the moved bands of a custom EQ preset.
249 SaveBands(u8),
250 /// Give a gesture (by its row) an action. With `save`, write it right away.
251 Action {
252 row: u8,
253 action: String,
254 save: bool,
255 },
256 /// Put a mode (by its place) in the hold cycle, or take it out.
257 Cycle(u8, bool),
258 /// Ring the buds (after a confirmation), or stop them ringing.
259 Find,
260 /// The answer to "Ring the buds?".
261 ConfirmFind(bool),
262 ShowHelp,
263 /// Read the buds again.
264 Refresh,
265 Worker(Event),
266 /// The window got focus: the palette file may have changed meanwhile, and so may the buds.
267 Focused,
268 Key(Key, Modifiers),
269 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
270 Pointer,
271 CloseHelp,
272}
273
274struct App {
275 page: Page,
276 buds: buds::Buds,
277 /// The level "Noise cancelling" turns on at: the one last reported or picked.
278 anc_level: AncLevel,
279 look: Look,
280 theme: Theme,
281 palette: theme::Source,
282 keys: keys::Keys,
283 /// Where the keyboard cursor is, among [`App::items`].
284 cursor: usize,
285 /// Whether it shows. Only keys show it; a click hides it.
286 cursor_shown: bool,
287 /// Whether the list of keys is open.
288 help: bool,
289 /// `--device`, for the worker.
290 device: Option<BdAddr>,
291 link: Link,
292 /// Where commands go, once the worker is up.
293 worker: Option<Sender<Command>>,
294 /// The commands sent and not answered yet, oldest first: the item each one writes, or `None`
295 /// for a read. The worker answers them in order.
296 in_flight: VecDeque<Option<Item>>,
297 /// When the buds were last read.
298 read_at: Option<Instant>,
299 /// Bands moved and not saved yet.
300 draft: Option<Draft>,
301 /// A gesture's action picked and not written yet.
302 touch_draft: Option<TouchDraft>,
303 /// The buds were told to ring and not yet to stop.
304 ringing: bool,
305 /// "Ring the buds?" is open.
306 confirm_find: bool,
307 /// Why the palette file could not be used, if it could not.
308 palette_problem: Option<String>,
309}
310
311impl App {
312 fn new(theme_file: Option<PathBuf>, font: Font, device: Option<BdAddr>, demo: bool) -> Self {
313 let buds = if demo { buds::demo() } else { buds::unknown() };
314 let anc_level = buds
315 .status
316 .noise_control
317 .as_ref()
318 .and_then(|now| now.level.as_deref())
319 .and_then(anc_level)
320 .unwrap_or(AncLevel::Deep);
321 let mut app = App {
322 page: Page::Overview,
323 buds,
324 anc_level,
325 look: Look {
326 p: theme::BUILT_IN,
327 font,
328 },
329 theme: theme::iced_theme(&theme::BUILT_IN),
330 palette: theme::Source::new(theme_file),
331 keys: keys::Keys::default(),
332 cursor: 0,
333 cursor_shown: false,
334 help: false,
335 device,
336 link: if demo { Link::Demo } else { Link::Connecting },
337 worker: None,
338 in_flight: VecDeque::new(),
339 read_at: None,
340 draft: None,
341 touch_draft: None,
342 ringing: false,
343 confirm_find: false,
344 palette_problem: None,
345 };
346 app.refresh_palette();
347 app
348 }
349
350 fn update(&mut self, message: Message) -> Task<Message> {
351 match message {
352 Message::Open(page) => return self.open(page),
353 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
354 Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
355 Message::Preset(id) => self.select(id),
356 Message::Band { preset, hz, gain, save } => {
357 self.move_band(preset, hz, gain);
358 if save {
359 self.save_bands(preset);
360 }
361 }
362 Message::SaveBands(preset) => self.save_bands(preset),
363 Message::Action { row, action, save } => {
364 self.touch_draft = Some(TouchDraft { row, action });
365 if save {
366 self.save_touch(row);
367 }
368 }
369 Message::Cycle(mode, on) => self.cycle(mode, on),
370 Message::Find if self.ringing => self.write(Item::Find, Command::Find(false)),
371 Message::Find => self.confirm_find = true,
372 Message::ConfirmFind(ring) => {
373 self.confirm_find = false;
374 if ring {
375 self.write(Item::Find, Command::Find(true));
376 }
377 }
378 Message::ShowHelp => self.help = true,
379 Message::Refresh => self.refresh(),
380 Message::Worker(event) => self.answer(event),
381 Message::Focused => {
382 self.refresh_palette();
383 if self.read_at.is_some_and(|at| at.elapsed() >= STALE) && self.in_flight.is_empty() {
384 self.refresh();
385 }
386 }
387 Message::Key(key, modifiers) => return self.key(&key, modifiers),
388 Message::Pointer => self.cursor_shown = false,
389 Message::CloseHelp => self.help = false,
390 }
391 Task::none()
392 }
393
394 /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
395 /// answer, the item shows that it is saving and takes no other write.
396 fn write(&mut self, item: Item, command: Command) {
397 if self.link == Link::Demo {
398 match command {
399 Command::Noise(mode) => self.apply_noise(NoiseControl {
400 mode: mode.name().to_owned(),
401 level: match mode {
402 NoiseMode::Anc(level) => Some(level.name().to_owned()),
403 NoiseMode::Off | NoiseMode::Transparency => None,
404 },
405 }),
406 Command::Switch(key, on) => {
407 if let Some(switch) = self.buds.status.switches.iter().find(|s| s.key == key) {
408 let switch = Switch { on, ..switch.clone() };
409 self.apply_switch(switch);
410 }
411 }
412 Command::Eq(preset) => self.buds.set_eq(preset),
413 Command::Touch { side, gesture, action } => {
414 if let Some(entry) = self.buds.touch.as_mut().and_then(|touch| {
415 touch
416 .gestures
417 .iter_mut()
418 .find(|g| g.side == side && g.gesture == gesture && g.group.is_none())
419 }) {
420 entry.action = action;
421 }
422 self.touch_draft = None;
423 }
424 Command::HoldCycle(modes) => {
425 if let Some(touch) = &mut self.buds.touch {
426 touch.hold_cycle = Some(modes);
427 }
428 }
429 Command::Find(ring) => self.ringing = ring,
430 Command::CustomEq(_, bands) => {
431 if let Some(preset) = self.buds.preset(item_preset(item)) {
432 let mut preset = preset.clone();
433 for band in &bands {
434 if let Some(shown) = preset.bands.iter_mut().find(|b| b.hz == band.hz) {
435 shown.gain = band.gain;
436 }
437 }
438 self.buds.set_custom(preset);
439 }
440 self.draft = None;
441 }
442 Command::Read => {}
443 }
444 } else if !self.pending().contains(&item) {
445 self.send(command, Some(item));
446 }
447 }
448
449 /// Reads the buds again; with the made-up buds, starts them over.
450 fn refresh(&mut self) {
451 match self.link {
452 Link::Demo => {
453 self.buds = buds::demo();
454 self.draft = None;
455 self.touch_draft = None;
456 }
457 _ if self.in_flight.contains(&None) => {}
458 _ => {
459 if self.read_at.is_none() {
460 self.link = Link::Connecting;
461 }
462 self.send(Command::Read, None);
463 }
464 }
465 }
466
467 fn send(&mut self, command: Command, item: Option<Item>) {
468 let Some(worker) = &self.worker else {
469 return;
470 };
471 if worker.send(command).is_ok() {
472 self.in_flight.push_back(item);
473 }
474 }
475
476 /// Takes in what the worker reports.
477 fn answer(&mut self, event: Event) {
478 match event {
479 Event::Ready(worker) => {
480 self.worker = Some(worker);
481 self.send(Command::Read, None);
482 }
483 Event::Found(name) => self.buds.name = name,
484 Event::Lost(message) => {
485 self.buds.connected = false;
486 self.link = Link::Failed {
487 kind: Kind::NotFound,
488 message,
489 };
490 }
491 Event::Read(reading) => {
492 self.in_flight.pop_front();
493 self.apply_reading(*reading);
494 }
495 Event::Noise(now) => {
496 self.in_flight.pop_front();
497 self.apply_noise(now);
498 self.link = Link::Live;
499 }
500 Event::Switch(switch) => {
501 self.in_flight.pop_front();
502 self.apply_switch(switch);
503 self.link = Link::Live;
504 }
505 Event::Eq(now) => {
506 self.in_flight.pop_front();
507 self.buds.set_eq(now);
508 self.link = Link::Live;
509 }
510 Event::CustomEq(preset) => {
511 self.in_flight.pop_front();
512 let id = preset.id;
513 self.buds.set_custom(preset);
514 self.link = Link::Live;
515 // Bands moved while this write was on its way go out now.
516 match &self.draft {
517 Some(draft) if draft.preset == id => {
518 if self.buds.preset(id).is_some_and(|saved| saved.bands == draft.bands) {
519 self.draft = None;
520 } else {
521 self.save_bands(id);
522 }
523 }
524 _ => {}
525 }
526 }
527 Event::Touch(gestures) => {
528 let done = self.in_flight.pop_front();
529 if let Some(touch) = &mut self.buds.touch {
530 touch.gestures = gestures;
531 }
532 self.link = Link::Live;
533 // An action picked while this write was on its way goes out now.
534 if let Some(Some(Item::Gesture(row))) = done {
535 self.save_touch(row);
536 }
537 }
538 Event::Ringing(ring) => {
539 self.in_flight.pop_front();
540 self.ringing = ring;
541 self.link = Link::Live;
542 }
543 Event::HoldCycle(modes) => {
544 self.in_flight.pop_front();
545 if let Some(touch) = &mut self.buds.touch {
546 touch.hold_cycle = Some(modes);
547 }
548 self.link = Link::Live;
549 }
550 Event::Failed { kind, message } => {
551 // The buds keep what they had: show that, not what was picked.
552 match self.in_flight.pop_front() {
553 Some(Some(Item::Preset(id))) if self.draft.as_ref().is_some_and(|d| d.preset == id) => {
554 self.draft = None;
555 }
556 Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => {
557 self.touch_draft = None;
558 }
559 _ => {}
560 }
561 if kind == Kind::NotFound {
562 self.buds.connected = false;
563 }
564 self.link = Link::Failed { kind, message };
565 }
566 }
567 }
568
569 fn apply_reading(&mut self, reading: Reading) {
570 self.buds.eq = reading.eq;
571 self.buds.touch = reading.touch;
572 if let Some(level) = reading
573 .status
574 .noise_control
575 .as_ref()
576 .and_then(|now| now.level.as_deref())
577 .and_then(anc_level)
578 {
579 self.anc_level = level;
580 }
581 self.buds.status = reading.status;
582 self.buds.connected = true;
583 self.link = Link::Live;
584 self.read_at = Some(Instant::now());
585 }
586
587 fn apply_noise(&mut self, now: NoiseControl) {
588 if let Some(level) = now.level.as_deref().and_then(anc_level) {
589 self.anc_level = level;
590 }
591 self.buds.status.noise_control = Some(now);
592 }
593
594 fn apply_switch(&mut self, switch: Switch) {
595 if let Some(shown) = self.buds.status.switches.iter_mut().find(|s| s.key == switch.key) {
596 *shown = switch;
597 }
598 }
599
600 /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged. The
601 /// preset in use is left alone.
602 fn select(&mut self, id: u8) {
603 let Some(preset) = self.buds.preset(id) else {
604 return;
605 };
606 if self.buds.status.eq.as_deref() == Some(buds::key(preset).as_str()) {
607 return;
608 }
609 let command = if preset.custom {
610 Command::CustomEq(buds::key(preset), Vec::new())
611 } else {
612 Command::Eq(preset.name.clone())
613 };
614 self.write(Item::Preset(id), command);
615 }
616
617 /// Moves a band on the screen, within the preset's range. Nothing goes to the buds yet.
618 fn move_band(&mut self, id: u8, hz: u16, gain: i8) {
619 let Some(preset) = self.buds.preset(id) else {
620 return;
621 };
622 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
623 let mut bands = match self.draft.take() {
624 Some(draft) if draft.preset == id => draft.bands,
625 _ => preset.bands.clone(),
626 };
627 if let Some(band) = bands.iter_mut().find(|band| band.hz == hz) {
628 band.gain = gain.clamp(low, high);
629 }
630 self.draft = Some(Draft { preset: id, bands });
631 }
632
633 /// Saves the moved bands of a custom preset. If a write to it is on its way, they go out
634 /// when it is answered: the latest bands win.
635 fn save_bands(&mut self, id: u8) {
636 let Some(draft) = self.draft.clone().filter(|draft| draft.preset == id) else {
637 return;
638 };
639 let Some(preset) = self.buds.preset(id) else {
640 return;
641 };
642 if !self.pending().contains(&Item::Preset(id)) {
643 self.write(Item::Preset(id), Command::CustomEq(buds::key(preset), draft.bands));
644 }
645 }
646
647 /// A gesture row: the bud, the gesture, and the actions it takes.
648 fn gesture_row(&self, row: u8) -> Option<(&'static str, String, Vec<String>)> {
649 let touch = self.buds.touch.as_ref()?;
650 let rows = gestures::rows(touch);
651 let found = rows.get(usize::from(row))?;
652 Some((found.side, found.gesture.to_owned(), found.actions.to_vec()))
653 }
654
655 /// The action a gesture row has: the one picked on the screen, else the buds' own.
656 fn gesture_action(&self, row: u8) -> Option<String> {
657 if let Some(draft) = self.touch_draft.as_ref().filter(|draft| draft.row == row) {
658 return Some(draft.action.clone());
659 }
660 let (side, gesture, _) = self.gesture_row(row)?;
661 self.buds.action(side, &gesture).map(str::to_owned)
662 }
663
664 /// Writes the action picked for a gesture row. If a write to it is on its way, the pick
665 /// goes out when that is answered: the latest pick wins.
666 fn save_touch(&mut self, row: u8) {
667 let Some(draft) = self.touch_draft.clone().filter(|draft| draft.row == row) else {
668 return;
669 };
670 let Some((side, gesture, _)) = self.gesture_row(row) else {
671 return;
672 };
673 if self.buds.action(side, &gesture) == Some(draft.action.as_str()) {
674 self.touch_draft = None;
675 } else if !self.pending().contains(&Item::Gesture(row)) {
676 let command = Command::Touch {
677 side: side.to_owned(),
678 gesture,
679 action: draft.action,
680 };
681 self.write(Item::Gesture(row), command);
682 }
683 }
684
685 /// Puts a mode in the hold cycle or takes it out. The buds need two modes to step
686 /// through, so the last two stay.
687 fn cycle(&mut self, mode: u8, on: bool) {
688 let Some(touch) = &self.buds.touch else {
689 return;
690 };
691 let (Some(name), Some(now)) = (touch.cycle_modes.get(usize::from(mode)), &touch.hold_cycle) else {
692 return;
693 };
694 let mut modes: Vec<String> = now.iter().filter(|m| *m != name).cloned().collect();
695 if on {
696 modes.push(name.clone());
697 }
698 let busy = self.pending().iter().any(|item| matches!(item, Item::Cycle(_)));
699 if modes.len() >= 2 && !busy {
700 self.write(Item::Cycle(mode), Command::HoldCycle(modes));
701 }
702 }
703
704 /// The items with a write on its way.
705 fn pending(&self) -> Vec<Item> {
706 self.in_flight.iter().flatten().copied().collect()
707 }
708
709 /// Shows `page` from its top, with the cursor on its first item.
710 fn open(&mut self, page: Page) -> Task<Message> {
711 self.page = page;
712 self.cursor = 0;
713 operation::snap_to(SCROLL, RelativeOffset::START)
714 }
715
716 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
717 let Some(action) = self.keys.press(key, modifiers) else {
718 return Task::none();
719 };
720 if self.help {
721 // The list of keys is in front of everything: a key only closes it.
722 self.help = false;
723 return Task::none();
724 }
725 if self.confirm_find {
726 // Only Enter rings the buds; any other key says no.
727 let ring = matches!(key, Key::Named(keyboard::key::Named::Enter));
728 return self.update(Message::ConfirmFind(ring));
729 }
730 match action {
731 Action::Quit => iced::exit(),
732 Action::Help => {
733 self.help = true;
734 Task::none()
735 }
736 Action::Escape => {
737 self.cursor_shown = false;
738 Task::none()
739 }
740 Action::Refresh => {
741 self.refresh();
742 Task::none()
743 }
744 Action::NextPage => self.open(self.page.step(true)),
745 Action::PreviousPage => self.open(self.page.step(false)),
746 Action::Page(n) => match Page::ALL.get(n) {
747 Some(&page) => self.open(page),
748 None => Task::none(),
749 },
750 Action::Down
751 | Action::Up
752 | Action::First
753 | Action::Last
754 | Action::Left
755 | Action::Right
756 | Action::Activate => self.on_cursor(action),
757 }
758 }
759
760 /// What the current page offers the keyboard cursor, top to bottom.
761 fn items(&self) -> Vec<Item> {
762 match self.page {
763 Page::Overview if self.has_reading() => overview::items(&self.buds),
764 Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
765 Page::Gestures if self.has_reading() => gestures::items(&self.buds),
766 Page::Earbuds if self.has_reading() => earbuds::items(),
767 _ => Vec::new(),
768 }
769 }
770
771 /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
772 fn has_reading(&self) -> bool {
773 self.link == Link::Demo || self.read_at.is_some()
774 }
775
776 /// The item under the cursor, when the cursor shows.
777 fn focus(&self) -> Option<Item> {
778 let items = self.items();
779 let last = items.len().checked_sub(1)?;
780 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
781 }
782
783 fn on_cursor(&mut self, action: Action) -> Task<Message> {
784 let items = self.items();
785 let Some(last) = items.len().checked_sub(1) else {
786 return Task::none();
787 };
788 self.cursor = self.cursor.min(last);
789 if !self.cursor_shown {
790 // The first key only shows the cursor, so nothing changes where it cannot be seen.
791 self.cursor_shown = true;
792 return self.follow();
793 }
794 match action {
795 Action::Down => self.cursor = (self.cursor + 1).min(last),
796 Action::Up => self.cursor = self.cursor.saturating_sub(1),
797 Action::First => self.cursor = 0,
798 Action::Last => self.cursor = last,
799 _ => {
800 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
801 return match change {
802 Some(message) => self.update(message),
803 None => Task::none(),
804 };
805 }
806 }
807 self.follow()
808 }
809
810 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
811 fn change(&self, item: Item, action: Action) -> Option<Message> {
812 match item {
813 Item::NoiseMode => {
814 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
815 let now = self.buds.status.noise_control.as_ref()?;
816 let at = modes.iter().position(|mode| mode.name() == now.mode);
817 step(&modes, at, action).map(Message::Noise)
818 }
819 Item::AncLevel => {
820 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
821 let at = levels.iter().position(|&level| level == self.anc_level);
822 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
823 }
824 Item::Switch(key) => {
825 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
826 let want = match action {
827 Action::Left => false,
828 Action::Right => true,
829 _ => !on,
830 };
831 (want != on).then_some(Message::Switch(key, want))
832 }
833 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
834 Item::Preset(id) => {
835 let chosen = self.buds.status.eq.as_deref() == self.buds.preset(id).map(buds::key).as_deref();
836 (action != Action::Left && !chosen).then_some(Message::Preset(id))
837 }
838 Item::Band(id, hz) => {
839 let preset = self.buds.preset(id)?;
840 let gain = match &self.draft {
841 Some(draft) if draft.preset == id => &draft.bands,
842 _ => &preset.bands,
843 }
844 .iter()
845 .find(|band| band.hz == hz)?
846 .gain;
847 let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX));
848 let to = match action {
849 Action::Left => gain.checked_sub(1)?.max(low),
850 Action::Right => gain.checked_add(1)?.min(high),
851 _ => return None,
852 };
853 (to != gain).then_some(Message::Band {
854 preset: id,
855 hz,
856 gain: to,
857 save: true,
858 })
859 }
860 Item::Gesture(row) => {
861 let (_, _, actions) = self.gesture_row(row)?;
862 let now = self.gesture_action(row)?;
863 let places: Vec<usize> = (0..actions.len()).collect();
864 let at = actions.iter().position(|a| *a == now);
865 let to = step(&places, at, action)?;
866 actions.get(to).map(|action| Message::Action {
867 row,
868 action: action.clone(),
869 save: true,
870 })
871 }
872 Item::Find => (action != Action::Left).then_some(Message::Find),
873 Item::Cycle(mode) => {
874 let touch = self.buds.touch.as_ref()?;
875 let on = touch
876 .hold_cycle
877 .as_ref()?
878 .contains(touch.cycle_modes.get(usize::from(mode))?);
879 let want = match action {
880 Action::Left => false,
881 Action::Right => true,
882 _ => !on,
883 };
884 (want != on).then_some(Message::Cycle(mode, want))
885 }
886 }
887 }
888
889 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
890 /// bottom, the others in between.
891 fn follow(&self) -> Task<Message> {
892 let count = self.items().len();
893 if !self.cursor_shown || count < 2 {
894 return Task::none();
895 }
896 let y = self.cursor as f32 / (count - 1) as f32;
897 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
898 }
899
900 fn refresh_palette(&mut self) {
901 let palette = match self.palette.refresh() {
902 None => return,
903 Some(Ok(palette)) => {
904 self.palette_problem = None;
905 palette
906 }
907 Some(Err(e)) => {
908 eprintln!("budctl-gui: {e}; using the built-in colors");
909 self.palette_problem = Some(e);
910 theme::BUILT_IN
911 }
912 };
913 self.look.p = palette;
914 self.theme = theme::iced_theme(&palette);
915 }
916
917 fn theme(&self) -> Theme {
918 self.theme.clone()
919 }
920
921 fn subscription(&self) -> Subscription<Message> {
922 let events = event::listen_with(|event, _status, _window| match event {
923 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
924 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
925 Some(Message::Key(key, modifiers))
926 }
927 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
928 _ => None,
929 });
930 if self.link == Link::Demo {
931 return events;
932 }
933 let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker);
934 Subscription::batch([events, worker])
935 }
936
937 fn view(&self) -> Element<'_, Message> {
938 let look = self.look;
939 let pending = self.pending();
940 let page = match self.page {
941 Page::Overview if self.has_reading() => {
942 let marks = Marks {
943 focus: self.focus(),
944 pending: &pending,
945 };
946 overview::view(look, &self.buds, self.anc_level, marks)
947 }
948 Page::Overview => self.connecting(),
949 Page::Equalizer if self.has_reading() => {
950 let marks = Marks {
951 focus: self.focus(),
952 pending: &pending,
953 };
954 equalizer::view(look, &self.buds, self.draft.as_ref(), marks)
955 }
956 Page::Gestures if self.has_reading() => {
957 let marks = Marks {
958 focus: self.focus(),
959 pending: &pending,
960 };
961 gestures::view(look, &self.buds, self.touch_draft.as_ref(), marks)
962 }
963 Page::Earbuds if self.has_reading() => {
964 let marks = Marks {
965 focus: self.focus(),
966 pending: &pending,
967 };
968 earbuds::view(look, &self.buds, self.ringing, marks)
969 }
970 Page::Settings => {
971 let colors = settings::Colors {
972 file: self.palette.file(),
973 problem: self.palette_problem.as_deref(),
974 };
975 settings::view(look, colors)
976 }
977 page => column![
978 ui::title(look, page.title()),
979 text("This page comes in a later step.").size(14).color(look.p.dim)
980 ]
981 .spacing(12)
982 .into(),
983 };
984 let page = column![self.problem(), page].spacing(18);
985 let p = look.p;
986 let content = scrollable(
987 container(container(page).max_width(860))
988 .center_x(Length::Fill)
989 .padding([30, 36]),
990 )
991 .id(SCROLL)
992 .height(Length::Fill)
993 .style(move |theme, status| {
994 let mut style = scrollable::default(theme, status);
995 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
996 rail.background = None;
997 rail.border = Border::default();
998 rail.scroller.background = p.surface_highest.into();
999 rail.scroller.border = Border::default().rounded(4);
1000 }
1001 style
1002 });
1003 let window = row![self.sidebar(), ui::divider(look), content];
1004 if self.help {
1005 stack![window, self.help()].into()
1006 } else if self.confirm_find {
1007 stack![window, self.confirm_find()].into()
1008 } else {
1009 window.into()
1010 }
1011 }
1012
1013 /// The Overview before the first reading: connecting, or why it did not work.
1014 fn connecting(&self) -> Element<'_, Message> {
1015 let look = self.look;
1016 let p = look.p;
1017 let (headline, detail) = match &self.link {
1018 Link::Failed {
1019 kind: Kind::NotFound,
1020 message,
1021 } => ("Your buds are not connected", message.as_str()),
1022 Link::Failed {
1023 kind: Kind::Busy,
1024 message,
1025 } => ("The control channel is busy", message.as_str()),
1026 Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
1027 _ => (
1028 "Connecting to your buds…",
1029 "budctl looks for the paired buds it supports and reads them.",
1030 ),
1031 };
1032 let retry = matches!(self.link, Link::Failed { .. })
1033 .then(|| container(ui::outline_button(look, "Try again", Message::Refresh)).width(180));
1034 let words = column![
1035 text(headline).size(20).font(look.bold()).color(p.fg),
1036 text(detail).size(14).color(p.dim)
1037 ]
1038 .spacing(8);
1039 column![
1040 ui::title(look, "Overview"),
1041 ui::panel(look, column![words].push(retry).spacing(18).into())
1042 ]
1043 .spacing(18)
1044 .into()
1045 }
1046
1047 /// A failure after the buds were read once: what went wrong, above the page.
1048 fn problem(&self) -> Option<Element<'_, Message>> {
1049 let Link::Failed { message, .. } = &self.link else {
1050 return None;
1051 };
1052 // Before the first reading, the Overview says it already.
1053 self.read_at?;
1054 let look = self.look;
1055 let p = look.p;
1056 let content = row![
1057 text(message.as_str()).size(14).color(p.err).width(Length::Fill),
1058 container(ui::outline_button(look, "Try again", Message::Refresh)).width(140)
1059 ]
1060 .spacing(16)
1061 .align_y(Alignment::Center);
1062 Some(
1063 container(content)
1064 .padding([12, 16])
1065 .width(Length::Fill)
1066 .style(move |_| container::Style {
1067 background: Some(p.surface.into()),
1068 border: Border {
1069 color: p.err,
1070 width: 1.0,
1071 radius: 14.0.into(),
1072 },
1073 ..container::Style::default()
1074 })
1075 .into(),
1076 )
1077 }
1078
1079 fn sidebar(&self) -> Element<'_, Message> {
1080 let look = self.look;
1081 let p = look.p;
1082 let (light, state) = match &self.link {
1083 Link::Demo | Link::Live => (p.ok, "Connected"),
1084 Link::Connecting => (p.dim, "Connecting…"),
1085 Link::Failed { kind: Kind::Busy, .. } => (p.err, "Busy"),
1086 Link::Failed { .. } if !self.buds.connected => (p.dim, "Not connected"),
1087 Link::Failed { .. } => (p.err, "Error"),
1088 };
1089 let device = container(
1090 column![
1091 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
1092 row![ui::light(light), text(state).size(13).color(p.dim), space::horizontal()]
1093 .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
1094 .spacing(8)
1095 .align_y(Alignment::Center),
1096 ui::outline_button(look, "Refresh", Message::Refresh),
1097 ]
1098 .spacing(12),
1099 )
1100 .padding(16)
1101 .style(move |_| container::Style {
1102 background: Some(p.surface.into()),
1103 border: Border {
1104 color: p.line,
1105 width: 1.0,
1106 radius: 18.0.into(),
1107 },
1108 ..container::Style::default()
1109 });
1110 let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
1111 pages.push(ui::nav_item(
1112 look,
1113 page.icon(),
1114 page.title(),
1115 page == self.page,
1116 Message::Open(page),
1117 ))
1118 });
1119 let hint = text("Press ? for keys").size(12.5).color(p.dim);
1120 container(column![device, pages, space::vertical(), hint].spacing(22))
1121 .padding([18, 14])
1122 .width(268)
1123 .height(Length::Fill)
1124 .style(move |_| container::Style {
1125 background: Some(p.surface_low.into()),
1126 ..container::Style::default()
1127 })
1128 .into()
1129 }
1130
1131 /// "Ring the buds?", over a dimmed window: the sound is loud enough to hurt in the ears.
1132 fn confirm_find(&self) -> Element<'_, Message> {
1133 let look = self.look;
1134 let p = look.p;
1135 let words = column![
1136 text("Ring your buds?").size(22).font(look.bold()).color(p.fg),
1137 text("Take them out of your ears first: the sound is loud. They ring until you press Stop.")
1138 .size(14)
1139 .color(p.dim),
1140 ]
1141 .spacing(8);
1142 let buttons = row![
1143 space::horizontal(),
1144 ui::pill(look, "Cancel", false, Message::ConfirmFind(false)),
1145 ui::pill(look, "Ring", true, Message::ConfirmFind(true)),
1146 ]
1147 .spacing(10);
1148 let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1149 let backdrop = container(container(dialog).width(480))
1150 .center(Length::Fill)
1151 .style(|_| container::Style {
1152 background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1153 ..container::Style::default()
1154 });
1155 opaque(backdrop)
1156 }
1157
1158 /// The list of keys, over a dimmed window. A key or a click closes it.
1159 fn help(&self) -> Element<'_, Message> {
1160 let look = self.look;
1161 let p = look.p;
1162 let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
1163 rows.push(row![
1164 text(keys).size(14).font(look.medium()).color(p.fg).width(170),
1165 text(what).size(14).color(p.dim)
1166 ])
1167 });
1168 let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
1169 let backdrop = container(container(list).width(560))
1170 .center(Length::Fill)
1171 .style(|_| container::Style {
1172 background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1173 ..container::Style::default()
1174 });
1175 opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
1176 }
1177}
1178
1179/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
1180/// round. With nothing chosen, any of them picks the first.
1181fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
1182 let to = match (action, at) {
1183 (_, None) => 0,
1184 (Action::Left, Some(at)) => at.checked_sub(1)?,
1185 (Action::Right, Some(at)) => at + 1,
1186 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
1187 _ => return None,
1188 };
1189 choices.get(to).copied()
1190}
1191
1192/// The level a [`NoiseControl::level`] names.
1193fn anc_level(name: &str) -> Option<AncLevel> {
1194 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
1195 .into_iter()
1196 .find(|level| level.name() == name)
1197}
1198
1199/// The preset a write to `item` is for.
1200fn item_preset(item: Item) -> u8 {
1201 match item {
1202 Item::Preset(id) | Item::Band(id, _) => id,
1203 _ => 0,
1204 }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use iced::keyboard::key::Named;
1211 use std::fs;
1212 use std::path::Path;
1213 use std::sync::mpsc::{self, Receiver};
1214
1215 /// With the made-up buds.
1216 fn app() -> App {
1217 App::new(None, Font::DEFAULT, None, true)
1218 }
1219
1220 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
1221 fn live() -> (App, Receiver<Command>) {
1222 let mut app = App::new(None, Font::DEFAULT, None, false);
1223 let (worker, commands) = mpsc::channel();
1224 let _ = app.update(Message::Worker(Event::Ready(worker)));
1225 (app, commands)
1226 }
1227
1228 fn reading() -> Reading {
1229 Reading {
1230 status: buds::demo().status,
1231 eq: buds::demo().eq,
1232 touch: buds::demo().touch,
1233 }
1234 }
1235
1236 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
1237 fn press(app: &mut App, keys: &str) {
1238 for name in keys.split_whitespace() {
1239 let (key, modifiers) = match name {
1240 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
1241 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
1242 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
1243 c => (Key::Character(c.into()), Modifiers::empty()),
1244 };
1245 let _ = app.update(Message::Key(key, modifiers));
1246 }
1247 }
1248
1249 fn mode(app: &App) -> &str {
1250 &app.buds.status.noise_control.as_ref().unwrap().mode
1251 }
1252
1253 fn switch(app: &App, key: &str) -> bool {
1254 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
1255 }
1256
1257 #[test]
1258 fn noise_cancelling_remembers_its_level() {
1259 let mut app = app();
1260 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
1261 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1262 assert_eq!(app.anc_level, AncLevel::Light);
1263 let now = app.buds.status.noise_control.as_ref().unwrap();
1264 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
1265 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
1266 let now = app.buds.status.noise_control.as_ref().unwrap();
1267 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
1268 }
1269
1270 #[test]
1271 fn switches_change_by_key() {
1272 let mut app = app();
1273 let _ = app.update(Message::Switch("game", true));
1274 assert!(switch(&app, "game"));
1275 }
1276
1277 #[test]
1278 fn the_overview_items_in_order() {
1279 let mut app = app();
1280 assert_eq!(
1281 app.items(),
1282 [
1283 Item::NoiseMode,
1284 Item::AncLevel,
1285 Item::Switch("game"),
1286 Item::Switch("spatial"),
1287 Item::Switch("bass-boost"),
1288 Item::Switch("wind"),
1289 Item::Switch("dual"),
1290 Item::Open(Page::Equalizer),
1291 Item::Open(Page::Gestures),
1292 Item::Open(Page::Earbuds),
1293 ]
1294 );
1295 let _ = app.update(Message::Noise(NoiseMode::Off));
1296 assert!(!app.items().contains(&Item::AncLevel));
1297 }
1298
1299 #[test]
1300 fn the_first_key_only_shows_the_cursor() {
1301 let mut app = app();
1302 assert_eq!(app.focus(), None);
1303 press(&mut app, "l");
1304 assert_eq!(app.focus(), Some(Item::NoiseMode));
1305 assert_eq!(mode(&app), "anc");
1306 }
1307
1308 #[test]
1309 fn h_and_l_step_through_choices_and_enter_goes_round() {
1310 let mut app = app();
1311 press(&mut app, "j l");
1312 assert_eq!(mode(&app), "transparency");
1313 press(&mut app, "l");
1314 assert_eq!(mode(&app), "transparency");
1315 press(&mut app, "h h h");
1316 assert_eq!(mode(&app), "off");
1317 press(&mut app, "Enter Enter");
1318 assert_eq!(mode(&app), "transparency");
1319 press(&mut app, "Enter");
1320 assert_eq!(mode(&app), "off");
1321 // On the strength: deep at first.
1322 press(&mut app, "l j");
1323 assert_eq!(app.focus(), Some(Item::AncLevel));
1324 press(&mut app, "h h h");
1325 assert_eq!(app.anc_level, AncLevel::Light);
1326 press(&mut app, "Enter");
1327 assert_eq!(app.anc_level, AncLevel::Medium);
1328 assert_eq!(
1329 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
1330 Some("medium")
1331 );
1332 }
1333
1334 #[test]
1335 fn switches_take_h_l_and_enter() {
1336 let mut app = app();
1337 press(&mut app, "j j j");
1338 assert_eq!(app.focus(), Some(Item::Switch("game")));
1339 press(&mut app, "l");
1340 assert!(switch(&app, "game"));
1341 press(&mut app, "l");
1342 assert!(switch(&app, "game"));
1343 press(&mut app, "h");
1344 assert!(!switch(&app, "game"));
1345 press(&mut app, "Enter");
1346 assert!(switch(&app, "game"));
1347 }
1348
1349 #[test]
1350 fn moving_stops_at_the_ends() {
1351 let mut app = app();
1352 press(&mut app, "k k");
1353 assert_eq!(app.focus(), Some(Item::NoiseMode));
1354 press(&mut app, "G j j");
1355 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
1356 press(&mut app, "g g");
1357 assert_eq!(app.focus(), Some(Item::NoiseMode));
1358 }
1359
1360 #[test]
1361 fn rows_open_pages_and_the_cursor_starts_over() {
1362 let mut app = app();
1363 press(&mut app, "G G h");
1364 assert_eq!(app.page, Page::Overview);
1365 press(&mut app, "l");
1366 assert_eq!(app.page, Page::Earbuds);
1367 assert_eq!(app.cursor, 0);
1368 assert!(app.cursor_shown);
1369 }
1370
1371 #[test]
1372 fn pages_by_key() {
1373 let mut app = app();
1374 press(&mut app, "J");
1375 assert_eq!(app.page, Page::Equalizer);
1376 press(&mut app, "K K");
1377 assert_eq!(app.page, Page::Settings);
1378 press(&mut app, "1");
1379 assert_eq!(app.page, Page::Overview);
1380 press(&mut app, "g t");
1381 assert_eq!(app.page, Page::Equalizer);
1382 press(&mut app, "g T");
1383 assert_eq!(app.page, Page::Overview);
1384 press(&mut app, "9");
1385 assert_eq!(app.page, Page::Overview);
1386 }
1387
1388 #[test]
1389 fn esc_and_clicks_hide_the_cursor() {
1390 let mut app = app();
1391 press(&mut app, "j j");
1392 assert!(app.focus().is_some());
1393 press(&mut app, "Esc");
1394 assert_eq!(app.focus(), None);
1395 press(&mut app, "j");
1396 let _ = app.update(Message::Pointer);
1397 assert_eq!(app.focus(), None);
1398 // It comes back where it was.
1399 press(&mut app, "j");
1400 assert_eq!(app.focus(), Some(Item::AncLevel));
1401 }
1402
1403 #[test]
1404 fn the_list_of_keys_closes_on_any_key() {
1405 let mut app = app();
1406 press(&mut app, "?");
1407 assert!(app.help);
1408 press(&mut app, "j");
1409 assert!(!app.help);
1410 assert_eq!(app.focus(), None);
1411 press(&mut app, "?");
1412 let _ = app.update(Message::CloseHelp);
1413 assert!(!app.help);
1414 }
1415
1416 #[test]
1417 fn live_buds_are_read_first() {
1418 let (mut app, commands) = live();
1419 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1420 assert_eq!(app.link, Link::Connecting);
1421 assert!(app.items().is_empty(), "nothing to move over before the first reading");
1422 // A second refresh while the read is on its way sends nothing.
1423 press(&mut app, "r");
1424 assert!(commands.try_recv().is_err());
1425
1426 let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1427 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1428 assert_eq!(app.link, Link::Live);
1429 assert_eq!(app.buds.name, "realme Buds T310");
1430 assert!(app.buds.connected);
1431 assert_eq!(app.items().len(), 10);
1432 }
1433
1434 #[test]
1435 fn the_preset_in_use_is_not_written_again() {
1436 let (mut app, commands) = live();
1437 let _ = commands.try_recv();
1438 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1439 let _ = app.update(Message::Preset(4));
1440 assert!(commands.try_recv().is_err(), "Custom1 is in use already");
1441 let _ = app.update(Message::Preset(3));
1442 assert!(matches!(commands.try_recv(), Ok(Command::Eq(name)) if name == "pure-bass"));
1443 }
1444
1445 #[test]
1446 fn live_writes_wait_for_the_buds() {
1447 let (mut app, commands) = live();
1448 let _ = commands.try_recv();
1449 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1450
1451 let _ = app.update(Message::Switch("game", true));
1452 assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1453 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1454 assert_eq!(app.pending(), [Item::Switch("game")]);
1455 // While it is on its way, the same switch takes no other write.
1456 press(&mut app, "j j j Enter");
1457 assert!(commands.try_recv().is_err());
1458
1459 let game = Switch {
1460 key: "game",
1461 label: "Game mode",
1462 on: true,
1463 };
1464 let _ = app.update(Message::Worker(Event::Switch(game)));
1465 assert!(switch(&app, "game"));
1466 assert!(app.pending().is_empty());
1467
1468 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1469 assert!(matches!(
1470 commands.try_recv(),
1471 Ok(Command::Noise(NoiseMode::Transparency))
1472 ));
1473 assert_eq!(mode(&app), "anc");
1474 let now = NoiseControl {
1475 mode: "transparency".to_owned(),
1476 level: None,
1477 };
1478 let _ = app.update(Message::Worker(Event::Noise(now)));
1479 assert_eq!(mode(&app), "transparency");
1480 }
1481
1482 #[test]
1483 fn failures_say_why_and_clear_the_write() {
1484 let (mut app, commands) = live();
1485 let _ = app.update(Message::Worker(Event::Failed {
1486 kind: Kind::Busy,
1487 message: "control channel busy".to_owned(),
1488 }));
1489 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1490 assert!(app.problem().is_none(), "the Overview says it, not a banner");
1491
1492 let _ = commands.try_recv();
1493 let _ = app.update(Message::Refresh);
1494 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1495 assert_eq!(app.link, Link::Connecting);
1496 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1497
1498 let _ = app.update(Message::Switch("dual", false));
1499 let _ = app.update(Message::Worker(Event::Failed {
1500 kind: Kind::NotFound,
1501 message: "the buds closed the control link".to_owned(),
1502 }));
1503 assert!(app.pending().is_empty());
1504 assert!(switch(&app, "dual"));
1505 assert!(!app.buds.connected);
1506 assert!(app.problem().is_some());
1507 }
1508
1509 #[test]
1510 fn presets_and_bands_by_key() {
1511 let mut app = app();
1512 press(&mut app, "J");
1513 assert_eq!(app.page, Page::Equalizer);
1514 press(&mut app, "j l");
1515 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
1516 assert_eq!(app.buds.eq_name(), "Pure raw");
1517 press(&mut app, "G");
1518 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
1519 // Moving a band saves the custom preset, which also selects it.
1520 press(&mut app, "l");
1521 assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
1522 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
1523 assert_eq!(last(&app), 3);
1524 press(&mut app, "l l l l l");
1525 assert_eq!(last(&app), 6, "it stops at the top of the range");
1526 }
1527
1528 #[test]
1529 fn band_moves_wait_for_the_write_before_them() {
1530 let (mut app, commands) = live();
1531 let _ = commands.try_recv();
1532 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1533 press(&mut app, "J G G l");
1534 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
1535 assert!(matches!(
1536 commands.try_recv(),
1537 Ok(Command::CustomEq(name, bands)) if name == "custom-4" && gain(&bands) == Some(3)
1538 ));
1539 // While that write is on its way, moves only change the screen.
1540 press(&mut app, "l l");
1541 assert!(commands.try_recv().is_err());
1542 assert_eq!(app.draft.as_ref().and_then(|draft| gain(&draft.bands)), Some(5));
1543 // The answer comes: the latest bands go out at once.
1544 let mut saved = buds::demo().preset(4).unwrap().clone();
1545 if let Some(band) = saved.bands.last_mut() {
1546 band.gain = 3;
1547 }
1548 let _ = app.update(Message::Worker(Event::CustomEq(saved.clone())));
1549 assert!(matches!(
1550 commands.try_recv(),
1551 Ok(Command::CustomEq(_, bands)) if gain(&bands) == Some(5)
1552 ));
1553 assert_eq!(app.pending(), [Item::Preset(4)]);
1554 // A failure drops the moves: the screen shows what the buds kept.
1555 let _ = app.update(Message::Worker(Event::Failed {
1556 kind: Kind::Failed,
1557 message: "no reply".to_owned(),
1558 }));
1559 assert_eq!(app.draft, None);
1560 assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3));
1561 }
1562
1563 fn cycle(app: &App) -> Vec<String> {
1564 app.buds.touch.as_ref().unwrap().hold_cycle.clone().unwrap()
1565 }
1566
1567 #[test]
1568 fn gestures_and_the_hold_cycle_by_key() {
1569 let mut app = app();
1570 press(&mut app, "3 j j");
1571 assert_eq!(app.page, Page::Gestures);
1572 assert_eq!(app.focus(), Some(Item::Gesture(1)));
1573 press(&mut app, "l");
1574 assert_eq!(app.buds.action("left", "triple-tap"), Some("next"));
1575 press(&mut app, "h h h");
1576 assert_eq!(
1577 app.buds.action("left", "triple-tap"),
1578 Some("none"),
1579 "it stops at the first"
1580 );
1581
1582 press(&mut app, "G k l");
1583 assert_eq!(app.focus(), Some(Item::Cycle(1)));
1584 assert_eq!(cycle(&app), ["off", "anc", "transparency"]);
1585 press(&mut app, "h");
1586 assert_eq!(cycle(&app), ["off", "anc"]);
1587 press(&mut app, "j h");
1588 assert_eq!(cycle(&app), ["off", "anc"], "a hold needs two modes");
1589 }
1590
1591 #[test]
1592 fn gesture_picks_wait_for_the_write_before_them() {
1593 let (mut app, commands) = live();
1594 let _ = commands.try_recv();
1595 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1596 press(&mut app, "3 j j l");
1597 assert!(matches!(
1598 commands.try_recv(),
1599 Ok(Command::Touch { side, gesture, action })
1600 if side == "left" && gesture == "triple-tap" && action == "next"
1601 ));
1602 // While that write is on its way, picks only change the screen.
1603 press(&mut app, "l l");
1604 assert!(commands.try_recv().is_err());
1605 assert_eq!(app.gesture_action(1).as_deref(), Some("volume-up"));
1606 // The answer comes: the latest pick goes out at once.
1607 let mut table = reading().touch.unwrap().gestures;
1608 table[1].action = "next".to_owned();
1609 let _ = app.update(Message::Worker(Event::Touch(table)));
1610 assert!(matches!(
1611 commands.try_recv(),
1612 Ok(Command::Touch { action, .. }) if action == "volume-up"
1613 ));
1614 assert_eq!(app.pending(), [Item::Gesture(1)]);
1615 }
1616
1617 #[test]
1618 fn ringing_needs_a_yes() {
1619 let mut app = app();
1620 press(&mut app, "4 j Enter");
1621 assert_eq!(app.page, Page::Earbuds);
1622 assert!(app.confirm_find);
1623 press(&mut app, "Esc");
1624 assert!(!app.confirm_find);
1625 assert!(!app.ringing);
1626 press(&mut app, "Enter j");
1627 assert!(!app.ringing, "only Enter says yes");
1628 press(&mut app, "Enter Enter");
1629 assert!(app.ringing);
1630 // Stopping asks nothing.
1631 press(&mut app, "Enter");
1632 assert!(!app.confirm_find);
1633 assert!(!app.ringing);
1634 }
1635
1636 #[test]
1637 fn live_ringing_waits_for_the_buds() {
1638 let (mut app, commands) = live();
1639 let _ = commands.try_recv();
1640 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1641 let _ = app.update(Message::Find);
1642 let _ = app.update(Message::ConfirmFind(true));
1643 assert!(matches!(commands.try_recv(), Ok(Command::Find(true))));
1644 assert!(!app.ringing);
1645 let _ = app.update(Message::Worker(Event::Ringing(true)));
1646 assert!(app.ringing);
1647 let _ = app.update(Message::Find);
1648 assert!(matches!(commands.try_recv(), Ok(Command::Find(false))));
1649 }
1650
1651 #[test]
1652 fn steps() {
1653 let abc = ['a', 'b', 'c'];
1654 assert_eq!(step(&abc, Some(0), Action::Left), None);
1655 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
1656 assert_eq!(step(&abc, Some(2), Action::Right), None);
1657 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
1658 assert_eq!(step(&abc, None, Action::Left), Some('a'));
1659 }
1660
1661 #[test]
1662 fn arguments() {
1663 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
1664 assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
1665 assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
1666 assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
1667 assert!(matches!(
1668 args(&["--theme", "x.json"]),
1669 Ok(Args::Run(Options { theme: Some(_), .. }))
1670 ));
1671 assert!(matches!(
1672 args(&["--device", "88:0E:85:46:FE:5C"]),
1673 Ok(Args::Run(Options { device: Some(_), .. }))
1674 ));
1675 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
1676 assert!(args(&["--theme"]).is_err());
1677 assert!(args(&["--device", "nope"]).is_err());
1678 assert!(args(&["--bogus"]).is_err());
1679 }
1680
1681 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
1682 /// display. `BUDCTL_THEME=<file>` renders with that palette.
1683 #[test]
1684 fn pages_render() {
1685 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
1686 let _ = fs::remove_dir_all(&dir);
1687 let font = theme::system_font();
1688 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
1689 let on = |page: Page, keys: &str| {
1690 let mut app = App::new(palette.clone(), font, None, true);
1691 app.page = page;
1692 press(&mut app, keys);
1693 app
1694 };
1695 let mut shots: Vec<(String, f32, App)> = Page::ALL
1696 .into_iter()
1697 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
1698 .collect();
1699 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
1700 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
1701 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
1702 shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k")));
1703 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
1704 shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j Enter")));
1705 let mut connecting = App::new(palette.clone(), font, None, false);
1706 connecting.link = Link::Connecting;
1707 shots.push(("connecting".to_owned(), 800.0, connecting));
1708 let mut busy = App::new(palette.clone(), font, None, false);
1709 busy.answer(Event::Failed {
1710 kind: Kind::Busy,
1711 message: "control channel busy: is another app connected?".to_owned(),
1712 });
1713 shots.push(("busy".to_owned(), 800.0, busy));
1714 let (mut saving, _commands) = live();
1715 saving.answer(Event::Read(Box::new(reading())));
1716 saving.answer(Event::Failed {
1717 kind: Kind::Failed,
1718 message: "the buds did not answer: no reply to command 0x0403".to_owned(),
1719 });
1720 let _ = saving.update(Message::Switch("spatial", false));
1721 let _ = saving.update(Message::Noise(NoiseMode::Off));
1722 shots.push(("saving".to_owned(), 1240.0, saving));
1723 for (name, height, app) in shots {
1724 let settings = iced::Settings {
1725 default_font: font,
1726 ..iced::Settings::default()
1727 };
1728 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
1729 let snapshot = ui.snapshot(&app.theme).unwrap();
1730 assert!(snapshot.matches_image(dir.join(name)).unwrap());
1731 }
1732 }
1733}