Krolyxon
Commit b9e5c00

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

1192 lines · 42.2 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 demo;
8mod icons;
9mod keys;
10mod overview;
11mod theme;
12mod ui;
13mod worker;
14
15use std::collections::VecDeque;
16use std::path::PathBuf;
17use std::process::ExitCode;
18use std::sync::mpsc::Sender;
19use std::time::{Duration, Instant};
20
21use budctl_core::Kind;
22use budctl_core::bt::BdAddr;
23use budctl_core::status::{AncLevel, EqPresets, NoiseControl, NoiseMode, Switch};
24use iced::keyboard::{self, Key, Modifiers};
25use iced::widget::operation::{self, RelativeOffset};
26use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
27use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window};
28
29use icons::Icon;
30use keys::Action;
31use theme::Look;
32use worker::{Command, Event, Reading};
33
34const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug]
35
36--device picks the buds when more than one supported pair is connected.
37--demo shows made-up buds, without Bluetooth.
38--debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
39Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
40The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
41Press ? in the window for the keys.";
42
43/// The page's scrolling area, so the cursor can be kept in view.
44const SCROLL: &str = "page";
45
46/// When the window gets focus and the last reading is older than this, the buds are read again:
47/// the phone may have changed something meanwhile.
48const STALE: Duration = Duration::from_secs(30);
49
50fn main() -> ExitCode {
51 let options = match parse_args(std::env::args().skip(1)) {
52 Ok(Args::Run(options)) => options,
53 Ok(Args::Help) => {
54 println!("{USAGE}");
55 return ExitCode::SUCCESS;
56 }
57 Err(message) => {
58 eprintln!("budctl-gui: {message}\n{USAGE}");
59 return ExitCode::from(1);
60 }
61 };
62 if options.debug {
63 budctl_core::debug::enable();
64 }
65 let theme_file = options.theme.clone().or_else(theme::default_file);
66 let font = theme::system_font();
67 let boot = move || App::new(theme_file.clone(), font, options.device, options.demo);
68 let result = iced::application(boot, App::update, App::view)
69 .title("budctl")
70 .theme(App::theme)
71 .subscription(App::subscription)
72 .default_font(font)
73 .window(window::Settings {
74 size: Size::new(1040.0, 800.0),
75 min_size: Some(Size::new(760.0, 540.0)),
76 platform_specific: window::settings::PlatformSpecific {
77 application_id: "budctl".to_owned(),
78 ..window::settings::PlatformSpecific::default()
79 },
80 ..window::Settings::default()
81 })
82 .run();
83 match result {
84 Ok(()) => ExitCode::SUCCESS,
85 Err(e) => {
86 eprintln!("budctl-gui: {e}");
87 ExitCode::FAILURE
88 }
89 }
90}
91
92#[derive(Default)]
93struct Options {
94 theme: Option<PathBuf>,
95 device: Option<BdAddr>,
96 demo: bool,
97 debug: bool,
98}
99
100enum Args {
101 Run(Options),
102 Help,
103}
104
105fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
106 let mut options = Options::default();
107 while let Some(arg) = args.next() {
108 match arg.as_str() {
109 "--theme" => options.theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
110 "--device" => {
111 let address = args.next().ok_or("--device needs an address")?;
112 options.device = Some(address.parse().map_err(|e| format!("--device: {e}"))?);
113 }
114 "--demo" => options.demo = true,
115 "--debug" => options.debug = true,
116 "-h" | "--help" => return Ok(Args::Help),
117 other => return Err(format!("unknown argument {other:?}")),
118 }
119 }
120 Ok(Args::Run(options))
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum Page {
125 Overview,
126 Equalizer,
127 Gestures,
128 Earbuds,
129 Settings,
130}
131
132impl Page {
133 const ALL: [Page; 5] = [
134 Page::Overview,
135 Page::Equalizer,
136 Page::Gestures,
137 Page::Earbuds,
138 Page::Settings,
139 ];
140
141 fn title(self) -> &'static str {
142 match self {
143 Page::Overview => "Overview",
144 Page::Equalizer => "Equalizer",
145 Page::Gestures => "Gestures",
146 Page::Earbuds => "Earbud settings",
147 Page::Settings => "App settings",
148 }
149 }
150
151 fn icon(self) -> Icon {
152 match self {
153 Page::Overview => Icon::Overview,
154 Page::Equalizer => Icon::Equalizer,
155 Page::Gestures => Icon::Gestures,
156 Page::Earbuds => Icon::Earbud,
157 Page::Settings => Icon::Settings,
158 }
159 }
160
161 /// The next page in the sidebar, or the previous one, going round.
162 fn step(self, forward: bool) -> Page {
163 let count = Page::ALL.len();
164 let at = Page::ALL.iter().position(|&page| page == self).unwrap_or(0);
165 let to = if forward { at + 1 } else { at + count - 1 };
166 Page::ALL.get(to % count).copied().unwrap_or(self)
167 }
168}
169
170/// What the keyboard cursor can rest on, and what a write changes.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum Item {
173 NoiseMode,
174 AncLevel,
175 /// A switch, by its key.
176 Switch(&'static str),
177 /// A row that opens a page.
178 Open(Page),
179}
180
181/// Where the connection to the buds stands.
182#[derive(Debug, Clone, PartialEq)]
183enum Link {
184 /// Made-up buds (`--demo`): nothing talks to Bluetooth.
185 Demo,
186 /// Looking for the buds, or reading them for the first time.
187 Connecting,
188 /// The buds answered the last command.
189 Live,
190 /// The last command failed.
191 Failed { kind: Kind, message: String },
192}
193
194#[derive(Debug, Clone)]
195enum Message {
196 Open(Page),
197 Noise(NoiseMode),
198 /// A switch's key, and whether to turn it on.
199 Switch(&'static str, bool),
200 /// Read the buds again.
201 Refresh,
202 Worker(Event),
203 /// The window got focus: the palette file may have changed meanwhile, and so may the buds.
204 Focused,
205 Key(Key, Modifiers),
206 /// A mouse button went down: the mouse is in use, so the keyboard cursor hides.
207 Pointer,
208 CloseHelp,
209}
210
211struct App {
212 page: Page,
213 buds: demo::Buds,
214 /// The level "Noise cancelling" turns on at: the one last reported or picked.
215 anc_level: AncLevel,
216 look: Look,
217 theme: Theme,
218 palette: theme::Source,
219 keys: keys::Keys,
220 /// Where the keyboard cursor is, among [`App::items`].
221 cursor: usize,
222 /// Whether it shows. Only keys show it; a click hides it.
223 cursor_shown: bool,
224 /// Whether the list of keys is open.
225 help: bool,
226 /// `--device`, for the worker.
227 device: Option<BdAddr>,
228 link: Link,
229 /// Where commands go, once the worker is up.
230 worker: Option<Sender<Command>>,
231 /// The commands sent and not answered yet, oldest first: the item each one writes, or `None`
232 /// for a read. The worker answers them in order.
233 in_flight: VecDeque<Option<Item>>,
234 /// When the buds were last read.
235 read_at: Option<Instant>,
236}
237
238impl App {
239 fn new(theme_file: Option<PathBuf>, font: Font, device: Option<BdAddr>, demo: bool) -> Self {
240 let buds = if demo { demo::buds() } else { demo::unknown() };
241 let anc_level = buds
242 .status
243 .noise_control
244 .as_ref()
245 .and_then(|now| now.level.as_deref())
246 .and_then(anc_level)
247 .unwrap_or(AncLevel::Deep);
248 let mut app = App {
249 page: Page::Overview,
250 buds,
251 anc_level,
252 look: Look {
253 p: theme::BUILT_IN,
254 font,
255 },
256 theme: theme::iced_theme(&theme::BUILT_IN),
257 palette: theme::Source::new(theme_file),
258 keys: keys::Keys::default(),
259 cursor: 0,
260 cursor_shown: false,
261 help: false,
262 device,
263 link: if demo { Link::Demo } else { Link::Connecting },
264 worker: None,
265 in_flight: VecDeque::new(),
266 read_at: None,
267 };
268 app.refresh_palette();
269 app
270 }
271
272 fn update(&mut self, message: Message) -> Task<Message> {
273 match message {
274 Message::Open(page) => return self.open(page),
275 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
276 Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)),
277 Message::Refresh => self.refresh(),
278 Message::Worker(event) => self.answer(event),
279 Message::Focused => {
280 self.refresh_palette();
281 if self.read_at.is_some_and(|at| at.elapsed() >= STALE) && self.in_flight.is_empty() {
282 self.refresh();
283 }
284 }
285 Message::Key(key, modifiers) => return self.key(&key, modifiers),
286 Message::Pointer => self.cursor_shown = false,
287 Message::CloseHelp => self.help = false,
288 }
289 Task::none()
290 }
291
292 /// Changes a setting: at once with the made-up buds, else through the worker. Until the buds
293 /// answer, the item shows that it is saving and takes no other write.
294 fn write(&mut self, item: Item, command: Command) {
295 if self.link == Link::Demo {
296 match command {
297 Command::Noise(mode) => self.apply_noise(NoiseControl {
298 mode: mode.name().to_owned(),
299 level: match mode {
300 NoiseMode::Anc(level) => Some(level.name().to_owned()),
301 NoiseMode::Off | NoiseMode::Transparency => None,
302 },
303 }),
304 Command::Switch(key, on) => {
305 if let Some(switch) = self.buds.status.switches.iter().find(|s| s.key == key) {
306 let switch = Switch { on, ..switch.clone() };
307 self.apply_switch(switch);
308 }
309 }
310 Command::Read => {}
311 }
312 } else if !self.pending().contains(&item) {
313 self.send(command, Some(item));
314 }
315 }
316
317 /// Reads the buds again; with the made-up buds, starts them over.
318 fn refresh(&mut self) {
319 match self.link {
320 Link::Demo => self.buds = demo::buds(),
321 _ if self.in_flight.contains(&None) => {}
322 _ => {
323 if self.read_at.is_none() {
324 self.link = Link::Connecting;
325 }
326 self.send(Command::Read, None);
327 }
328 }
329 }
330
331 fn send(&mut self, command: Command, item: Option<Item>) {
332 let Some(worker) = &self.worker else {
333 return;
334 };
335 if worker.send(command).is_ok() {
336 self.in_flight.push_back(item);
337 }
338 }
339
340 /// Takes in what the worker reports.
341 fn answer(&mut self, event: Event) {
342 match event {
343 Event::Ready(worker) => {
344 self.worker = Some(worker);
345 self.send(Command::Read, None);
346 }
347 Event::Found(name) => self.buds.name = name,
348 Event::Lost(message) => {
349 self.buds.connected = false;
350 self.link = Link::Failed {
351 kind: Kind::NotFound,
352 message,
353 };
354 }
355 Event::Read(reading) => {
356 self.in_flight.pop_front();
357 self.apply_reading(*reading);
358 }
359 Event::Noise(now) => {
360 self.in_flight.pop_front();
361 self.apply_noise(now);
362 self.link = Link::Live;
363 }
364 Event::Switch(switch) => {
365 self.in_flight.pop_front();
366 self.apply_switch(switch);
367 self.link = Link::Live;
368 }
369 Event::Failed { kind, message } => {
370 self.in_flight.pop_front();
371 if kind == Kind::NotFound {
372 self.buds.connected = false;
373 }
374 self.link = Link::Failed { kind, message };
375 }
376 }
377 }
378
379 fn apply_reading(&mut self, reading: Reading) {
380 self.buds.eq_name = eq_name(reading.status.eq.as_deref(), reading.eq.as_ref());
381 if let Some(level) = reading
382 .status
383 .noise_control
384 .as_ref()
385 .and_then(|now| now.level.as_deref())
386 .and_then(anc_level)
387 {
388 self.anc_level = level;
389 }
390 self.buds.status = reading.status;
391 self.buds.connected = true;
392 self.link = Link::Live;
393 self.read_at = Some(Instant::now());
394 }
395
396 fn apply_noise(&mut self, now: NoiseControl) {
397 if let Some(level) = now.level.as_deref().and_then(anc_level) {
398 self.anc_level = level;
399 }
400 self.buds.status.noise_control = Some(now);
401 }
402
403 fn apply_switch(&mut self, switch: Switch) {
404 if let Some(shown) = self.buds.status.switches.iter_mut().find(|s| s.key == switch.key) {
405 *shown = switch;
406 }
407 }
408
409 /// The items with a write on its way.
410 fn pending(&self) -> Vec<Item> {
411 self.in_flight.iter().flatten().copied().collect()
412 }
413
414 /// Shows `page` from its top, with the cursor on its first item.
415 fn open(&mut self, page: Page) -> Task<Message> {
416 self.page = page;
417 self.cursor = 0;
418 operation::snap_to(SCROLL, RelativeOffset::START)
419 }
420
421 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
422 let Some(action) = self.keys.press(key, modifiers) else {
423 return Task::none();
424 };
425 if self.help {
426 // The list of keys is in front of everything: a key only closes it.
427 self.help = false;
428 return Task::none();
429 }
430 match action {
431 Action::Quit => iced::exit(),
432 Action::Help => {
433 self.help = true;
434 Task::none()
435 }
436 Action::Escape => {
437 self.cursor_shown = false;
438 Task::none()
439 }
440 Action::Refresh => {
441 self.refresh();
442 Task::none()
443 }
444 Action::NextPage => self.open(self.page.step(true)),
445 Action::PreviousPage => self.open(self.page.step(false)),
446 Action::Page(n) => match Page::ALL.get(n) {
447 Some(&page) => self.open(page),
448 None => Task::none(),
449 },
450 Action::Down
451 | Action::Up
452 | Action::First
453 | Action::Last
454 | Action::Left
455 | Action::Right
456 | Action::Activate => self.on_cursor(action),
457 }
458 }
459
460 /// What the current page offers the keyboard cursor, top to bottom.
461 fn items(&self) -> Vec<Item> {
462 match self.page {
463 Page::Overview if self.has_reading() => overview::items(&self.buds),
464 _ => Vec::new(),
465 }
466 }
467
468 /// Whether there is anything to show: the made-up buds, or a reading of the real ones.
469 fn has_reading(&self) -> bool {
470 self.link == Link::Demo || self.read_at.is_some()
471 }
472
473 /// The item under the cursor, when the cursor shows.
474 fn focus(&self) -> Option<Item> {
475 let items = self.items();
476 let last = items.len().checked_sub(1)?;
477 self.cursor_shown.then(|| items.get(self.cursor.min(last)).copied())?
478 }
479
480 fn on_cursor(&mut self, action: Action) -> Task<Message> {
481 let items = self.items();
482 let Some(last) = items.len().checked_sub(1) else {
483 return Task::none();
484 };
485 self.cursor = self.cursor.min(last);
486 if !self.cursor_shown {
487 // The first key only shows the cursor, so nothing changes where it cannot be seen.
488 self.cursor_shown = true;
489 return self.follow();
490 }
491 match action {
492 Action::Down => self.cursor = (self.cursor + 1).min(last),
493 Action::Up => self.cursor = self.cursor.saturating_sub(1),
494 Action::First => self.cursor = 0,
495 Action::Last => self.cursor = last,
496 _ => {
497 let change = items.get(self.cursor).and_then(|&item| self.change(item, action));
498 return match change {
499 Some(message) => self.update(message),
500 None => Task::none(),
501 };
502 }
503 }
504 self.follow()
505 }
506
507 /// What `action` (`h`, `l` or Enter) does to `item`, if anything.
508 fn change(&self, item: Item, action: Action) -> Option<Message> {
509 match item {
510 Item::NoiseMode => {
511 let modes = [NoiseMode::Off, NoiseMode::Anc(self.anc_level), NoiseMode::Transparency];
512 let now = self.buds.status.noise_control.as_ref()?;
513 let at = modes.iter().position(|mode| mode.name() == now.mode);
514 step(&modes, at, action).map(Message::Noise)
515 }
516 Item::AncLevel => {
517 let levels = [AncLevel::Light, AncLevel::Medium, AncLevel::Deep];
518 let at = levels.iter().position(|&level| level == self.anc_level);
519 step(&levels, at, action).map(|level| Message::Noise(NoiseMode::Anc(level)))
520 }
521 Item::Switch(key) => {
522 let on = self.buds.status.switches.iter().find(|s| s.key == key)?.on;
523 let want = match action {
524 Action::Left => false,
525 Action::Right => true,
526 _ => !on,
527 };
528 (want != on).then_some(Message::Switch(key, want))
529 }
530 Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)),
531 }
532 }
533
534 /// Scrolls the page so the cursor stays in view: the first item at the top, the last at the
535 /// bottom, the others in between.
536 fn follow(&self) -> Task<Message> {
537 let count = self.items().len();
538 if !self.cursor_shown || count < 2 {
539 return Task::none();
540 }
541 let y = self.cursor as f32 / (count - 1) as f32;
542 operation::snap_to(SCROLL, RelativeOffset { x: 0.0, y })
543 }
544
545 fn refresh_palette(&mut self) {
546 let palette = match self.palette.refresh() {
547 None => return,
548 Some(Ok(palette)) => palette,
549 Some(Err(e)) => {
550 eprintln!("budctl-gui: {e}; using the built-in colors");
551 theme::BUILT_IN
552 }
553 };
554 self.look.p = palette;
555 self.theme = theme::iced_theme(&palette);
556 }
557
558 fn theme(&self) -> Theme {
559 self.theme.clone()
560 }
561
562 fn subscription(&self) -> Subscription<Message> {
563 let events = event::listen_with(|event, _status, _window| match event {
564 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
565 iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
566 Some(Message::Key(key, modifiers))
567 }
568 iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer),
569 _ => None,
570 });
571 if self.link == Link::Demo {
572 return events;
573 }
574 let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker);
575 Subscription::batch([events, worker])
576 }
577
578 fn view(&self) -> Element<'_, Message> {
579 let look = self.look;
580 let pending = self.pending();
581 let page = match self.page {
582 Page::Overview if self.has_reading() => {
583 let marks = overview::Marks {
584 focus: self.focus(),
585 pending: &pending,
586 };
587 overview::view(look, &self.buds, self.anc_level, marks)
588 }
589 Page::Overview => self.connecting(),
590 page => column![
591 ui::title(look, page.title()),
592 text("This page comes in a later step.").size(14).color(look.p.dim)
593 ]
594 .spacing(12)
595 .into(),
596 };
597 let page = column![self.problem(), page].spacing(18);
598 let p = look.p;
599 let content = scrollable(
600 container(container(page).max_width(860))
601 .center_x(Length::Fill)
602 .padding([30, 36]),
603 )
604 .id(SCROLL)
605 .height(Length::Fill)
606 .style(move |theme, status| {
607 let mut style = scrollable::default(theme, status);
608 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
609 rail.background = None;
610 rail.border = Border::default();
611 rail.scroller.background = p.surface_highest.into();
612 rail.scroller.border = Border::default().rounded(4);
613 }
614 style
615 });
616 let window = row![self.sidebar(), ui::divider(look), content];
617 if self.help {
618 stack![window, self.help()].into()
619 } else {
620 window.into()
621 }
622 }
623
624 /// The Overview before the first reading: connecting, or why it did not work.
625 fn connecting(&self) -> Element<'_, Message> {
626 let look = self.look;
627 let p = look.p;
628 let (headline, detail) = match &self.link {
629 Link::Failed {
630 kind: Kind::NotFound,
631 message,
632 } => ("Your buds are not connected", message.as_str()),
633 Link::Failed {
634 kind: Kind::Busy,
635 message,
636 } => ("The control channel is busy", message.as_str()),
637 Link::Failed { message, .. } => ("Something went wrong", message.as_str()),
638 _ => (
639 "Connecting to your buds…",
640 "budctl looks for the paired buds it supports and reads them.",
641 ),
642 };
643 let retry = matches!(self.link, Link::Failed { .. })
644 .then(|| container(ui::outline_button(look, "Try again", Message::Refresh)).width(180));
645 let words = column![
646 text(headline).size(20).font(look.bold()).color(p.fg),
647 text(detail).size(14).color(p.dim)
648 ]
649 .spacing(8);
650 column![
651 ui::title(look, "Overview"),
652 ui::panel(look, column![words].push(retry).spacing(18).into())
653 ]
654 .spacing(18)
655 .into()
656 }
657
658 /// A failure after the buds were read once: what went wrong, above the page.
659 fn problem(&self) -> Option<Element<'_, Message>> {
660 let Link::Failed { message, .. } = &self.link else {
661 return None;
662 };
663 // Before the first reading, the Overview says it already.
664 self.read_at?;
665 let look = self.look;
666 let p = look.p;
667 let content = row![
668 text(message.as_str()).size(14).color(p.err).width(Length::Fill),
669 container(ui::outline_button(look, "Try again", Message::Refresh)).width(140)
670 ]
671 .spacing(16)
672 .align_y(Alignment::Center);
673 Some(
674 container(content)
675 .padding([12, 16])
676 .width(Length::Fill)
677 .style(move |_| container::Style {
678 background: Some(p.surface.into()),
679 border: Border {
680 color: p.err,
681 width: 1.0,
682 radius: 14.0.into(),
683 },
684 ..container::Style::default()
685 })
686 .into(),
687 )
688 }
689
690 fn sidebar(&self) -> Element<'_, Message> {
691 let look = self.look;
692 let p = look.p;
693 let (light, state) = match &self.link {
694 Link::Demo | Link::Live => (p.ok, "Connected"),
695 Link::Connecting => (p.dim, "Connecting…"),
696 Link::Failed { kind: Kind::Busy, .. } => (p.err, "Busy"),
697 Link::Failed { .. } if !self.buds.connected => (p.dim, "Not connected"),
698 Link::Failed { .. } => (p.err, "Error"),
699 };
700 let device = container(
701 column![
702 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
703 row![ui::light(light), text(state).size(13).color(p.dim), space::horizontal()]
704 .push((self.link == Link::Demo).then(|| ui::tag(look, "demo")))
705 .spacing(8)
706 .align_y(Alignment::Center),
707 ui::outline_button(look, "Refresh", Message::Refresh),
708 ]
709 .spacing(12),
710 )
711 .padding(16)
712 .style(move |_| container::Style {
713 background: Some(p.surface.into()),
714 border: Border {
715 color: p.line,
716 width: 1.0,
717 radius: 18.0.into(),
718 },
719 ..container::Style::default()
720 });
721 let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
722 pages.push(ui::nav_item(
723 look,
724 page.icon(),
725 page.title(),
726 page == self.page,
727 Message::Open(page),
728 ))
729 });
730 let hint = text("Press ? for keys").size(12.5).color(p.dim);
731 container(column![device, pages, space::vertical(), hint].spacing(22))
732 .padding([18, 14])
733 .width(268)
734 .height(Length::Fill)
735 .style(move |_| container::Style {
736 background: Some(p.surface_low.into()),
737 ..container::Style::default()
738 })
739 .into()
740 }
741
742 /// The list of keys, over a dimmed window. A key or a click closes it.
743 fn help(&self) -> Element<'_, Message> {
744 let look = self.look;
745 let p = look.p;
746 let rows = keys::HELP.iter().fold(Column::new().spacing(9), |rows, &(keys, what)| {
747 rows.push(row![
748 text(keys).size(14).font(look.medium()).color(p.fg).width(170),
749 text(what).size(14).color(p.dim)
750 ])
751 });
752 let list = ui::panel(look, column![ui::title(look, "Keys"), rows].spacing(18).into());
753 let backdrop = container(container(list).width(560))
754 .center(Length::Fill)
755 .style(|_| container::Style {
756 background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
757 ..container::Style::default()
758 });
759 opaque(mouse_area(backdrop).on_press(Message::CloseHelp))
760 }
761}
762
763/// The choice `action` moves to from the one at `at`: `h` and `l` stop at the ends, Enter goes
764/// round. With nothing chosen, any of them picks the first.
765fn step<T: Copy>(choices: &[T], at: Option<usize>, action: Action) -> Option<T> {
766 let to = match (action, at) {
767 (_, None) => 0,
768 (Action::Left, Some(at)) => at.checked_sub(1)?,
769 (Action::Right, Some(at)) => at + 1,
770 (Action::Activate, Some(at)) => (at + 1).checked_rem(choices.len())?,
771 _ => return None,
772 };
773 choices.get(to).copied()
774}
775
776/// The level a [`NoiseControl::level`] names.
777fn anc_level(name: &str) -> Option<AncLevel> {
778 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
779 .into_iter()
780 .find(|level| level.name() == name)
781}
782
783/// The name to show for the EQ preset in use: a custom preset's own name (`custom-4` →
784/// "Custom1"), else the preset's name in words (`pure-bass` → "Pure bass").
785fn eq_name(current: Option<&str>, presets: Option<&EqPresets>) -> String {
786 let Some(current) = current else {
787 return "Unknown".to_owned();
788 };
789 let custom = current
790 .strip_prefix("custom-")
791 .and_then(|id| id.parse::<u8>().ok())
792 .and_then(|id| presets?.presets.iter().find(|p| p.custom && p.id == id))
793 .map(|preset| preset.name.clone());
794 custom.unwrap_or_else(|| {
795 let words = current.replace('-', " ");
796 let mut letters = words.chars();
797 letters
798 .next()
799 .map(|first| first.to_uppercase().chain(letters).collect())
800 .unwrap_or_default()
801 })
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use budctl_core::status::EqPreset;
808 use iced::keyboard::key::Named;
809 use std::fs;
810 use std::path::Path;
811 use std::sync::mpsc::{self, Receiver};
812
813 /// With the made-up buds.
814 fn app() -> App {
815 App::new(None, Font::DEFAULT, None, true)
816 }
817
818 /// Talking to a worker that the test plays: the receiver gets what the app sends it.
819 fn live() -> (App, Receiver<Command>) {
820 let mut app = App::new(None, Font::DEFAULT, None, false);
821 let (worker, commands) = mpsc::channel();
822 let _ = app.update(Message::Worker(Event::Ready(worker)));
823 (app, commands)
824 }
825
826 fn reading() -> Reading {
827 Reading {
828 status: demo::buds().status,
829 eq: None,
830 }
831 }
832
833 /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`.
834 fn press(app: &mut App, keys: &str) {
835 for name in keys.split_whitespace() {
836 let (key, modifiers) = match name {
837 "Enter" => (Key::Named(Named::Enter), Modifiers::empty()),
838 "Esc" => (Key::Named(Named::Escape), Modifiers::empty()),
839 c if c.chars().all(char::is_uppercase) => (Key::Character(c.into()), Modifiers::SHIFT),
840 c => (Key::Character(c.into()), Modifiers::empty()),
841 };
842 let _ = app.update(Message::Key(key, modifiers));
843 }
844 }
845
846 fn mode(app: &App) -> &str {
847 &app.buds.status.noise_control.as_ref().unwrap().mode
848 }
849
850 fn switch(app: &App, key: &str) -> bool {
851 app.buds.status.switches.iter().find(|s| s.key == key).unwrap().on
852 }
853
854 #[test]
855 fn noise_cancelling_remembers_its_level() {
856 let mut app = app();
857 let _ = app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
858 let _ = app.update(Message::Noise(NoiseMode::Transparency));
859 assert_eq!(app.anc_level, AncLevel::Light);
860 let now = app.buds.status.noise_control.as_ref().unwrap();
861 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
862 let _ = app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
863 let now = app.buds.status.noise_control.as_ref().unwrap();
864 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
865 }
866
867 #[test]
868 fn switches_change_by_key() {
869 let mut app = app();
870 let _ = app.update(Message::Switch("game", true));
871 assert!(switch(&app, "game"));
872 }
873
874 #[test]
875 fn the_overview_items_in_order() {
876 let mut app = app();
877 assert_eq!(
878 app.items(),
879 [
880 Item::NoiseMode,
881 Item::AncLevel,
882 Item::Switch("game"),
883 Item::Switch("spatial"),
884 Item::Switch("bass-boost"),
885 Item::Switch("wind"),
886 Item::Switch("dual"),
887 Item::Open(Page::Equalizer),
888 Item::Open(Page::Gestures),
889 Item::Open(Page::Earbuds),
890 ]
891 );
892 let _ = app.update(Message::Noise(NoiseMode::Off));
893 assert!(!app.items().contains(&Item::AncLevel));
894 }
895
896 #[test]
897 fn the_first_key_only_shows_the_cursor() {
898 let mut app = app();
899 assert_eq!(app.focus(), None);
900 press(&mut app, "l");
901 assert_eq!(app.focus(), Some(Item::NoiseMode));
902 assert_eq!(mode(&app), "anc");
903 }
904
905 #[test]
906 fn h_and_l_step_through_choices_and_enter_goes_round() {
907 let mut app = app();
908 press(&mut app, "j l");
909 assert_eq!(mode(&app), "transparency");
910 press(&mut app, "l");
911 assert_eq!(mode(&app), "transparency");
912 press(&mut app, "h h h");
913 assert_eq!(mode(&app), "off");
914 press(&mut app, "Enter Enter");
915 assert_eq!(mode(&app), "transparency");
916 press(&mut app, "Enter");
917 assert_eq!(mode(&app), "off");
918 // On the strength: deep at first.
919 press(&mut app, "l j");
920 assert_eq!(app.focus(), Some(Item::AncLevel));
921 press(&mut app, "h h h");
922 assert_eq!(app.anc_level, AncLevel::Light);
923 press(&mut app, "Enter");
924 assert_eq!(app.anc_level, AncLevel::Medium);
925 assert_eq!(
926 app.buds.status.noise_control.as_ref().unwrap().level.as_deref(),
927 Some("medium")
928 );
929 }
930
931 #[test]
932 fn switches_take_h_l_and_enter() {
933 let mut app = app();
934 press(&mut app, "j j j");
935 assert_eq!(app.focus(), Some(Item::Switch("game")));
936 press(&mut app, "l");
937 assert!(switch(&app, "game"));
938 press(&mut app, "l");
939 assert!(switch(&app, "game"));
940 press(&mut app, "h");
941 assert!(!switch(&app, "game"));
942 press(&mut app, "Enter");
943 assert!(switch(&app, "game"));
944 }
945
946 #[test]
947 fn moving_stops_at_the_ends() {
948 let mut app = app();
949 press(&mut app, "k k");
950 assert_eq!(app.focus(), Some(Item::NoiseMode));
951 press(&mut app, "G j j");
952 assert_eq!(app.focus(), Some(Item::Open(Page::Earbuds)));
953 press(&mut app, "g g");
954 assert_eq!(app.focus(), Some(Item::NoiseMode));
955 }
956
957 #[test]
958 fn rows_open_pages_and_the_cursor_starts_over() {
959 let mut app = app();
960 press(&mut app, "G G h");
961 assert_eq!(app.page, Page::Overview);
962 press(&mut app, "l");
963 assert_eq!(app.page, Page::Earbuds);
964 assert_eq!(app.cursor, 0);
965 assert!(app.cursor_shown);
966 }
967
968 #[test]
969 fn pages_by_key() {
970 let mut app = app();
971 press(&mut app, "J");
972 assert_eq!(app.page, Page::Equalizer);
973 press(&mut app, "K K");
974 assert_eq!(app.page, Page::Settings);
975 press(&mut app, "1");
976 assert_eq!(app.page, Page::Overview);
977 press(&mut app, "g t");
978 assert_eq!(app.page, Page::Equalizer);
979 press(&mut app, "g T");
980 assert_eq!(app.page, Page::Overview);
981 press(&mut app, "9");
982 assert_eq!(app.page, Page::Overview);
983 }
984
985 #[test]
986 fn esc_and_clicks_hide_the_cursor() {
987 let mut app = app();
988 press(&mut app, "j j");
989 assert!(app.focus().is_some());
990 press(&mut app, "Esc");
991 assert_eq!(app.focus(), None);
992 press(&mut app, "j");
993 let _ = app.update(Message::Pointer);
994 assert_eq!(app.focus(), None);
995 // It comes back where it was.
996 press(&mut app, "j");
997 assert_eq!(app.focus(), Some(Item::AncLevel));
998 }
999
1000 #[test]
1001 fn the_list_of_keys_closes_on_any_key() {
1002 let mut app = app();
1003 press(&mut app, "?");
1004 assert!(app.help);
1005 press(&mut app, "j");
1006 assert!(!app.help);
1007 assert_eq!(app.focus(), None);
1008 press(&mut app, "?");
1009 let _ = app.update(Message::CloseHelp);
1010 assert!(!app.help);
1011 }
1012
1013 #[test]
1014 fn live_buds_are_read_first() {
1015 let (mut app, commands) = live();
1016 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1017 assert_eq!(app.link, Link::Connecting);
1018 assert!(app.items().is_empty(), "nothing to move over before the first reading");
1019 // A second refresh while the read is on its way sends nothing.
1020 press(&mut app, "r");
1021 assert!(commands.try_recv().is_err());
1022
1023 let _ = app.update(Message::Worker(Event::Found("realme Buds T310".to_owned())));
1024 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1025 assert_eq!(app.link, Link::Live);
1026 assert_eq!(app.buds.name, "realme Buds T310");
1027 assert!(app.buds.connected);
1028 assert_eq!(app.items().len(), 10);
1029 }
1030
1031 #[test]
1032 fn live_writes_wait_for_the_buds() {
1033 let (mut app, commands) = live();
1034 let _ = commands.try_recv();
1035 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1036
1037 let _ = app.update(Message::Switch("game", true));
1038 assert!(matches!(commands.try_recv(), Ok(Command::Switch("game", true))));
1039 assert!(!switch(&app, "game"), "the switch changes when the buds say so");
1040 assert_eq!(app.pending(), [Item::Switch("game")]);
1041 // While it is on its way, the same switch takes no other write.
1042 press(&mut app, "j j j Enter");
1043 assert!(commands.try_recv().is_err());
1044
1045 let game = Switch {
1046 key: "game",
1047 label: "Game mode",
1048 on: true,
1049 };
1050 let _ = app.update(Message::Worker(Event::Switch(game)));
1051 assert!(switch(&app, "game"));
1052 assert!(app.pending().is_empty());
1053
1054 let _ = app.update(Message::Noise(NoiseMode::Transparency));
1055 assert!(matches!(
1056 commands.try_recv(),
1057 Ok(Command::Noise(NoiseMode::Transparency))
1058 ));
1059 assert_eq!(mode(&app), "anc");
1060 let now = NoiseControl {
1061 mode: "transparency".to_owned(),
1062 level: None,
1063 };
1064 let _ = app.update(Message::Worker(Event::Noise(now)));
1065 assert_eq!(mode(&app), "transparency");
1066 }
1067
1068 #[test]
1069 fn failures_say_why_and_clear_the_write() {
1070 let (mut app, commands) = live();
1071 let _ = app.update(Message::Worker(Event::Failed {
1072 kind: Kind::Busy,
1073 message: "control channel busy".to_owned(),
1074 }));
1075 assert!(matches!(app.link, Link::Failed { kind: Kind::Busy, .. }));
1076 assert!(app.problem().is_none(), "the Overview says it, not a banner");
1077
1078 let _ = commands.try_recv();
1079 let _ = app.update(Message::Refresh);
1080 assert!(matches!(commands.try_recv(), Ok(Command::Read)));
1081 assert_eq!(app.link, Link::Connecting);
1082 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1083
1084 let _ = app.update(Message::Switch("dual", false));
1085 let _ = app.update(Message::Worker(Event::Failed {
1086 kind: Kind::NotFound,
1087 message: "the buds closed the control link".to_owned(),
1088 }));
1089 assert!(app.pending().is_empty());
1090 assert!(switch(&app, "dual"));
1091 assert!(!app.buds.connected);
1092 assert!(app.problem().is_some());
1093 }
1094
1095 #[test]
1096 fn eq_names() {
1097 let presets = EqPresets {
1098 current: Some("custom-4".to_owned()),
1099 presets: vec![EqPreset {
1100 name: "Custom1".to_owned(),
1101 id: 4,
1102 custom: true,
1103 bands: Vec::new(),
1104 }],
1105 warnings: Vec::new(),
1106 };
1107 assert_eq!(eq_name(Some("custom-4"), Some(&presets)), "Custom1");
1108 assert_eq!(eq_name(Some("custom-4"), None), "Custom 4");
1109 assert_eq!(eq_name(Some("pure-bass"), Some(&presets)), "Pure bass");
1110 assert_eq!(eq_name(None, None), "Unknown");
1111 }
1112
1113 #[test]
1114 fn steps() {
1115 let abc = ['a', 'b', 'c'];
1116 assert_eq!(step(&abc, Some(0), Action::Left), None);
1117 assert_eq!(step(&abc, Some(0), Action::Right), Some('b'));
1118 assert_eq!(step(&abc, Some(2), Action::Right), None);
1119 assert_eq!(step(&abc, Some(2), Action::Activate), Some('a'));
1120 assert_eq!(step(&abc, None, Action::Left), Some('a'));
1121 }
1122
1123 #[test]
1124 fn arguments() {
1125 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
1126 assert!(matches!(args(&[]), Ok(Args::Run(Options { demo: false, .. }))));
1127 assert!(matches!(args(&["--demo"]), Ok(Args::Run(Options { demo: true, .. }))));
1128 assert!(matches!(args(&["--debug"]), Ok(Args::Run(Options { debug: true, .. }))));
1129 assert!(matches!(
1130 args(&["--theme", "x.json"]),
1131 Ok(Args::Run(Options { theme: Some(_), .. }))
1132 ));
1133 assert!(matches!(
1134 args(&["--device", "88:0E:85:46:FE:5C"]),
1135 Ok(Args::Run(Options { device: Some(_), .. }))
1136 ));
1137 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
1138 assert!(args(&["--theme"]).is_err());
1139 assert!(args(&["--device", "nope"]).is_err());
1140 assert!(args(&["--bogus"]).is_err());
1141 }
1142
1143 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
1144 /// display. `BUDCTL_THEME=<file>` renders with that palette.
1145 #[test]
1146 fn pages_render() {
1147 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
1148 let _ = fs::remove_dir_all(&dir);
1149 let font = theme::system_font();
1150 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
1151 let on = |page: Page, keys: &str| {
1152 let mut app = App::new(palette.clone(), font, None, true);
1153 app.page = page;
1154 press(&mut app, keys);
1155 app
1156 };
1157 let mut shots: Vec<(String, f32, App)> = Page::ALL
1158 .into_iter()
1159 .map(|page| (format!("{page:?}").to_lowercase(), 800.0, on(page, "")))
1160 .collect();
1161 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
1162 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
1163 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
1164 let mut connecting = App::new(palette.clone(), font, None, false);
1165 connecting.link = Link::Connecting;
1166 shots.push(("connecting".to_owned(), 800.0, connecting));
1167 let mut busy = App::new(palette.clone(), font, None, false);
1168 busy.answer(Event::Failed {
1169 kind: Kind::Busy,
1170 message: "control channel busy: is another app connected?".to_owned(),
1171 });
1172 shots.push(("busy".to_owned(), 800.0, busy));
1173 let (mut saving, _commands) = live();
1174 saving.answer(Event::Read(Box::new(reading())));
1175 saving.answer(Event::Failed {
1176 kind: Kind::Failed,
1177 message: "the buds did not answer: no reply to command 0x0403".to_owned(),
1178 });
1179 let _ = saving.update(Message::Switch("spatial", false));
1180 let _ = saving.update(Message::Noise(NoiseMode::Off));
1181 shots.push(("saving".to_owned(), 1240.0, saving));
1182 for (name, height, app) in shots {
1183 let settings = iced::Settings {
1184 default_font: font,
1185 ..iced::Settings::default()
1186 };
1187 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
1188 let snapshot = ui.snapshot(&app.theme).unwrap();
1189 assert!(snapshot.matches_image(dir.join(name)).unwrap());
1190 }
1191 }
1192}