| @@ -24,10 +24,10 @@ use std::time::{Duration, Instant}; |
| 24 | 24 | | |
| 25 | 25 | | use budctl_core::Kind; |
| 26 | 26 | | use budctl_core::bt::BdAddr; |
| 27 | | − | use budctl_core::status::{AncLevel, EqBand, NoiseControl, NoiseMode, Switch}; |
| 27 | + | use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, Switch}; |
| 28 | 28 | | use iced::keyboard::{self, Key, Modifiers}; |
| 29 | 29 | | use iced::widget::operation::{self, RelativeOffset}; |
| 30 | | − | use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text}; |
| 30 | + | use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text, text_input}; |
| 31 | 31 | | use iced::{Alignment, Border, Color, Element, Font, Length, Size, Subscription, Task, Theme, event, mouse, window}; |
| 32 | 32 | | |
| 33 | 33 | | use icons::Icon; |
| @@ -46,6 +46,8 @@ Press ? in the window for the keys."; |
| 46 | 46 | | |
| 47 | 47 | | /// The page's scrolling area, so the cursor can be kept in view. |
| 48 | 48 | | const SCROLL: &str = "page"; |
| 49 | + | /// The name field of the preset-name dialog, so it can take the keyboard. |
| 50 | + | const NAME_INPUT: &str = "name"; |
| 49 | 51 | | |
| 50 | 52 | | /// When the window gets focus and the last reading is older than this, the buds are read again: |
| 51 | 53 | | /// the phone may have changed something meanwhile. |
| @@ -192,6 +194,26 @@ enum Item { |
| 192 | 194 | | Find, |
| 193 | 195 | | /// The bass level. |
| 194 | 196 | | Bass, |
| 197 | + | /// "New preset", on the Equalizer page. |
| 198 | + | NewPreset, |
| 199 | + | /// "Rename", on a custom EQ preset's card. |
| 200 | + | Rename(u8), |
| 201 | + | } |
| 202 | + | |
| 203 | + | /// The dialog that asks for a preset's name. |
| 204 | + | #[derive(Debug, Clone, PartialEq)] |
| 205 | + | struct NamePrompt { |
| 206 | + | target: NameTarget, |
| 207 | + | text: String, |
| 208 | + | /// What is wrong with the name, after a try. |
| 209 | + | problem: Option<String>, |
| 210 | + | } |
| 211 | + | |
| 212 | + | #[derive(Debug, Clone, Copy, PartialEq)] |
| 213 | + | enum NameTarget { |
| 214 | + | New, |
| 215 | + | /// A custom preset, by its id. |
| 216 | + | Rename(u8), |
| 195 | 217 | | } |
| 196 | 218 | | |
| 197 | 219 | | /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the |
| @@ -268,6 +290,13 @@ enum Message { |
| 268 | 290 | | save: bool, |
| 269 | 291 | | }, |
| 270 | 292 | | SaveBass, |
| 293 | + | /// Ask for a new preset's name. |
| 294 | + | NewPreset, |
| 295 | + | /// Ask for a custom preset's (by its id) new name. |
| 296 | + | RenamePreset(u8), |
| 297 | + | NameInput(String), |
| 298 | + | SubmitName, |
| 299 | + | CancelName, |
| 271 | 300 | | ShowHelp, |
| 272 | 301 | | /// Read the buds again. |
| 273 | 302 | | Refresh, |
| @@ -317,6 +346,8 @@ struct App { |
| 317 | 346 | | confirm_find: bool, |
| 318 | 347 | | /// Why the palette file could not be used, if it could not. |
| 319 | 348 | | palette_problem: Option<String>, |
| 349 | + | /// The preset-name dialog, when it is open. |
| 350 | + | prompt: Option<NamePrompt>, |
| 320 | 351 | | } |
| 321 | 352 | | |
| 322 | 353 | | impl App { |
| @@ -354,6 +385,7 @@ impl App { |
| 354 | 385 | | ringing: false, |
| 355 | 386 | | confirm_find: false, |
| 356 | 387 | | palette_problem: None, |
| 388 | + | prompt: None, |
| 357 | 389 | | }; |
| 358 | 390 | | app.refresh_palette(); |
| 359 | 391 | | app |
| @@ -396,6 +428,19 @@ impl App { |
| 396 | 428 | | } |
| 397 | 429 | | } |
| 398 | 430 | | Message::SaveBass => self.save_bass(), |
| 431 | + | Message::NewPreset => return self.ask_name(NameTarget::New, String::new()), |
| 432 | + | Message::RenamePreset(id) => { |
| 433 | + | let name = self.buds.preset(id).map(|p| p.name.clone()).unwrap_or_default(); |
| 434 | + | return self.ask_name(NameTarget::Rename(id), name); |
| 435 | + | } |
| 436 | + | Message::NameInput(text) => { |
| 437 | + | if let Some(prompt) = &mut self.prompt { |
| 438 | + | prompt.text = text; |
| 439 | + | prompt.problem = None; |
| 440 | + | } |
| 441 | + | } |
| 442 | + | Message::SubmitName => self.submit_name(), |
| 443 | + | Message::CancelName => self.prompt = None, |
| 399 | 444 | | Message::Refresh => self.refresh(), |
| 400 | 445 | | Message::Worker(event) => self.answer(event), |
| 401 | 446 | | Message::Focused => { |
| @@ -455,6 +500,32 @@ impl App { |
| 455 | 500 | | self.buds.bass = Some(level); |
| 456 | 501 | | self.bass_draft = None; |
| 457 | 502 | | } |
| 503 | + | Command::CreateEq(name) => { |
| 504 | + | let customs = || { |
| 505 | + | self.buds |
| 506 | + | .eq |
| 507 | + | .iter() |
| 508 | + | .flat_map(|eq| eq.presets.iter()) |
| 509 | + | .filter(|p| p.custom) |
| 510 | + | }; |
| 511 | + | let model = customs().next().cloned(); |
| 512 | + | let id = customs().map(|p| p.id).max().map_or(4, |id| id.saturating_add(1)); |
| 513 | + | if let Some(model) = model { |
| 514 | + | self.buds.set_custom(EqPreset { |
| 515 | + | name, |
| 516 | + | id, |
| 517 | + | custom: true, |
| 518 | + | bands: model.bands.iter().map(|b| EqBand { hz: b.hz, gain: 0 }).collect(), |
| 519 | + | range: model.range, |
| 520 | + | }); |
| 521 | + | } |
| 522 | + | } |
| 523 | + | Command::RenameEq(_, name) => { |
| 524 | + | if let Some(preset) = self.buds.preset(item_preset(item)) { |
| 525 | + | let preset = EqPreset { name, ..preset.clone() }; |
| 526 | + | self.buds.set_custom(preset); |
| 527 | + | } |
| 528 | + | } |
| 458 | 529 | | Command::CustomEq(_, bands) => { |
| 459 | 530 | | if let Some(preset) = self.buds.preset(item_preset(item)) { |
| 460 | 531 | | let mut preset = preset.clone(); |
| @@ -563,6 +634,11 @@ impl App { |
| 563 | 634 | | self.save_touch(row); |
| 564 | 635 | | } |
| 565 | 636 | | } |
| 637 | + | Event::Presets(eq) => { |
| 638 | + | self.in_flight.pop_front(); |
| 639 | + | self.buds.set_presets(eq); |
| 640 | + | self.link = Link::Live; |
| 641 | + | } |
| 566 | 642 | | Event::Bass(level) => { |
| 567 | 643 | | self.in_flight.pop_front(); |
| 568 | 644 | | self.buds.bass = Some(level); |
| @@ -738,6 +814,59 @@ impl App { |
| 738 | 814 | | } |
| 739 | 815 | | } |
| 740 | 816 | | |
| 817 | + | /// Opens the preset-name dialog, with the keyboard in its field. |
| 818 | + | fn ask_name(&mut self, target: NameTarget, text: String) -> Task<Message> { |
| 819 | + | self.prompt = Some(NamePrompt { |
| 820 | + | target, |
| 821 | + | text, |
| 822 | + | problem: None, |
| 823 | + | }); |
| 824 | + | operation::focus(NAME_INPUT) |
| 825 | + | } |
| 826 | + | |
| 827 | + | /// Creates or renames the preset, if the name will do. What the buds themselves refuse (a |
| 828 | + | /// name too long for them, a fourth preset) comes back as an error. |
| 829 | + | fn submit_name(&mut self) { |
| 830 | + | let Some(prompt) = self.prompt.clone() else { |
| 831 | + | return; |
| 832 | + | }; |
| 833 | + | let renaming = match prompt.target { |
| 834 | + | NameTarget::Rename(id) => Some(id), |
| 835 | + | NameTarget::New => None, |
| 836 | + | }; |
| 837 | + | let taken = self |
| 838 | + | .buds |
| 839 | + | .custom_names() |
| 840 | + | .any(|(id, name)| name == prompt.text && Some(id) != renaming); |
| 841 | + | let problem = if prompt.text.is_empty() || prompt.text.trim() != prompt.text { |
| 842 | + | Some("A name cannot be empty, or start or end with a space.".to_owned()) |
| 843 | + | } else if prompt.text.chars().any(char::is_control) { |
| 844 | + | Some("A name cannot hold control characters.".to_owned()) |
| 845 | + | } else if taken { |
| 846 | + | Some(format!("A preset is called {} already.", prompt.text)) |
| 847 | + | } else { |
| 848 | + | None |
| 849 | + | }; |
| 850 | + | if let Some(problem) = problem { |
| 851 | + | self.prompt = Some(NamePrompt { |
| 852 | + | problem: Some(problem), |
| 853 | + | ..prompt |
| 854 | + | }); |
| 855 | + | return; |
| 856 | + | } |
| 857 | + | self.prompt = None; |
| 858 | + | match prompt.target { |
| 859 | + | NameTarget::New => self.write(Item::NewPreset, Command::CreateEq(prompt.text)), |
| 860 | + | NameTarget::Rename(id) => { |
| 861 | + | let unchanged = self.buds.preset(id).is_some_and(|p| p.name == prompt.text); |
| 862 | + | if let (false, Some(preset)) = (unchanged, self.buds.preset(id)) { |
| 863 | + | let command = Command::RenameEq(buds::key(preset), prompt.text); |
| 864 | + | self.write(Item::Preset(id), command); |
| 865 | + | } |
| 866 | + | } |
| 867 | + | } |
| 868 | + | } |
| 869 | + | |
| 741 | 870 | | /// Sets the bass level moved on the screen. If a level is on its way, this one goes out |
| 742 | 871 | | /// when that is answered: the latest level wins. |
| 743 | 872 | | fn save_bass(&mut self) { |
| @@ -764,6 +893,13 @@ impl App { |
| 764 | 893 | | } |
| 765 | 894 | | |
| 766 | 895 | | fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> { |
| 896 | + | if self.prompt.is_some() { |
| 897 | + | // The name field takes the keys, and Enter in it; only Esc is ours. |
| 898 | + | if matches!(key, Key::Named(keyboard::key::Named::Escape)) { |
| 899 | + | self.prompt = None; |
| 900 | + | } |
| 901 | + | return Task::none(); |
| 902 | + | } |
| 767 | 903 | | let Some(action) = self.keys.press(key, modifiers) else { |
| 768 | 904 | | return Task::none(); |
| 769 | 905 | | }; |
| @@ -920,6 +1056,8 @@ impl App { |
| 920 | 1056 | | }) |
| 921 | 1057 | | } |
| 922 | 1058 | | Item::Find => (action != Action::Left).then_some(Message::Find), |
| 1059 | + | Item::NewPreset => (action != Action::Left).then_some(Message::NewPreset), |
| 1060 | + | Item::Rename(id) => (action != Action::Left).then_some(Message::RenamePreset(id)), |
| 923 | 1061 | | Item::Bass => { |
| 924 | 1062 | | let (low, high) = self.buds.bass_range?; |
| 925 | 1063 | | let now = self.bass_draft.or(self.buds.bass); |
| @@ -1067,6 +1205,8 @@ impl App { |
| 1067 | 1205 | | stack![window, self.help()].into() |
| 1068 | 1206 | | } else if self.confirm_find { |
| 1069 | 1207 | | stack![window, self.confirm_find()].into() |
| 1208 | + | } else if let Some(prompt) = &self.prompt { |
| 1209 | + | stack![window, self.name_prompt(prompt)].into() |
| 1070 | 1210 | | } else { |
| 1071 | 1211 | | window.into() |
| 1072 | 1212 | | } |
| @@ -1217,6 +1357,58 @@ impl App { |
| 1217 | 1357 | | opaque(backdrop) |
| 1218 | 1358 | | } |
| 1219 | 1359 | | |
| 1360 | + | /// The dialog that asks for a preset's name, over a dimmed window. |
| 1361 | + | fn name_prompt<'a>(&'a self, prompt: &'a NamePrompt) -> Element<'a, Message> { |
| 1362 | + | let look = self.look; |
| 1363 | + | let p = look.p; |
| 1364 | + | let (heading, action) = match prompt.target { |
| 1365 | + | NameTarget::New => ("New preset".to_owned(), "Create"), |
| 1366 | + | NameTarget::Rename(id) => { |
| 1367 | + | let name = self.buds.preset(id).map_or("the preset", |preset| preset.name.as_str()); |
| 1368 | + | (format!("Rename {name}"), "Rename") |
| 1369 | + | } |
| 1370 | + | }; |
| 1371 | + | let field = text_input("Name", &prompt.text) |
| 1372 | + | .id(NAME_INPUT) |
| 1373 | + | .on_input(Message::NameInput) |
| 1374 | + | .on_submit(Message::SubmitName) |
| 1375 | + | .size(16) |
| 1376 | + | .padding([10, 14]) |
| 1377 | + | .style(move |_, _| text_input::Style { |
| 1378 | + | background: p.surface_high.into(), |
| 1379 | + | border: Border { |
| 1380 | + | color: p.accent, |
| 1381 | + | width: 1.0, |
| 1382 | + | radius: 10.0.into(), |
| 1383 | + | }, |
| 1384 | + | icon: p.dim, |
| 1385 | + | placeholder: p.dim, |
| 1386 | + | value: p.fg, |
| 1387 | + | selection: p.accent_dim, |
| 1388 | + | }); |
| 1389 | + | let problem = prompt |
| 1390 | + | .problem |
| 1391 | + | .as_deref() |
| 1392 | + | .map(|problem| text(problem).size(13).color(p.err)); |
| 1393 | + | let buttons = row![ |
| 1394 | + | space::horizontal(), |
| 1395 | + | ui::pill(look, "Cancel", false, Message::CancelName), |
| 1396 | + | ui::pill(look, action, true, Message::SubmitName), |
| 1397 | + | ] |
| 1398 | + | .spacing(10); |
| 1399 | + | let dialog = column![text(heading).size(22).font(look.bold()).color(p.fg), field] |
| 1400 | + | .push(problem) |
| 1401 | + | .push(buttons) |
| 1402 | + | .spacing(16); |
| 1403 | + | let backdrop = container(container(ui::panel(look, dialog.into())).width(460)) |
| 1404 | + | .center(Length::Fill) |
| 1405 | + | .style(|_| container::Style { |
| 1406 | + | background: Some(Color { a: 0.6, ..Color::BLACK }.into()), |
| 1407 | + | ..container::Style::default() |
| 1408 | + | }); |
| 1409 | + | opaque(backdrop) |
| 1410 | + | } |
| 1411 | + | |
| 1220 | 1412 | | /// The list of keys, over a dimmed window. A key or a click closes it. |
| 1221 | 1413 | | fn help(&self) -> Element<'_, Message> { |
| 1222 | 1414 | | let look = self.look; |
| @@ -1765,6 +1957,71 @@ mod tests { |
| 1765 | 1957 | | assert!(commands.try_recv().is_err()); |
| 1766 | 1958 | | } |
| 1767 | 1959 | | |
| 1960 | + | #[test] |
| 1961 | + | fn presets_are_created_and_renamed() { |
| 1962 | + | let mut app = app(); |
| 1963 | + | let _ = app.update(Message::NewPreset); |
| 1964 | + | assert!(app.prompt.is_some()); |
| 1965 | + | // Typing goes to the field, not to the cursor. |
| 1966 | + | press(&mut app, "j j"); |
| 1967 | + | assert_eq!(app.focus(), None); |
| 1968 | + | let _ = app.update(Message::NameInput(" Gym".to_owned())); |
| 1969 | + | let _ = app.update(Message::SubmitName); |
| 1970 | + | assert!(app.prompt.as_ref().is_some_and(|p| p.problem.is_some())); |
| 1971 | + | let _ = app.update(Message::NameInput("Custom1".to_owned())); |
| 1972 | + | let _ = app.update(Message::SubmitName); |
| 1973 | + | assert!(app.prompt.as_ref().is_some_and(|p| p.problem.is_some()), "taken"); |
| 1974 | + | let _ = app.update(Message::NameInput("Gym".to_owned())); |
| 1975 | + | let _ = app.update(Message::SubmitName); |
| 1976 | + | assert_eq!(app.prompt, None); |
| 1977 | + | assert_eq!(app.buds.eq_name(), "Gym", "a new preset is put to use"); |
| 1978 | + | let gym = app |
| 1979 | + | .buds |
| 1980 | + | .custom_names() |
| 1981 | + | .find(|(_, name)| *name == "Gym") |
| 1982 | + | .map(|(id, _)| id) |
| 1983 | + | .unwrap(); |
| 1984 | + | |
| 1985 | + | let _ = app.update(Message::RenamePreset(gym)); |
| 1986 | + | assert_eq!(app.prompt.as_ref().map(|p| p.text.as_str()), Some("Gym")); |
| 1987 | + | let _ = app.update(Message::NameInput("Run".to_owned())); |
| 1988 | + | let _ = app.update(Message::SubmitName); |
| 1989 | + | assert_eq!(app.buds.preset(gym).map(|p| p.name.as_str()), Some("Run")); |
| 1990 | + | |
| 1991 | + | let _ = app.update(Message::RenamePreset(gym)); |
| 1992 | + | press(&mut app, "Esc"); |
| 1993 | + | assert_eq!(app.prompt, None); |
| 1994 | + | } |
| 1995 | + | |
| 1996 | + | #[test] |
| 1997 | + | fn live_presets_are_created_and_renamed_by_the_buds() { |
| 1998 | + | let (mut app, commands) = live(); |
| 1999 | + | let _ = commands.try_recv(); |
| 2000 | + | let _ = app.update(Message::Worker(Event::Read(Box::new(reading())))); |
| 2001 | + | let _ = app.update(Message::NewPreset); |
| 2002 | + | let _ = app.update(Message::NameInput("Gym".to_owned())); |
| 2003 | + | let _ = app.update(Message::SubmitName); |
| 2004 | + | assert!(matches!(commands.try_recv(), Ok(Command::CreateEq(name)) if name == "Gym")); |
| 2005 | + | assert_eq!(app.pending(), [Item::NewPreset]); |
| 2006 | + | let mut presets = buds::demo().eq.unwrap(); |
| 2007 | + | let mut gym = presets.presets.last().unwrap().clone(); |
| 2008 | + | gym.id = 5; |
| 2009 | + | gym.name = "Gym".to_owned(); |
| 2010 | + | presets.presets.push(gym); |
| 2011 | + | presets.current = Some("custom-5".to_owned()); |
| 2012 | + | let _ = app.update(Message::Worker(Event::Presets(presets))); |
| 2013 | + | assert_eq!(app.buds.eq_name(), "Gym"); |
| 2014 | + | assert!(app.pending().is_empty()); |
| 2015 | + | |
| 2016 | + | let _ = app.update(Message::RenamePreset(4)); |
| 2017 | + | let _ = app.update(Message::NameInput("Run".to_owned())); |
| 2018 | + | let _ = app.update(Message::SubmitName); |
| 2019 | + | assert!(matches!( |
| 2020 | + | commands.try_recv(), |
| 2021 | + | Ok(Command::RenameEq(preset, name)) if preset == "custom-4" && name == "Run" |
| 2022 | + | )); |
| 2023 | + | } |
| 2024 | + | |
| 1768 | 2025 | | #[test] |
| 1769 | 2026 | | fn steps() { |
| 1770 | 2027 | | let abc = ['a', 'b', 'c']; |
| @@ -1824,6 +2081,11 @@ mod tests { |
| 1824 | 2081 | | 1340.0, |
| 1825 | 2082 | | on(Page::Overview, "j j j j j j l l"), |
| 1826 | 2083 | | )); |
| 2084 | + | let mut naming = on(Page::Equalizer, ""); |
| 2085 | + | let _ = naming.update(Message::RenamePreset(4)); |
| 2086 | + | let _ = naming.update(Message::NameInput("Custom1 ".to_owned())); |
| 2087 | + | let _ = naming.update(Message::SubmitName); |
| 2088 | + | shots.push(("name-prompt".to_owned(), 800.0, naming)); |
| 1827 | 2089 | | let mut connecting = App::new(palette.clone(), font, None, false); |
| 1828 | 2090 | | connecting.link = Link::Connecting; |
| 1829 | 2091 | | shots.push(("connecting".to_owned(), 800.0, connecting)); |