Krolyxon
Commit 39f837a

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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