| @@ -1,8 +1,8 @@ |
| 1 | 1 | | //! `budctl-gui`: control wireless earbuds from Linux, in a window. |
| 2 | 2 | | //! |
| 3 | | − | //! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first, |
| 4 | | − | //! the live connection later (CLAUDE.md, GUI). The mouse comes first; vim-like keys work on top |
| 5 | | − | //! (`keys.rs`). |
| 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 | 6 | | |
| 7 | 7 | | mod demo; |
| 8 | 8 | | mod icons; |
| @@ -10,11 +10,17 @@ mod keys; |
| 10 | 10 | | mod overview; |
| 11 | 11 | | mod theme; |
| 12 | 12 | | mod ui; |
| 13 | + | mod worker; |
| 13 | 14 | | |
| 15 | + | use std::collections::VecDeque; |
| 14 | 16 | | use std::path::PathBuf; |
| 15 | 17 | | use std::process::ExitCode; |
| 18 | + | use std::sync::mpsc::Sender; |
| 19 | + | use std::time::{Duration, Instant}; |
| 16 | 20 | | |
| 17 | | − | use budctl_core::status::{AncLevel, NoiseControl, NoiseMode}; |
| 21 | + | use budctl_core::Kind; |
| 22 | + | use budctl_core::bt::BdAddr; |
| 23 | + | use budctl_core::status::{AncLevel, EqPresets, NoiseControl, NoiseMode, Switch}; |
| 18 | 24 | | use iced::keyboard::{self, Key, Modifiers}; |
| 19 | 25 | | use iced::widget::operation::{self, RelativeOffset}; |
| 20 | 26 | | use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text}; |
| @@ -23,9 +29,13 @@ use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, |
| 23 | 29 | | use icons::Icon; |
| 24 | 30 | | use keys::Action; |
| 25 | 31 | | use theme::Look; |
| 32 | + | use worker::{Command, Event, Reading}; |
| 26 | 33 | | |
| 27 | | − | const USAGE: &str = "usage: budctl-gui [--theme <palette.json>] |
| 34 | + | const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>] [--demo] [--debug] |
| 28 | 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. |
| 29 | 39 | | Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in. |
| 30 | 40 | | The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …). |
| 31 | 41 | | Press ? in the window for the keys."; |
| @@ -33,9 +43,13 @@ Press ? in the window for the keys."; |
| 33 | 43 | | /// The page's scrolling area, so the cursor can be kept in view. |
| 34 | 44 | | const SCROLL: &str = "page"; |
| 35 | 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. |
| 48 | + | const STALE: Duration = Duration::from_secs(30); |
| 49 | + | |
| 36 | 50 | | fn main() -> ExitCode { |
| 37 | | − | let theme_file = match parse_args(std::env::args().skip(1)) { |
| 38 | | − | Ok(Args::Run { theme }) => theme.or_else(theme::default_file), |
| 51 | + | let options = match parse_args(std::env::args().skip(1)) { |
| 52 | + | Ok(Args::Run(options)) => options, |
| 39 | 53 | | Ok(Args::Help) => { |
| 40 | 54 | | println!("{USAGE}"); |
| 41 | 55 | | return ExitCode::SUCCESS; |
| @@ -45,8 +59,13 @@ fn main() -> ExitCode { |
| 45 | 59 | | return ExitCode::from(1); |
| 46 | 60 | | } |
| 47 | 61 | | }; |
| 62 | + | if options.debug { |
| 63 | + | budctl_core::debug::enable(); |
| 64 | + | } |
| 65 | + | let theme_file = options.theme.clone().or_else(theme::default_file); |
| 48 | 66 | | let font = theme::system_font(); |
| 49 | | − | let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view) |
| 67 | + | let boot = move || App::new(theme_file.clone(), font, options.device, options.demo); |
| 68 | + | let result = iced::application(boot, App::update, App::view) |
| 50 | 69 | | .title("budctl") |
| 51 | 70 | | .theme(App::theme) |
| 52 | 71 | | .subscription(App::subscription) |
| @@ -70,21 +89,35 @@ fn main() -> ExitCode { |
| 70 | 89 | | } |
| 71 | 90 | | } |
| 72 | 91 | | |
| 92 | + | #[derive(Default)] |
| 93 | + | struct Options { |
| 94 | + | theme: Option<PathBuf>, |
| 95 | + | device: Option<BdAddr>, |
| 96 | + | demo: bool, |
| 97 | + | debug: bool, |
| 98 | + | } |
| 99 | + | |
| 73 | 100 | | enum Args { |
| 74 | | − | Run { theme: Option<PathBuf> }, |
| 101 | + | Run(Options), |
| 75 | 102 | | Help, |
| 76 | 103 | | } |
| 77 | 104 | | |
| 78 | 105 | | fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> { |
| 79 | | − | let mut theme = None; |
| 106 | + | let mut options = Options::default(); |
| 80 | 107 | | while let Some(arg) = args.next() { |
| 81 | 108 | | match arg.as_str() { |
| 82 | | − | "--theme" => theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)), |
| 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, |
| 83 | 116 | | "-h" | "--help" => return Ok(Args::Help), |
| 84 | 117 | | other => return Err(format!("unknown argument {other:?}")), |
| 85 | 118 | | } |
| 86 | 119 | | } |
| 87 | | − | Ok(Args::Run { theme }) |
| 120 | + | Ok(Args::Run(options)) |
| 88 | 121 | | } |
| 89 | 122 | | |
| 90 | 123 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| @@ -134,7 +167,7 @@ impl Page { |
| 134 | 167 | | } |
| 135 | 168 | | } |
| 136 | 169 | | |
| 137 | | − | /// What the keyboard cursor can rest on. |
| 170 | + | /// What the keyboard cursor can rest on, and what a write changes. |
| 138 | 171 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 139 | 172 | | enum Item { |
| 140 | 173 | | NoiseMode, |
| @@ -145,14 +178,29 @@ enum Item { |
| 145 | 178 | | Open(Page), |
| 146 | 179 | | } |
| 147 | 180 | | |
| 181 | + | /// Where the connection to the buds stands. |
| 182 | + | #[derive(Debug, Clone, PartialEq)] |
| 183 | + | enum 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 | + | |
| 148 | 194 | | #[derive(Debug, Clone)] |
| 149 | 195 | | enum Message { |
| 150 | 196 | | Open(Page), |
| 151 | 197 | | Noise(NoiseMode), |
| 152 | 198 | | /// A switch's key, and whether to turn it on. |
| 153 | 199 | | Switch(&'static str, bool), |
| 154 | | − | Connect(bool), |
| 155 | | − | /// The window got focus: the palette file may have changed meanwhile. |
| 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. |
| 156 | 204 | | Focused, |
| 157 | 205 | | Key(Key, Modifiers), |
| 158 | 206 | | /// A mouse button went down: the mouse is in use, so the keyboard cursor hides. |
| @@ -175,11 +223,21 @@ struct App { |
| 175 | 223 | | cursor_shown: bool, |
| 176 | 224 | | /// Whether the list of keys is open. |
| 177 | 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>, |
| 178 | 236 | | } |
| 179 | 237 | | |
| 180 | 238 | | impl App { |
| 181 | | − | fn new(theme_file: Option<PathBuf>, font: Font) -> Self { |
| 182 | | − | let buds = demo::buds(); |
| 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() }; |
| 183 | 241 | | let anc_level = buds |
| 184 | 242 | | .status |
| 185 | 243 | | .noise_control |
| @@ -201,6 +259,11 @@ impl App { |
| 201 | 259 | | cursor: 0, |
| 202 | 260 | | cursor_shown: false, |
| 203 | 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, |
| 204 | 267 | | }; |
| 205 | 268 | | app.refresh_palette(); |
| 206 | 269 | | app |
| @@ -209,26 +272,16 @@ impl App { |
| 209 | 272 | | fn update(&mut self, message: Message) -> Task<Message> { |
| 210 | 273 | | match message { |
| 211 | 274 | | Message::Open(page) => return self.open(page), |
| 212 | | − | Message::Noise(mode) => { |
| 213 | | − | let level = match mode { |
| 214 | | − | NoiseMode::Anc(level) => { |
| 215 | | − | self.anc_level = level; |
| 216 | | − | Some(level.name().to_owned()) |
| 217 | | − | } |
| 218 | | − | NoiseMode::Off | NoiseMode::Transparency => None, |
| 219 | | − | }; |
| 220 | | − | self.buds.status.noise_control = Some(NoiseControl { |
| 221 | | − | mode: mode.name().to_owned(), |
| 222 | | − | level, |
| 223 | | − | }); |
| 224 | | − | } |
| 225 | | − | Message::Switch(key, on) => { |
| 226 | | − | if let Some(switch) = self.buds.status.switches.iter_mut().find(|s| s.key == key) { |
| 227 | | − | switch.on = on; |
| 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(); |
| 228 | 283 | | } |
| 229 | 284 | | } |
| 230 | | − | Message::Connect(on) => self.buds.connected = on, |
| 231 | | − | Message::Focused => self.refresh_palette(), |
| 232 | 285 | | Message::Key(key, modifiers) => return self.key(&key, modifiers), |
| 233 | 286 | | Message::Pointer => self.cursor_shown = false, |
| 234 | 287 | | Message::CloseHelp => self.help = false, |
| @@ -236,6 +289,128 @@ impl App { |
| 236 | 289 | | Task::none() |
| 237 | 290 | | } |
| 238 | 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 | + | |
| 239 | 414 | | /// Shows `page` from its top, with the cursor on its first item. |
| 240 | 415 | | fn open(&mut self, page: Page) -> Task<Message> { |
| 241 | 416 | | self.page = page; |
| @@ -262,6 +437,10 @@ impl App { |
| 262 | 437 | | self.cursor_shown = false; |
| 263 | 438 | | Task::none() |
| 264 | 439 | | } |
| 440 | + | Action::Refresh => { |
| 441 | + | self.refresh(); |
| 442 | + | Task::none() |
| 443 | + | } |
| 265 | 444 | | Action::NextPage => self.open(self.page.step(true)), |
| 266 | 445 | | Action::PreviousPage => self.open(self.page.step(false)), |
| 267 | 446 | | Action::Page(n) => match Page::ALL.get(n) { |
| @@ -281,11 +460,16 @@ impl App { |
| 281 | 460 | | /// What the current page offers the keyboard cursor, top to bottom. |
| 282 | 461 | | fn items(&self) -> Vec<Item> { |
| 283 | 462 | | match self.page { |
| 284 | | − | Page::Overview => overview::items(&self.buds), |
| 463 | + | Page::Overview if self.has_reading() => overview::items(&self.buds), |
| 285 | 464 | | _ => Vec::new(), |
| 286 | 465 | | } |
| 287 | 466 | | } |
| 288 | 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 | + | |
| 289 | 473 | | /// The item under the cursor, when the cursor shows. |
| 290 | 474 | | fn focus(&self) -> Option<Item> { |
| 291 | 475 | | let items = self.items(); |
| @@ -376,20 +560,33 @@ impl App { |
| 376 | 560 | | } |
| 377 | 561 | | |
| 378 | 562 | | fn subscription(&self) -> Subscription<Message> { |
| 379 | | − | event::listen_with(|event, _status, _window| match event { |
| 563 | + | let events = event::listen_with(|event, _status, _window| match event { |
| 380 | 564 | | iced::Event::Window(window::Event::Focused) => Some(Message::Focused), |
| 381 | 565 | | iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { |
| 382 | 566 | | Some(Message::Key(key, modifiers)) |
| 383 | 567 | | } |
| 384 | 568 | | iced::Event::Mouse(mouse::Event::ButtonPressed(_)) => Some(Message::Pointer), |
| 385 | 569 | | _ => None, |
| 386 | | − | }) |
| 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]) |
| 387 | 576 | | } |
| 388 | 577 | | |
| 389 | 578 | | fn view(&self) -> Element<'_, Message> { |
| 390 | 579 | | let look = self.look; |
| 580 | + | let pending = self.pending(); |
| 391 | 581 | | let page = match self.page { |
| 392 | | − | Page::Overview => overview::view(look, &self.buds, self.anc_level, self.focus()), |
| 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(), |
| 393 | 590 | | page => column![ |
| 394 | 591 | | ui::title(look, page.title()), |
| 395 | 592 | | text("This page comes in a later step.").size(14).color(look.p.dim) |
| @@ -397,6 +594,7 @@ impl App { |
| 397 | 594 | | .spacing(12) |
| 398 | 595 | | .into(), |
| 399 | 596 | | }; |
| 597 | + | let page = column![self.problem(), page].spacing(18); |
| 400 | 598 | | let p = look.p; |
| 401 | 599 | | let content = scrollable( |
| 402 | 600 | | container(container(page).max_width(860)) |
| @@ -423,31 +621,90 @@ impl App { |
| 423 | 621 | | } |
| 424 | 622 | | } |
| 425 | 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 | + | |
| 426 | 690 | | fn sidebar(&self) -> Element<'_, Message> { |
| 427 | 691 | | let look = self.look; |
| 428 | 692 | | let p = look.p; |
| 429 | | − | let connected = self.buds.connected; |
| 430 | | − | let (light, state) = if connected { |
| 431 | | − | (p.ok, "Connected") |
| 432 | | − | } else { |
| 433 | | − | (p.dim, "Not connected") |
| 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"), |
| 434 | 699 | | }; |
| 435 | 700 | | let device = container( |
| 436 | 701 | | column![ |
| 437 | 702 | | text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg), |
| 438 | | − | row![ |
| 439 | | − | ui::light(light), |
| 440 | | − | text(state).size(13).color(p.dim), |
| 441 | | − | space::horizontal(), |
| 442 | | − | ui::tag(look, "demo") |
| 443 | | − | ] |
| 444 | | − | .spacing(8) |
| 445 | | − | .align_y(Alignment::Center), |
| 446 | | − | ui::outline_button( |
| 447 | | − | look, |
| 448 | | − | if connected { "Disconnect" } else { "Connect" }, |
| 449 | | − | Message::Connect(!connected) |
| 450 | | − | ), |
| 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), |
| 451 | 708 | | ] |
| 452 | 709 | | .spacing(12), |
| 453 | 710 | | ) |
| @@ -523,15 +780,54 @@ fn anc_level(name: &str) -> Option<AncLevel> { |
| 523 | 780 | | .find(|level| level.name() == name) |
| 524 | 781 | | } |
| 525 | 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"). |
| 785 | + | fn 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 | + | |
| 526 | 804 | | #[cfg(test)] |
| 527 | 805 | | mod tests { |
| 528 | 806 | | use super::*; |
| 807 | + | use budctl_core::status::EqPreset; |
| 529 | 808 | | use iced::keyboard::key::Named; |
| 530 | 809 | | use std::fs; |
| 531 | 810 | | use std::path::Path; |
| 811 | + | use std::sync::mpsc::{self, Receiver}; |
| 532 | 812 | | |
| 813 | + | /// With the made-up buds. |
| 533 | 814 | | fn app() -> App { |
| 534 | | − | App::new(None, Font::DEFAULT) |
| 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 | + | } |
| 535 | 831 | | } |
| 536 | 832 | | |
| 537 | 833 | | /// Presses keys, given by name and separated by spaces: `j`, `G`, `Enter`, `Esc`. |
| @@ -714,6 +1010,106 @@ mod tests { |
| 714 | 1010 | | assert!(!app.help); |
| 715 | 1011 | | } |
| 716 | 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 | + | |
| 717 | 1113 | | #[test] |
| 718 | 1114 | | fn steps() { |
| 719 | 1115 | | let abc = ['a', 'b', 'c']; |
| @@ -727,10 +1123,20 @@ mod tests { |
| 727 | 1123 | | #[test] |
| 728 | 1124 | | fn arguments() { |
| 729 | 1125 | | let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned())); |
| 730 | | − | assert!(matches!(args(&[]), Ok(Args::Run { theme: None }))); |
| 731 | | − | assert!(matches!(args(&["--theme", "x.json"]), Ok(Args::Run { theme: Some(_) }))); |
| 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 | + | )); |
| 732 | 1137 | | assert!(matches!(args(&["--help"]), Ok(Args::Help))); |
| 733 | 1138 | | assert!(args(&["--theme"]).is_err()); |
| 1139 | + | assert!(args(&["--device", "nope"]).is_err()); |
| 734 | 1140 | | assert!(args(&["--bogus"]).is_err()); |
| 735 | 1141 | | } |
| 736 | 1142 | | |
| @@ -743,7 +1149,7 @@ mod tests { |
| 743 | 1149 | | let font = theme::system_font(); |
| 744 | 1150 | | let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from); |
| 745 | 1151 | | let on = |page: Page, keys: &str| { |
| 746 | | − | let mut app = App::new(palette.clone(), font); |
| 1152 | + | let mut app = App::new(palette.clone(), font, None, true); |
| 747 | 1153 | | app.page = page; |
| 748 | 1154 | | press(&mut app, keys); |
| 749 | 1155 | | app |
| @@ -755,6 +1161,24 @@ mod tests { |
| 755 | 1161 | | shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, ""))); |
| 756 | 1162 | | shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j"))); |
| 757 | 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)); |
| 758 | 1182 | | for (name, height, app) in shots { |
| 759 | 1183 | | let settings = iced::Settings { |
| 760 | 1184 | | default_font: font, |