Krolyxon
Commit 904ab9a

Branches

Refs →

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.
Author
krolyxon
Date
2026-10-27 23:44 +0530
Parent
b9e5c00
Commit
904ab9a4b37dfab15cc3410ddfbd04f152077494

+644 −114

10 files changed

  1. M crates/budctl-core/src/devices/realme_t310/codec.rs +3 −0
  2. M crates/budctl-core/src/status.rs +3 −0
  3. A crates/budctl-gui/src/buds.rs +190 −0
  4. D crates/budctl-gui/src/demo.rs +0 −56
  5. A crates/budctl-gui/src/equalizer.rs +192 −0
  6. M crates/budctl-gui/src/icons.rs +2 −0
  7. M crates/budctl-gui/src/main.rs +232 −45
  8. M crates/budctl-gui/src/overview.rs +3 −11
  9. M crates/budctl-gui/src/ui.rs +1 −1
  10. M crates/budctl-gui/src/worker.rs +18 −1
M crates/budctl-core/src/devices/realme_t310/codec.rs View
@@ -482,6 +482,7 @@ pub(super) fn builtin_eqs() -> Vec<EqPreset> {
482482 id,
483483 custom: false,
484484 bands: Vec::new(),
485+ range: None,
485486 })
486487 .collect()
487488 }
@@ -506,6 +507,7 @@ impl CustomEq {
506507 id: self.id,
507508 custom: true,
508509 bands: self.bands.clone(),
510+ range: Some((self.min, self.max)),
509511 }
510512 }
511513 }
@@ -1044,6 +1046,7 @@ mod tests {
10441046 id: 0x04,
10451047 custom: true,
10461048 bands: vec![EqBand { hz: 62, gain: 3 }, EqBand { hz: 16000, gain: -2 }],
1049+ range: Some((-6, 6)),
10471050 }]
10481051 );
10491052 // Cut short inside the bands.
M crates/budctl-core/src/status.rs View
@@ -133,6 +133,9 @@ pub struct EqPreset {
133133 pub custom: bool,
134134 /// Custom presets only: gain per band. Empty for built-in presets.
135135 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)>,
136139 }
137140
138141 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
A crates/budctl-gui/src/buds.rs View
@@ -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+}
D crates/budctl-gui/src/demo.rs View
@@ -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−}
A crates/budctl-gui/src/equalizer.rs View
@@ -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+}
M crates/budctl-gui/src/icons.rs View
@@ -23,6 +23,7 @@ pub enum Icon {
2323 Dual,
2424 Chevron,
2525 Bolt,
26+ Check,
2627 }
2728
2829 /// `icon` at `size` pixels, in `color`.
@@ -239,6 +240,7 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
239240 pen.rect(17.5, 8.0, 4.5, 11.0, 1.2);
240241 }
241242 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),
242244 }
243245 }
244246
M crates/budctl-gui/src/main.rs View
@@ -4,7 +4,8 @@
44 //! own, and the window shows what they report. The mouse comes first; vim-like keys work on top
55 //! (`keys.rs`). `--demo` shows made-up buds instead.
66
7−mod demo;
7+mod buds;
8+mod equalizer;
89 mod icons;
910 mod keys;
1011 mod overview;
@@ -20,7 +21,7 @@ use std::time::{Duration, Instant};
2021
2122 use budctl_core::Kind;
2223 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};
2425 use iced::keyboard::{self, Key, Modifiers};
2526 use iced::widget::operation::{self, RelativeOffset};
2627 use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text};
@@ -176,6 +177,26 @@ enum Item {
176177 Switch(&'static str),
177178 /// A row that opens a page.
178179 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>,
179200 }
180201
181202 /// Where the connection to the buds stands.
@@ -197,6 +218,18 @@ enum Message {
197218 Noise(NoiseMode),
198219 /// A switch's key, and whether to turn it on.
199220 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),
200233 /// Read the buds again.
201234 Refresh,
202235 Worker(Event),
@@ -210,7 +243,7 @@ enum Message {
210243
211244 struct App {
212245 page: Page,
213− buds: demo::Buds,
246+ buds: buds::Buds,
214247 /// The level "Noise cancelling" turns on at: the one last reported or picked.
215248 anc_level: AncLevel,
216249 look: Look,
@@ -233,11 +266,13 @@ struct App {
233266 in_flight: VecDeque<Option<Item>>,
234267 /// When the buds were last read.
235268 read_at: Option<Instant>,
269+ /// Bands moved and not saved yet.
270+ draft: Option<Draft>,
236271 }
237272
238273 impl App {
239274 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() };
241276 let anc_level = buds
242277 .status
243278 .noise_control
@@ -264,6 +299,7 @@ impl App {
264299 worker: None,
265300 in_flight: VecDeque::new(),
266301 read_at: None,
302+ draft: None,
267303 };
268304 app.refresh_palette();
269305 app
@@ -274,6 +310,14 @@ impl App {
274310 Message::Open(page) => return self.open(page),
275311 Message::Noise(mode) => self.write(Item::NoiseMode, Command::Noise(mode)),
276312 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),
277321 Message::Refresh => self.refresh(),
278322 Message::Worker(event) => self.answer(event),
279323 Message::Focused => {
@@ -307,6 +351,19 @@ impl App {
307351 self.apply_switch(switch);
308352 }
309353 }
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+ }
310367 Command::Read => {}
311368 }
312369 } else if !self.pending().contains(&item) {
@@ -317,7 +374,10 @@ impl App {
317374 /// Reads the buds again; with the made-up buds, starts them over.
318375 fn refresh(&mut self) {
319376 match self.link {
320− Link::Demo => self.buds = demo::buds(),
377+ Link::Demo => {
378+ self.buds = buds::demo();
379+ self.draft = None;
380+ }
321381 _ if self.in_flight.contains(&None) => {}
322382 _ => {
323383 if self.read_at.is_none() {
@@ -366,8 +426,35 @@ impl App {
366426 self.apply_switch(switch);
367427 self.link = Link::Live;
368428 }
369− Event::Failed { kind, message } => {
429+ Event::Eq(now) => {
370430 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+ }
371458 if kind == Kind::NotFound {
372459 self.buds.connected = false;
373460 }
@@ -377,7 +464,7 @@ impl App {
377464 }
378465
379466 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;
381468 if let Some(level) = reading
382469 .status
383470 .noise_control
@@ -406,6 +493,49 @@ impl App {
406493 }
407494 }
408495
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+
409539 /// The items with a write on its way.
410540 fn pending(&self) -> Vec<Item> {
411541 self.in_flight.iter().flatten().copied().collect()
@@ -461,6 +591,7 @@ impl App {
461591 fn items(&self) -> Vec<Item> {
462592 match self.page {
463593 Page::Overview if self.has_reading() => overview::items(&self.buds),
594+ Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
464595 _ => Vec::new(),
465596 }
466597 }
@@ -528,6 +659,32 @@ impl App {
528659 (want != on).then_some(Message::Switch(key, want))
529660 }
530661 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+ }
531688 }
532689 }
533690
@@ -580,13 +737,20 @@ impl App {
580737 let pending = self.pending();
581738 let page = match self.page {
582739 Page::Overview if self.has_reading() => {
583− let marks = overview::Marks {
740+ let marks = Marks {
584741 focus: self.focus(),
585742 pending: &pending,
586743 };
587744 overview::view(look, &self.buds, self.anc_level, marks)
588745 }
589746 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+ }
590754 page => column![
591755 ui::title(look, page.title()),
592756 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> {
780944 .find(|level| level.name() == name)
781945 }
782946
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+ }
802953 }
803954
804955 #[cfg(test)]
805956 mod tests {
806957 use super::*;
807− use budctl_core::status::EqPreset;
808958 use iced::keyboard::key::Named;
809959 use std::fs;
810960 use std::path::Path;
@@ -825,8 +975,8 @@ mod tests {
825975
826976 fn reading() -> Reading {
827977 Reading {
828− status: demo::buds().status,
829− eq: None,
978+ status: buds::demo().status,
979+ eq: buds::demo().eq,
830980 }
831981 }
832982
@@ -1093,21 +1243,57 @@ mod tests {
10931243 }
10941244
10951245 #[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));
11111297 }
11121298
11131299 #[test]
@@ -1161,6 +1347,7 @@ mod tests {
11611347 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
11621348 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
11631349 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")));
11641351 let mut connecting = App::new(palette.clone(), font, None, false);
11651352 connecting.link = Link::Connecting;
11661353 shots.push(("connecting".to_owned(), 800.0, connecting));
M crates/budctl-gui/src/overview.rs View
@@ -4,11 +4,11 @@ use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch};
44 use iced::widget::{column, container, row, text};
55 use iced::{Alignment, Element, Length};
66
7−use crate::demo::Buds;
7+use crate::buds::Buds;
88 use crate::icons::{self, Icon, Part, RingColors};
99 use crate::theme::Look;
1010 use crate::ui::{self, Segment};
11−use crate::{Item, Message, Page};
11+use crate::{Item, Marks, Message, Page};
1212
1313 /// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them.
1414 pub fn items(buds: &Buds) -> Vec<Item> {
@@ -24,14 +24,6 @@ pub fn items(buds: &Buds) -> Vec<Item> {
2424 items
2525 }
2626
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−
3527 /// Shown instead of an item's subtitle while a write to it is on its way.
3628 const SAVING: &str = "Saving…";
3729
@@ -193,7 +185,7 @@ fn more<'a>(look: Look, buds: &Buds, focus: Option<Item>) -> Element<'a, Message
193185 .into_iter()
194186 .map(|(page, icon, title)| {
195187 let subtitle = match page {
196− Page::Equalizer => buds.eq_name.clone(),
188+ Page::Equalizer => buds.eq_name(),
197189 Page::Gestures => "Taps and holds on each bud".to_owned(),
198190 _ => "Find my earbuds, firmware".to_owned(),
199191 };
M crates/budctl-gui/src/ui.rs View
@@ -73,7 +73,7 @@ fn separator<'a>(look: Look) -> Element<'a, Message> {
7373 pub fn setting<'a>(
7474 look: Look,
7575 icon: Icon,
76− title: &'a str,
76+ title: impl text::IntoFragment<'a>,
7777 subtitle: Option<String>,
7878 trailing: Element<'a, Message>,
7979 on_press: Message,
M crates/budctl-gui/src/worker.rs View
@@ -12,7 +12,7 @@ use std::time::{Duration, Instant};
1212
1313 use budctl_core::bt::BdAddr;
1414 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};
1616 use budctl_core::{Error, Kind, Result};
1717 use iced::futures::channel::mpsc as channel;
1818 use iced::futures::{SinkExt, Stream, StreamExt};
@@ -29,6 +29,11 @@ pub enum Command {
2929 Noise(NoiseMode),
3030 /// A switch's key, and whether to turn it on.
3131 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>),
3237 }
3338
3439 #[derive(Debug, Clone)]
@@ -43,6 +48,10 @@ pub enum Event {
4348 Noise(NoiseControl),
4449 /// The answer to [`Command::Switch`]: the switch, as read back.
4550 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),
4655 /// The answer to a command that failed. The session is closed; the next command opens a
4756 /// new one.
4857 Failed { kind: Kind, message: String },
@@ -144,6 +153,14 @@ impl Worker {
144153 .session()
145154 .and_then(|session| session.set_switch(key, on))
146155 .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),
147164 };
148165 match answer {
149166 Ok(event) => self.send(event),