budctl904ab9a
budctl-gui: the Equalizer page
The page lists the presets, built-in and custom, with a check on the
one in use; a click (or l, Enter) selects one. Each custom preset gets
a card of band sliders within its range. A mouse drag moves a band on
the screen and saves the preset once, on release; h/l on a band save
each step. While a save is on its way, further moves wait and go out
together when it is answered, so the latest bands win. A failure drops
them and shows what the buds kept.
The worker gained Command::Eq (built-in presets, set_eq) and
Command::CustomEq (custom presets, set_custom_eq, which also selects).
demo.rs became buds.rs: the GUI's buds model, now with the presets.
Core: EqPreset carries a custom preset's gain range ("range" in JSON,
custom presets only).
No new dependencies.
+644 −114
10 files changed
- M crates/budctl-core/src/devices/realme_t310/codec.rs +3 −0
- M crates/budctl-core/src/status.rs +3 −0
- A crates/budctl-gui/src/buds.rs +190 −0
- D crates/budctl-gui/src/demo.rs +0 −56
- A crates/budctl-gui/src/equalizer.rs +192 −0
- M crates/budctl-gui/src/icons.rs +2 −0
- M crates/budctl-gui/src/main.rs +232 −45
- M crates/budctl-gui/src/overview.rs +3 −11
- M crates/budctl-gui/src/ui.rs +1 −1
- M crates/budctl-gui/src/worker.rs +18 −1
| @@ -482,6 +482,7 @@ pub(super) fn builtin_eqs() -> Vec<EqPreset> { | |||
| 482 | 482 | id, | |
| 483 | 483 | custom: false, | |
| 484 | 484 | bands: Vec::new(), | |
| 485 | + | range: None, | |
| 485 | 486 | }) | |
| 486 | 487 | .collect() | |
| 487 | 488 | } | |
| @@ -506,6 +507,7 @@ impl CustomEq { | |||
| 506 | 507 | id: self.id, | |
| 507 | 508 | custom: true, | |
| 508 | 509 | bands: self.bands.clone(), | |
| 510 | + | range: Some((self.min, self.max)), | |
| 509 | 511 | } | |
| 510 | 512 | } | |
| 511 | 513 | } | |
| @@ -1044,6 +1046,7 @@ mod tests { | |||
| 1044 | 1046 | id: 0x04, | |
| 1045 | 1047 | custom: true, | |
| 1046 | 1048 | bands: vec![EqBand { hz: 62, gain: 3 }, EqBand { hz: 16000, gain: -2 }], | |
| 1049 | + | range: Some((-6, 6)), | |
| 1047 | 1050 | }] | |
| 1048 | 1051 | ); | |
| 1049 | 1052 | // Cut short inside the bands. | |
| @@ -133,6 +133,9 @@ pub struct EqPreset { | |||
| 133 | 133 | pub custom: bool, | |
| 134 | 134 | /// Custom presets only: gain per band. Empty for built-in presets. | |
| 135 | 135 | pub bands: Vec<EqBand>, | |
| 136 | + | /// Custom presets only: the lowest and the highest gain a band takes, dB. | |
| 137 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 138 | + | pub range: Option<(i8, i8)>, | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | 141 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] | |
| @@ -0,0 +1,190 @@ | |||
| 1 | + | //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported | |
| 2 | + | //! at the last checkpoints), or an empty pair until the real buds answer. | |
| 3 | + | ||
| 4 | + | use budctl_core::status::{Battery, Charge, EqBand, EqPreset, EqPresets, NoiseControl, Status, Switch}; | |
| 5 | + | ||
| 6 | + | /// The buds the window shows. | |
| 7 | + | pub struct Buds { | |
| 8 | + | pub name: String, | |
| 9 | + | pub connected: bool, | |
| 10 | + | pub status: Status, | |
| 11 | + | /// The EQ presets, when the buds list them. | |
| 12 | + | pub eq: Option<EqPresets>, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | impl Buds { | |
| 16 | + | /// The name to show for the EQ preset in use. | |
| 17 | + | pub fn eq_name(&self) -> String { | |
| 18 | + | eq_name(self.status.eq.as_deref(), self.eq.as_ref()) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// Records that `now` is the EQ preset in use. | |
| 22 | + | pub fn set_eq(&mut self, now: String) { | |
| 23 | + | if let Some(eq) = &mut self.eq { | |
| 24 | + | eq.current = Some(now.clone()); | |
| 25 | + | } | |
| 26 | + | self.status.eq = Some(now); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Takes in a custom preset as the buds report it after a write: saved, and in use. | |
| 30 | + | pub fn set_custom(&mut self, preset: EqPreset) { | |
| 31 | + | let now = key(&preset); | |
| 32 | + | if let Some(shown) = self | |
| 33 | + | .eq | |
| 34 | + | .as_mut() | |
| 35 | + | .and_then(|eq| eq.presets.iter_mut().find(|p| p.custom && p.id == preset.id)) | |
| 36 | + | { | |
| 37 | + | *shown = preset; | |
| 38 | + | } | |
| 39 | + | self.set_eq(now); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// The preset with this id. | |
| 43 | + | pub fn preset(&self, id: u8) -> Option<&EqPreset> { | |
| 44 | + | self.eq.as_ref()?.presets.iter().find(|p| p.id == id) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// The made-up buds of `--demo`. | |
| 49 | + | pub fn demo() -> Buds { | |
| 50 | + | let charge = |level| Some(Charge { level, charging: false }); | |
| 51 | + | let switch = |key, label, on| Switch { key, label, on }; | |
| 52 | + | let builtin = |id, name: &str| EqPreset { | |
| 53 | + | name: name.to_owned(), | |
| 54 | + | id, | |
| 55 | + | custom: false, | |
| 56 | + | bands: Vec::new(), | |
| 57 | + | range: None, | |
| 58 | + | }; | |
| 59 | + | let band = |hz, gain| EqBand { hz, gain }; | |
| 60 | + | Buds { | |
| 61 | + | name: "realme Buds T310".to_owned(), | |
| 62 | + | connected: true, | |
| 63 | + | status: Status { | |
| 64 | + | battery: Battery { | |
| 65 | + | left: charge(80), | |
| 66 | + | right: charge(80), | |
| 67 | + | case: None, | |
| 68 | + | }, | |
| 69 | + | noise_control: Some(NoiseControl { | |
| 70 | + | mode: "anc".to_owned(), | |
| 71 | + | level: Some("deep".to_owned()), | |
| 72 | + | }), | |
| 73 | + | eq: Some("custom-4".to_owned()), | |
| 74 | + | bass_level: None, | |
| 75 | + | switches: vec![ | |
| 76 | + | switch("game", "Game mode", false), | |
| 77 | + | switch("dual", "Dual connection", true), | |
| 78 | + | switch("wind", "Wind-noise reduction", true), | |
| 79 | + | switch("spatial", "3D sound", true), | |
| 80 | + | switch("bass-boost", "Bass boost", true), | |
| 81 | + | ], | |
| 82 | + | codec: Some("SBC".to_owned()), | |
| 83 | + | firmware: Some("1.1.0.43".to_owned()), | |
| 84 | + | warnings: Vec::new(), | |
| 85 | + | }, | |
| 86 | + | eq: Some(EqPresets { | |
| 87 | + | current: Some("custom-4".to_owned()), | |
| 88 | + | presets: vec![ | |
| 89 | + | builtin(0, "pure-raw"), | |
| 90 | + | builtin(1, "deep-bass"), | |
| 91 | + | builtin(2, "pure-vocals"), | |
| 92 | + | builtin(3, "pure-bass"), | |
| 93 | + | EqPreset { | |
| 94 | + | name: "Custom1".to_owned(), | |
| 95 | + | id: 4, | |
| 96 | + | custom: true, | |
| 97 | + | bands: vec![ | |
| 98 | + | band(50, 3), | |
| 99 | + | band(250, 1), | |
| 100 | + | band(1000, 0), | |
| 101 | + | band(4000, -1), | |
| 102 | + | band(8000, 1), | |
| 103 | + | band(16000, 2), | |
| 104 | + | ], | |
| 105 | + | range: Some((-6, 6)), | |
| 106 | + | }, | |
| 107 | + | ], | |
| 108 | + | warnings: Vec::new(), | |
| 109 | + | }), | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// What the window shows before the real buds answer. | |
| 114 | + | pub fn unknown() -> Buds { | |
| 115 | + | Buds { | |
| 116 | + | name: "Your buds".to_owned(), | |
| 117 | + | connected: false, | |
| 118 | + | status: Status::default(), | |
| 119 | + | eq: None, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// The name a preset is selected by, as in [`Status::eq`]: a built-in one's own name, | |
| 124 | + | /// `custom-<id>` for a custom one. | |
| 125 | + | pub fn key(preset: &EqPreset) -> String { | |
| 126 | + | if preset.custom { | |
| 127 | + | format!("custom-{}", preset.id) | |
| 128 | + | } else { | |
| 129 | + | preset.name.clone() | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// A preset's name to show: a custom one's stored name, a built-in one's in words. | |
| 134 | + | pub fn shown_name(preset: &EqPreset) -> String { | |
| 135 | + | if preset.custom { | |
| 136 | + | preset.name.clone() | |
| 137 | + | } else { | |
| 138 | + | words(&preset.name) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// The name to show for the EQ preset in use: a custom preset's own name (`custom-4` → | |
| 143 | + | /// "Custom1"), else the preset's name in words (`pure-bass` → "Pure bass"). | |
| 144 | + | pub fn eq_name(current: Option<&str>, presets: Option<&EqPresets>) -> String { | |
| 145 | + | let Some(current) = current else { | |
| 146 | + | return "Unknown".to_owned(); | |
| 147 | + | }; | |
| 148 | + | presets | |
| 149 | + | .and_then(|eq| eq.presets.iter().find(|p| key(p) == current)) | |
| 150 | + | .map_or_else(|| words(current), shown_name) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// `pure-bass` → "Pure bass". | |
| 154 | + | fn words(name: &str) -> String { | |
| 155 | + | let words = name.replace('-', " "); | |
| 156 | + | let mut letters = words.chars(); | |
| 157 | + | letters | |
| 158 | + | .next() | |
| 159 | + | .map(|first| first.to_uppercase().chain(letters).collect()) | |
| 160 | + | .unwrap_or_default() | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[cfg(test)] | |
| 164 | + | mod tests { | |
| 165 | + | use super::*; | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn eq_names() { | |
| 169 | + | let buds = demo(); | |
| 170 | + | assert_eq!(eq_name(Some("custom-4"), buds.eq.as_ref()), "Custom1"); | |
| 171 | + | assert_eq!(eq_name(Some("custom-4"), None), "Custom 4"); | |
| 172 | + | assert_eq!(eq_name(Some("pure-bass"), buds.eq.as_ref()), "Pure bass"); | |
| 173 | + | assert_eq!(eq_name(None, None), "Unknown"); | |
| 174 | + | assert_eq!(buds.eq_name(), "Custom1"); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 178 | + | fn eq_changes_show() { | |
| 179 | + | let mut buds = demo(); | |
| 180 | + | buds.set_eq("deep-bass".to_owned()); | |
| 181 | + | assert_eq!(buds.eq_name(), "Deep bass"); | |
| 182 | + | assert_eq!(buds.eq.as_ref().unwrap().current.as_deref(), Some("deep-bass")); | |
| 183 | + | ||
| 184 | + | let mut custom = buds.preset(4).unwrap().clone(); | |
| 185 | + | custom.bands[0].gain = -6; | |
| 186 | + | buds.set_custom(custom); | |
| 187 | + | assert_eq!(buds.status.eq.as_deref(), Some("custom-4")); | |
| 188 | + | assert_eq!(buds.preset(4).unwrap().bands[0].gain, -6); | |
| 189 | + | } | |
| 190 | + | } | |
| @@ -1,56 +0,0 @@ | |||
| 1 | − | //! The buds the window shows: made-up ones for `--demo` (roughly what the owner's T310 reported | |
| 2 | − | //! at the last checkpoint), or an empty pair until the real buds answer. | |
| 3 | − | ||
| 4 | − | use budctl_core::status::{Battery, Charge, NoiseControl, Status, Switch}; | |
| 5 | − | ||
| 6 | − | /// The buds the window shows. | |
| 7 | − | pub struct Buds { | |
| 8 | − | pub name: String, | |
| 9 | − | pub connected: bool, | |
| 10 | − | pub status: Status, | |
| 11 | − | /// The name to show for the EQ preset in use. | |
| 12 | − | pub eq_name: String, | |
| 13 | − | } | |
| 14 | − | ||
| 15 | − | pub fn buds() -> Buds { | |
| 16 | − | let charge = |level| Some(Charge { level, charging: false }); | |
| 17 | − | let switch = |key, label, on| Switch { key, label, on }; | |
| 18 | − | Buds { | |
| 19 | − | name: "realme Buds T310".to_owned(), | |
| 20 | − | connected: true, | |
| 21 | − | status: Status { | |
| 22 | − | battery: Battery { | |
| 23 | − | left: charge(80), | |
| 24 | − | right: charge(80), | |
| 25 | − | case: None, | |
| 26 | − | }, | |
| 27 | − | noise_control: Some(NoiseControl { | |
| 28 | − | mode: "anc".to_owned(), | |
| 29 | − | level: Some("deep".to_owned()), | |
| 30 | − | }), | |
| 31 | − | eq: Some("custom-4".to_owned()), | |
| 32 | − | bass_level: None, | |
| 33 | − | switches: vec![ | |
| 34 | − | switch("game", "Game mode", false), | |
| 35 | − | switch("dual", "Dual connection", true), | |
| 36 | − | switch("wind", "Wind-noise reduction", true), | |
| 37 | − | switch("spatial", "3D sound", true), | |
| 38 | − | switch("bass-boost", "Bass boost", true), | |
| 39 | − | ], | |
| 40 | − | codec: Some("SBC".to_owned()), | |
| 41 | − | firmware: Some("1.1.0.43".to_owned()), | |
| 42 | − | warnings: Vec::new(), | |
| 43 | − | }, | |
| 44 | − | eq_name: "Custom1".to_owned(), | |
| 45 | − | } | |
| 46 | − | } | |
| 47 | − | ||
| 48 | − | /// What the window shows before the real buds answer. | |
| 49 | − | pub fn unknown() -> Buds { | |
| 50 | − | Buds { | |
| 51 | − | name: "Your buds".to_owned(), | |
| 52 | − | connected: false, | |
| 53 | − | status: Status::default(), | |
| 54 | − | eq_name: String::new(), | |
| 55 | − | } | |
| 56 | − | } | |
| @@ -0,0 +1,192 @@ | |||
| 1 | + | //! The Equalizer page: the presets, and the bands of the custom ones. | |
| 2 | + | ||
| 3 | + | use budctl_core::status::{EqBand, EqPreset}; | |
| 4 | + | use iced::widget::{Row, column, container, row, slider, space, text, vertical_slider}; | |
| 5 | + | use iced::{Alignment, Border, Element, Length}; | |
| 6 | + | ||
| 7 | + | use crate::buds::{self, Buds}; | |
| 8 | + | use crate::icons::{self, Icon}; | |
| 9 | + | use crate::theme::Look; | |
| 10 | + | use crate::ui; | |
| 11 | + | use crate::{Draft, Item, Marks, Message}; | |
| 12 | + | ||
| 13 | + | /// Shown instead of a subtitle while a write is on its way. | |
| 14 | + | const SAVING: &str = "Saving…"; | |
| 15 | + | ||
| 16 | + | /// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them: each preset, | |
| 17 | + | /// then each band of each custom preset. | |
| 18 | + | pub fn items(buds: &Buds) -> Vec<Item> { | |
| 19 | + | let Some(eq) = &buds.eq else { | |
| 20 | + | return Vec::new(); | |
| 21 | + | }; | |
| 22 | + | let mut items: Vec<Item> = eq.presets.iter().map(|p| Item::Preset(p.id)).collect(); | |
| 23 | + | for preset in eq.presets.iter().filter(|p| p.custom) { | |
| 24 | + | items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz))); | |
| 25 | + | } | |
| 26 | + | items | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// `draft`: bands moved on the screen and not saved on the buds yet. | |
| 30 | + | pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> { | |
| 31 | + | let title = ui::title(look, "Equalizer"); | |
| 32 | + | let Some(eq) = &buds.eq else { | |
| 33 | + | let none = text("These buds did not list their presets.") | |
| 34 | + | .size(14) | |
| 35 | + | .color(look.p.dim); | |
| 36 | + | return column![title, none].spacing(12).into(); | |
| 37 | + | }; | |
| 38 | + | let current = eq.current.as_deref(); | |
| 39 | + | let rows = eq | |
| 40 | + | .presets | |
| 41 | + | .iter() | |
| 42 | + | .map(|preset| { | |
| 43 | + | let chosen = current == Some(buds::key(preset).as_str()); | |
| 44 | + | let subtitle = if marks.pending.contains(&Item::Preset(preset.id)) { | |
| 45 | + | SAVING | |
| 46 | + | } else if preset.custom { | |
| 47 | + | "Your preset" | |
| 48 | + | } else { | |
| 49 | + | "Built in" | |
| 50 | + | }; | |
| 51 | + | let mark: Element<'a, Message> = if chosen { | |
| 52 | + | icons::icon(Icon::Check, 20.0, look.p.icon) | |
| 53 | + | } else { | |
| 54 | + | space().width(20).into() | |
| 55 | + | }; | |
| 56 | + | ui::setting( | |
| 57 | + | look, | |
| 58 | + | Icon::Equalizer, | |
| 59 | + | buds::shown_name(preset), | |
| 60 | + | Some(subtitle.to_owned()), | |
| 61 | + | mark, | |
| 62 | + | Message::Preset(preset.id), | |
| 63 | + | marks.focus == Some(Item::Preset(preset.id)), | |
| 64 | + | ) | |
| 65 | + | }) | |
| 66 | + | .collect(); | |
| 67 | + | eq.presets | |
| 68 | + | .iter() | |
| 69 | + | .filter(|p| p.custom) | |
| 70 | + | .fold(column![title, ui::card(look, rows)].spacing(18), |page, preset| { | |
| 71 | + | page.push(bands(look, preset, draft, marks)) | |
| 72 | + | }) | |
| 73 | + | .into() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// A custom preset's bands, one slider each. | |
| 77 | + | fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> { | |
| 78 | + | let p = look.p; | |
| 79 | + | let id = preset.id; | |
| 80 | + | let gains: &[EqBand] = match draft { | |
| 81 | + | Some(draft) if draft.preset == id => &draft.bands, | |
| 82 | + | _ => &preset.bands, | |
| 83 | + | }; | |
| 84 | + | let (low, high) = preset.range.unwrap_or((-6, 6)); | |
| 85 | + | let saving = marks | |
| 86 | + | .pending | |
| 87 | + | .contains(&Item::Preset(id)) | |
| 88 | + | .then(|| text(SAVING).size(13).color(p.icon)); | |
| 89 | + | let heading = row![ | |
| 90 | + | text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg), | |
| 91 | + | text(format!("{low:+} to {high:+} dB")) | |
| 92 | + | .size(13) | |
| 93 | + | .color(p.dim) | |
| 94 | + | .width(Length::Fill) | |
| 95 | + | ] | |
| 96 | + | .push(saving) | |
| 97 | + | .spacing(10) | |
| 98 | + | .align_y(Alignment::Center); | |
| 99 | + | let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| { | |
| 100 | + | let focused = marks.focus == Some(Item::Band(id, band.hz)); | |
| 101 | + | let hz = band.hz; | |
| 102 | + | let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| { | |
| 103 | + | Message::Band { | |
| 104 | + | preset: id, | |
| 105 | + | hz, | |
| 106 | + | gain: i8::try_from(gain).unwrap_or(0), | |
| 107 | + | save: false, | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | .on_release(Message::SaveBands(id)) | |
| 111 | + | .height(170) | |
| 112 | + | .style(move |_, _| slider::Style { | |
| 113 | + | rail: slider::Rail { | |
| 114 | + | backgrounds: (p.accent.into(), p.surface_highest.into()), | |
| 115 | + | width: 6.0, | |
| 116 | + | border: Border::default().rounded(3), | |
| 117 | + | }, | |
| 118 | + | handle: slider::Handle { | |
| 119 | + | shape: slider::HandleShape::Circle { radius: 9.0 }, | |
| 120 | + | background: p.fg.into(), | |
| 121 | + | border_width: 2.0, | |
| 122 | + | border_color: p.accent, | |
| 123 | + | }, | |
| 124 | + | }); | |
| 125 | + | let band = column![ | |
| 126 | + | text(gain(band.gain)).size(14).font(look.medium()).color(p.fg), | |
| 127 | + | slider, | |
| 128 | + | text(frequency(hz)).size(12).color(p.dim) | |
| 129 | + | ] | |
| 130 | + | .spacing(10) | |
| 131 | + | .align_x(Alignment::Center); | |
| 132 | + | sliders.push( | |
| 133 | + | container(band) | |
| 134 | + | .center_x(Length::Fill) | |
| 135 | + | .padding([12, 0]) | |
| 136 | + | .style(move |_| container::Style { | |
| 137 | + | background: focused.then_some(p.surface_high.into()), | |
| 138 | + | border: Border { | |
| 139 | + | color: p.accent, | |
| 140 | + | width: if focused { 2.0 } else { 0.0 }, | |
| 141 | + | radius: 12.0.into(), | |
| 142 | + | }, | |
| 143 | + | ..container::Style::default() | |
| 144 | + | }), | |
| 145 | + | ) | |
| 146 | + | }); | |
| 147 | + | ui::panel(look, column![heading, sliders].spacing(16).into()) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// `3` → "+3", `0` → "0", `-2` → "-2". | |
| 151 | + | fn gain(db: i8) -> String { | |
| 152 | + | if db == 0 { "0".to_owned() } else { format!("{db:+}") } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// `50` → "50 Hz", `16000` → "16 kHz". | |
| 156 | + | fn frequency(hz: u16) -> String { | |
| 157 | + | if hz >= 1000 && hz.is_multiple_of(1000) { | |
| 158 | + | format!("{} kHz", hz / 1000) | |
| 159 | + | } else { | |
| 160 | + | format!("{hz} Hz") | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | #[cfg(test)] | |
| 165 | + | mod tests { | |
| 166 | + | use super::*; | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn items_follow_the_page() { | |
| 170 | + | let items = items(&buds::demo()); | |
| 171 | + | assert_eq!(items.len(), 5 + 6); | |
| 172 | + | assert_eq!(items.first(), Some(&Item::Preset(0))); | |
| 173 | + | assert_eq!(items.get(5), Some(&Item::Band(4, 50))); | |
| 174 | + | assert_eq!(items.last(), Some(&Item::Band(4, 16000))); | |
| 175 | + | assert!(super::items(&buds::unknown()).is_empty()); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[test] | |
| 179 | + | fn gains() { | |
| 180 | + | assert_eq!(gain(3), "+3"); | |
| 181 | + | assert_eq!(gain(0), "0"); | |
| 182 | + | assert_eq!(gain(-6), "-6"); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn frequencies() { | |
| 187 | + | assert_eq!(frequency(50), "50 Hz"); | |
| 188 | + | assert_eq!(frequency(1000), "1 kHz"); | |
| 189 | + | assert_eq!(frequency(16000), "16 kHz"); | |
| 190 | + | assert_eq!(frequency(62), "62 Hz"); | |
| 191 | + | } | |
| 192 | + | } | |
| @@ -23,6 +23,7 @@ pub enum Icon { | |||
| 23 | 23 | Dual, | |
| 24 | 24 | Chevron, | |
| 25 | 25 | Bolt, | |
| 26 | + | Check, | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | /// `icon` at `size` pixels, in `color`. | |
| @@ -239,6 +240,7 @@ fn draw_icon(pen: &mut Pen, icon: Icon) { | |||
| 239 | 240 | pen.rect(17.5, 8.0, 4.5, 11.0, 1.2); | |
| 240 | 241 | } | |
| 241 | 242 | Icon::Chevron => pen.polyline(&[(9.0, 5.5), (15.5, 12.0), (9.0, 18.5)], false), | |
| 243 | + | Icon::Check => pen.polyline(&[(4.5, 12.5), (9.5, 17.5), (19.5, 7.0)], false), | |
| 242 | 244 | } | |
| 243 | 245 | } | |
| 244 | 246 | ||
| @@ -4,7 +4,8 @@ | |||
| 4 | 4 | //! own, and the window shows what they report. The mouse comes first; vim-like keys work on top | |
| 5 | 5 | //! (`keys.rs`). `--demo` shows made-up buds instead. | |
| 6 | 6 | ||
| 7 | − | mod demo; | |
| 7 | + | mod buds; | |
| 8 | + | mod equalizer; | |
| 8 | 9 | mod icons; | |
| 9 | 10 | mod keys; | |
| 10 | 11 | mod overview; | |
| @@ -20,7 +21,7 @@ use std::time::{Duration, Instant}; | |||
| 20 | 21 | ||
| 21 | 22 | use budctl_core::Kind; | |
| 22 | 23 | use budctl_core::bt::BdAddr; | |
| 23 | − | use budctl_core::status::{AncLevel, EqPresets, NoiseControl, NoiseMode, Switch}; | |
| 24 | + | use budctl_core::status::{AncLevel, EqBand, NoiseControl, NoiseMode, Switch}; | |
| 24 | 25 | use iced::keyboard::{self, Key, Modifiers}; | |
| 25 | 26 | use iced::widget::operation::{self, RelativeOffset}; | |
| 26 | 27 | use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text}; | |
| @@ -176,6 +177,26 @@ enum Item { | |||
| 176 | 177 | Switch(&'static str), | |
| 177 | 178 | /// A row that opens a page. | |
| 178 | 179 | Open(Page), | |
| 180 | + | /// An EQ preset, by its id. | |
| 181 | + | Preset(u8), | |
| 182 | + | /// One band of a custom EQ preset: the preset's id and the band's frequency. | |
| 183 | + | Band(u8, u16), | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// What a page needs besides the buds: the item under the keyboard cursor, if it shows, and the | |
| 187 | + | /// items with a write on its way. | |
| 188 | + | #[derive(Clone, Copy)] | |
| 189 | + | struct Marks<'a> { | |
| 190 | + | focus: Option<Item>, | |
| 191 | + | pending: &'a [Item], | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// Bands of a custom EQ preset moved on the screen and not saved on the buds yet. | |
| 195 | + | #[derive(Debug, Clone, PartialEq)] | |
| 196 | + | struct Draft { | |
| 197 | + | preset: u8, | |
| 198 | + | /// All the preset's bands, as shown. | |
| 199 | + | bands: Vec<EqBand>, | |
| 179 | 200 | } | |
| 180 | 201 | ||
| 181 | 202 | /// Where the connection to the buds stands. | |
| @@ -197,6 +218,18 @@ enum Message { | |||
| 197 | 218 | Noise(NoiseMode), | |
| 198 | 219 | /// A switch's key, and whether to turn it on. | |
| 199 | 220 | Switch(&'static str, bool), | |
| 221 | + | /// Select an EQ preset, by its id. | |
| 222 | + | Preset(u8), | |
| 223 | + | /// Move a band of a custom EQ preset. With `save`, save the preset right away, else when | |
| 224 | + | /// [`Message::SaveBands`] comes (the mouse lets go of the slider). | |
| 225 | + | Band { | |
| 226 | + | preset: u8, | |
| 227 | + | hz: u16, | |
| 228 | + | gain: i8, | |
| 229 | + | save: bool, | |
| 230 | + | }, | |
| 231 | + | /// Save the moved bands of a custom EQ preset. | |
| 232 | + | SaveBands(u8), | |
| 200 | 233 | /// Read the buds again. | |
| 201 | 234 | Refresh, | |
| 202 | 235 | Worker(Event), | |
| @@ -210,7 +243,7 @@ enum Message { | |||
| 210 | 243 | ||
| 211 | 244 | struct App { | |
| 212 | 245 | page: Page, | |
| 213 | − | buds: demo::Buds, | |
| 246 | + | buds: buds::Buds, | |
| 214 | 247 | /// The level "Noise cancelling" turns on at: the one last reported or picked. | |
| 215 | 248 | anc_level: AncLevel, | |
| 216 | 249 | look: Look, | |
| @@ -233,11 +266,13 @@ struct App { | |||
| 233 | 266 | in_flight: VecDeque<Option<Item>>, | |
| 234 | 267 | /// When the buds were last read. | |
| 235 | 268 | read_at: Option<Instant>, | |
| 269 | + | /// Bands moved and not saved yet. | |
| 270 | + | draft: Option<Draft>, | |
| 236 | 271 | } | |
| 237 | 272 | ||
| 238 | 273 | impl App { | |
| 239 | 274 | fn new(theme_file: Option<PathBuf>, font: Font, device: Option<BdAddr>, demo: bool) -> Self { | |
| 240 | − | let buds = if demo { demo::buds() } else { demo::unknown() }; | |
| 275 | + | let buds = if demo { buds::demo() } else { buds::unknown() }; | |
| 241 | 276 | let anc_level = buds | |
| 242 | 277 | .status | |
| 243 | 278 | .noise_control | |
| @@ -264,6 +299,7 @@ impl App { | |||
| 264 | 299 | worker: None, | |
| 265 | 300 | in_flight: VecDeque::new(), | |
| 266 | 301 | read_at: None, | |
| 302 | + | draft: None, | |
| 267 | 303 | }; | |
| 268 | 304 | app.refresh_palette(); | |
| 269 | 305 | app | |
| @@ -274,6 +310,14 @@ impl App { | |||
| 274 | 310 | Message::Open(page) => return self.open(page), | |
| 275 | 311 | Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)), | |
| 276 | 312 | Message::Switch(key, on) => self.write(Item::Switch(key), Command::Switch(key, on)), | |
| 313 | + | Message::Preset(id) => self.select(id), | |
| 314 | + | Message::Band { preset, hz, gain, save } => { | |
| 315 | + | self.move_band(preset, hz, gain); | |
| 316 | + | if save { | |
| 317 | + | self.save_bands(preset); | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | Message::SaveBands(preset) => self.save_bands(preset), | |
| 277 | 321 | Message::Refresh => self.refresh(), | |
| 278 | 322 | Message::Worker(event) => self.answer(event), | |
| 279 | 323 | Message::Focused => { | |
| @@ -307,6 +351,19 @@ impl App { | |||
| 307 | 351 | self.apply_switch(switch); | |
| 308 | 352 | } | |
| 309 | 353 | } | |
| 354 | + | Command::Eq(preset) => self.buds.set_eq(preset), | |
| 355 | + | Command::CustomEq(_, bands) => { | |
| 356 | + | if let Some(preset) = self.buds.preset(item_preset(item)) { | |
| 357 | + | let mut preset = preset.clone(); | |
| 358 | + | for band in &bands { | |
| 359 | + | if let Some(shown) = preset.bands.iter_mut().find(|b| b.hz == band.hz) { | |
| 360 | + | shown.gain = band.gain; | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | self.buds.set_custom(preset); | |
| 364 | + | } | |
| 365 | + | self.draft = None; | |
| 366 | + | } | |
| 310 | 367 | Command::Read => {} | |
| 311 | 368 | } | |
| 312 | 369 | } else if !self.pending().contains(&item) { | |
| @@ -317,7 +374,10 @@ impl App { | |||
| 317 | 374 | /// Reads the buds again; with the made-up buds, starts them over. | |
| 318 | 375 | fn refresh(&mut self) { | |
| 319 | 376 | match self.link { | |
| 320 | − | Link::Demo => self.buds = demo::buds(), | |
| 377 | + | Link::Demo => { | |
| 378 | + | self.buds = buds::demo(); | |
| 379 | + | self.draft = None; | |
| 380 | + | } | |
| 321 | 381 | _ if self.in_flight.contains(&None) => {} | |
| 322 | 382 | _ => { | |
| 323 | 383 | if self.read_at.is_none() { | |
| @@ -366,8 +426,35 @@ impl App { | |||
| 366 | 426 | self.apply_switch(switch); | |
| 367 | 427 | self.link = Link::Live; | |
| 368 | 428 | } | |
| 369 | − | Event::Failed { kind, message } => { | |
| 429 | + | Event::Eq(now) => { | |
| 370 | 430 | self.in_flight.pop_front(); | |
| 431 | + | self.buds.set_eq(now); | |
| 432 | + | self.link = Link::Live; | |
| 433 | + | } | |
| 434 | + | Event::CustomEq(preset) => { | |
| 435 | + | self.in_flight.pop_front(); | |
| 436 | + | let id = preset.id; | |
| 437 | + | self.buds.set_custom(preset); | |
| 438 | + | self.link = Link::Live; | |
| 439 | + | // Bands moved while this write was on its way go out now. | |
| 440 | + | match &self.draft { | |
| 441 | + | Some(draft) if draft.preset == id => { | |
| 442 | + | if self.buds.preset(id).is_some_and(|saved| saved.bands == draft.bands) { | |
| 443 | + | self.draft = None; | |
| 444 | + | } else { | |
| 445 | + | self.save_bands(id); | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | _ => {} | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | Event::Failed { kind, message } => { | |
| 452 | + | if let Some(Some(Item::Preset(id))) = self.in_flight.pop_front() { | |
| 453 | + | // The buds keep what they had: show that, not the moved bands. | |
| 454 | + | if self.draft.as_ref().is_some_and(|draft| draft.preset == id) { | |
| 455 | + | self.draft = None; | |
| 456 | + | } | |
| 457 | + | } | |
| 371 | 458 | if kind == Kind::NotFound { | |
| 372 | 459 | self.buds.connected = false; | |
| 373 | 460 | } | |
| @@ -377,7 +464,7 @@ impl App { | |||
| 377 | 464 | } | |
| 378 | 465 | ||
| 379 | 466 | fn apply_reading(&mut self, reading: Reading) { | |
| 380 | − | self.buds.eq_name = eq_name(reading.status.eq.as_deref(), reading.eq.as_ref()); | |
| 467 | + | self.buds.eq = reading.eq; | |
| 381 | 468 | if let Some(level) = reading | |
| 382 | 469 | .status | |
| 383 | 470 | .noise_control | |
| @@ -406,6 +493,49 @@ impl App { | |||
| 406 | 493 | } | |
| 407 | 494 | } | |
| 408 | 495 | ||
| 496 | + | /// Selects an EQ preset: a built-in one by name, a custom one by saving it unchanged. | |
| 497 | + | fn select(&mut self, id: u8) { | |
| 498 | + | let Some(preset) = self.buds.preset(id) else { | |
| 499 | + | return; | |
| 500 | + | }; | |
| 501 | + | let command = if preset.custom { | |
| 502 | + | Command::CustomEq(buds::key(preset), Vec::new()) | |
| 503 | + | } else { | |
| 504 | + | Command::Eq(preset.name.clone()) | |
| 505 | + | }; | |
| 506 | + | self.write(Item::Preset(id), command); | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | /// Moves a band on the screen, within the preset's range. Nothing goes to the buds yet. | |
| 510 | + | fn move_band(&mut self, id: u8, hz: u16, gain: i8) { | |
| 511 | + | let Some(preset) = self.buds.preset(id) else { | |
| 512 | + | return; | |
| 513 | + | }; | |
| 514 | + | let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX)); | |
| 515 | + | let mut bands = match self.draft.take() { | |
| 516 | + | Some(draft) if draft.preset == id => draft.bands, | |
| 517 | + | _ => preset.bands.clone(), | |
| 518 | + | }; | |
| 519 | + | if let Some(band) = bands.iter_mut().find(|band| band.hz == hz) { | |
| 520 | + | band.gain = gain.clamp(low, high); | |
| 521 | + | } | |
| 522 | + | self.draft = Some(Draft { preset: id, bands }); | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | /// Saves the moved bands of a custom preset. If a write to it is on its way, they go out | |
| 526 | + | /// when it is answered: the latest bands win. | |
| 527 | + | fn save_bands(&mut self, id: u8) { | |
| 528 | + | let Some(draft) = self.draft.clone().filter(|draft| draft.preset == id) else { | |
| 529 | + | return; | |
| 530 | + | }; | |
| 531 | + | let Some(preset) = self.buds.preset(id) else { | |
| 532 | + | return; | |
| 533 | + | }; | |
| 534 | + | if !self.pending().contains(&Item::Preset(id)) { | |
| 535 | + | self.write(Item::Preset(id), Command::CustomEq(buds::key(preset), draft.bands)); | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | ||
| 409 | 539 | /// The items with a write on its way. | |
| 410 | 540 | fn pending(&self) -> Vec<Item> { | |
| 411 | 541 | self.in_flight.iter().flatten().copied().collect() | |
| @@ -461,6 +591,7 @@ impl App { | |||
| 461 | 591 | fn items(&self) -> Vec<Item> { | |
| 462 | 592 | match self.page { | |
| 463 | 593 | Page::Overview if self.has_reading() => overview::items(&self.buds), | |
| 594 | + | Page::Equalizer if self.has_reading() => equalizer::items(&self.buds), | |
| 464 | 595 | _ => Vec::new(), | |
| 465 | 596 | } | |
| 466 | 597 | } | |
| @@ -528,6 +659,32 @@ impl App { | |||
| 528 | 659 | (want != on).then_some(Message::Switch(key, want)) | |
| 529 | 660 | } | |
| 530 | 661 | Item::Open(page) => (action != Action::Left).then_some(Message::Open(page)), | |
| 662 | + | Item::Preset(id) => { | |
| 663 | + | let chosen = self.buds.status.eq.as_deref() == self.buds.preset(id).map(buds::key).as_deref(); | |
| 664 | + | (action != Action::Left && !chosen).then_some(Message::Preset(id)) | |
| 665 | + | } | |
| 666 | + | Item::Band(id, hz) => { | |
| 667 | + | let preset = self.buds.preset(id)?; | |
| 668 | + | let gain = match &self.draft { | |
| 669 | + | Some(draft) if draft.preset == id => &draft.bands, | |
| 670 | + | _ => &preset.bands, | |
| 671 | + | } | |
| 672 | + | .iter() | |
| 673 | + | .find(|band| band.hz == hz)? | |
| 674 | + | .gain; | |
| 675 | + | let (low, high) = preset.range.unwrap_or((i8::MIN, i8::MAX)); | |
| 676 | + | let to = match action { | |
| 677 | + | Action::Left => gain.checked_sub(1)?.max(low), | |
| 678 | + | Action::Right => gain.checked_add(1)?.min(high), | |
| 679 | + | _ => return None, | |
| 680 | + | }; | |
| 681 | + | (to != gain).then_some(Message::Band { | |
| 682 | + | preset: id, | |
| 683 | + | hz, | |
| 684 | + | gain: to, | |
| 685 | + | save: true, | |
| 686 | + | }) | |
| 687 | + | } | |
| 531 | 688 | } | |
| 532 | 689 | } | |
| 533 | 690 | ||
| @@ -580,13 +737,20 @@ impl App { | |||
| 580 | 737 | let pending = self.pending(); | |
| 581 | 738 | let page = match self.page { | |
| 582 | 739 | Page::Overview if self.has_reading() => { | |
| 583 | − | let marks = overview::Marks { | |
| 740 | + | let marks = Marks { | |
| 584 | 741 | focus: self.focus(), | |
| 585 | 742 | pending: &pending, | |
| 586 | 743 | }; | |
| 587 | 744 | overview::view(look, &self.buds, self.anc_level, marks) | |
| 588 | 745 | } | |
| 589 | 746 | Page::Overview => self.connecting(), | |
| 747 | + | Page::Equalizer if self.has_reading() => { | |
| 748 | + | let marks = Marks { | |
| 749 | + | focus: self.focus(), | |
| 750 | + | pending: &pending, | |
| 751 | + | }; | |
| 752 | + | equalizer::view(look, &self.buds, self.draft.as_ref(), marks) | |
| 753 | + | } | |
| 590 | 754 | page => column![ | |
| 591 | 755 | ui::title(look, page.title()), | |
| 592 | 756 | text("This page comes in a later step.").size(14).color(look.p.dim) | |
| @@ -780,31 +944,17 @@ fn anc_level(name: &str) -> Option<AncLevel> { | |||
| 780 | 944 | .find(|level| level.name() == name) | |
| 781 | 945 | } | |
| 782 | 946 | ||
| 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 | − | }) | |
| 947 | + | /// The preset a write to `item` is for. | |
| 948 | + | fn item_preset(item: Item) -> u8 { | |
| 949 | + | match item { | |
| 950 | + | Item::Preset(id) | Item::Band(id, _) => id, | |
| 951 | + | _ => 0, | |
| 952 | + | } | |
| 802 | 953 | } | |
| 803 | 954 | ||
| 804 | 955 | #[cfg(test)] | |
| 805 | 956 | mod tests { | |
| 806 | 957 | use super::*; | |
| 807 | − | use budctl_core::status::EqPreset; | |
| 808 | 958 | use iced::keyboard::key::Named; | |
| 809 | 959 | use std::fs; | |
| 810 | 960 | use std::path::Path; | |
| @@ -825,8 +975,8 @@ mod tests { | |||
| 825 | 975 | ||
| 826 | 976 | fn reading() -> Reading { | |
| 827 | 977 | Reading { | |
| 828 | − | status: demo::buds().status, | |
| 829 | − | eq: None, | |
| 978 | + | status: buds::demo().status, | |
| 979 | + | eq: buds::demo().eq, | |
| 830 | 980 | } | |
| 831 | 981 | } | |
| 832 | 982 | ||
| @@ -1093,21 +1243,57 @@ mod tests { | |||
| 1093 | 1243 | } | |
| 1094 | 1244 | ||
| 1095 | 1245 | #[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"); | |
| 1246 | + | fn presets_and_bands_by_key() { | |
| 1247 | + | let mut app = app(); | |
| 1248 | + | press(&mut app, "J"); | |
| 1249 | + | assert_eq!(app.page, Page::Equalizer); | |
| 1250 | + | press(&mut app, "j l"); | |
| 1251 | + | assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw")); | |
| 1252 | + | assert_eq!(app.buds.eq_name(), "Pure raw"); | |
| 1253 | + | press(&mut app, "G"); | |
| 1254 | + | assert_eq!(app.focus(), Some(Item::Band(4, 16000))); | |
| 1255 | + | // Moving a band saves the custom preset, which also selects it. | |
| 1256 | + | press(&mut app, "l"); | |
| 1257 | + | assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4")); | |
| 1258 | + | let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain; | |
| 1259 | + | assert_eq!(last(&app), 3); | |
| 1260 | + | press(&mut app, "l l l l l"); | |
| 1261 | + | assert_eq!(last(&app), 6, "it stops at the top of the range"); | |
| 1262 | + | } | |
| 1263 | + | ||
| 1264 | + | #[test] | |
| 1265 | + | fn band_moves_wait_for_the_write_before_them() { | |
| 1266 | + | let (mut app, commands) = live(); | |
| 1267 | + | let _ = commands.try_recv(); | |
| 1268 | + | let _ = app.update(Message::Worker(Event::Read(Box::new(reading())))); | |
| 1269 | + | press(&mut app, "J G G l"); | |
| 1270 | + | let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain); | |
| 1271 | + | assert!(matches!( | |
| 1272 | + | commands.try_recv(), | |
| 1273 | + | Ok(Command::CustomEq(name, bands)) if name == "custom-4" && gain(&bands) == Some(3) | |
| 1274 | + | )); | |
| 1275 | + | // While that write is on its way, moves only change the screen. | |
| 1276 | + | press(&mut app, "l l"); | |
| 1277 | + | assert!(commands.try_recv().is_err()); | |
| 1278 | + | assert_eq!(app.draft.as_ref().and_then(|draft| gain(&draft.bands)), Some(5)); | |
| 1279 | + | // The answer comes: the latest bands go out at once. | |
| 1280 | + | let mut saved = buds::demo().preset(4).unwrap().clone(); | |
| 1281 | + | if let Some(band) = saved.bands.last_mut() { | |
| 1282 | + | band.gain = 3; | |
| 1283 | + | } | |
| 1284 | + | let _ = app.update(Message::Worker(Event::CustomEq(saved.clone()))); | |
| 1285 | + | assert!(matches!( | |
| 1286 | + | commands.try_recv(), | |
| 1287 | + | Ok(Command::CustomEq(_, bands)) if gain(&bands) == Some(5) | |
| 1288 | + | )); | |
| 1289 | + | assert_eq!(app.pending(), [Item::Preset(4)]); | |
| 1290 | + | // A failure drops the moves: the screen shows what the buds kept. | |
| 1291 | + | let _ = app.update(Message::Worker(Event::Failed { | |
| 1292 | + | kind: Kind::Failed, | |
| 1293 | + | message: "no reply".to_owned(), | |
| 1294 | + | })); | |
| 1295 | + | assert_eq!(app.draft, None); | |
| 1296 | + | assert_eq!(gain(&app.buds.preset(4).unwrap().bands), Some(3)); | |
| 1111 | 1297 | } | |
| 1112 | 1298 | ||
| 1113 | 1299 | #[test] | |
| @@ -1161,6 +1347,7 @@ mod tests { | |||
| 1161 | 1347 | shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, ""))); | |
| 1162 | 1348 | shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j"))); | |
| 1163 | 1349 | shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?"))); | |
| 1350 | + | shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k"))); | |
| 1164 | 1351 | let mut connecting = App::new(palette.clone(), font, None, false); | |
| 1165 | 1352 | connecting.link = Link::Connecting; | |
| 1166 | 1353 | shots.push(("connecting".to_owned(), 800.0, connecting)); | |
| @@ -4,11 +4,11 @@ use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch}; | |||
| 4 | 4 | use iced::widget::{column, container, row, text}; | |
| 5 | 5 | use iced::{Alignment, Element, Length}; | |
| 6 | 6 | ||
| 7 | − | use crate::demo::Buds; | |
| 7 | + | use crate::buds::Buds; | |
| 8 | 8 | use crate::icons::{self, Icon, Part, RingColors}; | |
| 9 | 9 | use crate::theme::Look; | |
| 10 | 10 | use crate::ui::{self, Segment}; | |
| 11 | − | use crate::{Item, Message, Page}; | |
| 11 | + | use crate::{Item, Marks, Message, Page}; | |
| 12 | 12 | ||
| 13 | 13 | /// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them. | |
| 14 | 14 | pub fn items(buds: &Buds) -> Vec<Item> { | |
| @@ -24,14 +24,6 @@ pub fn items(buds: &Buds) -> Vec<Item> { | |||
| 24 | 24 | items | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | − | /// What the page needs besides the buds: the item under the keyboard cursor, if it shows, and the | |
| 28 | − | /// items with a write on its way. | |
| 29 | − | #[derive(Clone, Copy)] | |
| 30 | − | pub struct Marks<'a> { | |
| 31 | − | pub focus: Option<Item>, | |
| 32 | − | pub pending: &'a [Item], | |
| 33 | − | } | |
| 34 | − | ||
| 35 | 27 | /// Shown instead of an item's subtitle while a write to it is on its way. | |
| 36 | 28 | const SAVING: &str = "Saving…"; | |
| 37 | 29 | ||
| @@ -193,7 +185,7 @@ fn more<'a>(look: Look, buds: &Buds, focus: Option<Item>) -> Element<'a, Message | |||
| 193 | 185 | .into_iter() | |
| 194 | 186 | .map(|(page, icon, title)| { | |
| 195 | 187 | let subtitle = match page { | |
| 196 | − | Page::Equalizer => buds.eq_name.clone(), | |
| 188 | + | Page::Equalizer => buds.eq_name(), | |
| 197 | 189 | Page::Gestures => "Taps and holds on each bud".to_owned(), | |
| 198 | 190 | _ => "Find my earbuds, firmware".to_owned(), | |
| 199 | 191 | }; | |
| @@ -73,7 +73,7 @@ fn separator<'a>(look: Look) -> Element<'a, Message> { | |||
| 73 | 73 | pub fn setting<'a>( | |
| 74 | 74 | look: Look, | |
| 75 | 75 | icon: Icon, | |
| 76 | − | title: &'a str, | |
| 76 | + | title: impl text::IntoFragment<'a>, | |
| 77 | 77 | subtitle: Option<String>, | |
| 78 | 78 | trailing: Element<'a, Message>, | |
| 79 | 79 | on_press: Message, | |
| @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; | |||
| 12 | 12 | ||
| 13 | 13 | use budctl_core::bt::BdAddr; | |
| 14 | 14 | use budctl_core::devices::Session; | |
| 15 | − | use budctl_core::status::{EqPresets, NoiseControl, NoiseMode, Status, Switch}; | |
| 15 | + | use budctl_core::status::{EqBand, EqPreset, EqPresets, NoiseControl, NoiseMode, Status, Switch}; | |
| 16 | 16 | use budctl_core::{Error, Kind, Result}; | |
| 17 | 17 | use iced::futures::channel::mpsc as channel; | |
| 18 | 18 | use iced::futures::{SinkExt, Stream, StreamExt}; | |
| @@ -29,6 +29,11 @@ pub enum Command { | |||
| 29 | 29 | Noise(NoiseMode), | |
| 30 | 30 | /// A switch's key, and whether to turn it on. | |
| 31 | 31 | Switch(&'static str, bool), | |
| 32 | + | /// Select a built-in EQ preset, by name. | |
| 33 | + | Eq(String), | |
| 34 | + | /// Save a custom EQ preset (`custom-<id>`) with these bands changed, which also selects it. | |
| 35 | + | /// With no bands, it only selects it. | |
| 36 | + | CustomEq(String, Vec<EqBand>), | |
| 32 | 37 | } | |
| 33 | 38 | ||
| 34 | 39 | #[derive(Debug, Clone)] | |
| @@ -43,6 +48,10 @@ pub enum Event { | |||
| 43 | 48 | Noise(NoiseControl), | |
| 44 | 49 | /// The answer to [`Command::Switch`]: the switch, as read back. | |
| 45 | 50 | Switch(Switch), | |
| 51 | + | /// The answer to [`Command::Eq`]: the preset now in use. | |
| 52 | + | Eq(String), | |
| 53 | + | /// The answer to [`Command::CustomEq`]: the preset, as read back. | |
| 54 | + | CustomEq(EqPreset), | |
| 46 | 55 | /// The answer to a command that failed. The session is closed; the next command opens a | |
| 47 | 56 | /// new one. | |
| 48 | 57 | Failed { kind: Kind, message: String }, | |
| @@ -144,6 +153,14 @@ impl Worker { | |||
| 144 | 153 | .session() | |
| 145 | 154 | .and_then(|session| session.set_switch(key, on)) | |
| 146 | 155 | .map(Event::Switch), | |
| 156 | + | Command::Eq(preset) => self | |
| 157 | + | .session() | |
| 158 | + | .and_then(|session| session.set_eq(&preset)) | |
| 159 | + | .map(Event::Eq), | |
| 160 | + | Command::CustomEq(preset, bands) => self | |
| 161 | + | .session() | |
| 162 | + | .and_then(|session| session.set_custom_eq(&preset, &bands)) | |
| 163 | + | .map(Event::CustomEq), | |
| 147 | 164 | }; | |
| 148 | 165 | match answer { | |
| 149 | 166 | Ok(event) => self.send(event), | |