Krolyxon
Commit 6dcfe3c

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

1829 lines · 67.5 KB · Rust

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