Krolyxon
Commit 47dc78a

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

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