Krolyxon
Commit 4276e2b

Branches

Refs →

budctl4276e2b

budctl-gui: a pencil and a bin on each custom preset's row

Opening a preset's bands no longer needs it selected: the pencil on its
row opens them below the list (Rename and Done on that card), and the
bin deletes it after a yes. A click on the row still selects it, as for
built-in presets. Since the buds save bands only with the command that
also selects the preset (W11b), the open card says that moving a band
puts the preset in use. A new preset opens its bands at once.

The cursor reaches the pencil and the bin right after their row.

No new dependencies.
Author
krolyxon
Date
2026-10-28 01:48 +0530
Parent
449c3e4
Commit
4276e2b8e0b6661963b8a952e07d3b7091741e1d

+180 −55

4 files changed

  1. M crates/budctl-gui/src/equalizer.rs +89 −43
  2. M crates/budctl-gui/src/icons.rs +13 −0
  3. M crates/budctl-gui/src/main.rs +59 −12
  4. M crates/budctl-gui/src/ui.rs +19 −0
M crates/budctl-gui/src/equalizer.rs View
@@ -14,34 +14,52 @@ use crate::{Draft, Item, Marks, Message};
1414 const SAVING: &str = "Saving…";
1515
1616 /// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them: each preset,
17−/// "New preset", then, when a custom preset is in use, "Rename", "Delete" and its bands.
18−pub fn items(buds: &Buds) -> Vec<Item> {
17+/// with a custom one's pencil and bin; "New preset"; then, while a preset's bands are open,
18+/// "Rename", "Done" and its bands.
19+pub fn items(buds: &Buds, editing: Option<u8>) -> Vec<Item> {
1920 let mut items = Vec::new();
20− if let Some(eq) = &buds.eq {
21− items.extend(eq.presets.iter().map(|p| Item::Preset(p.id)));
22− items.push(Item::NewPreset);
23− if let Some(preset) = in_use(buds) {
24− items.extend([Item::Rename(preset.id), Item::Delete(preset.id)]);
25− items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz)));
21+ let Some(eq) = &buds.eq else {
22+ return items;
23+ };
24+ for preset in &eq.presets {
25+ items.push(Item::Preset(preset.id));
26+ if preset.custom {
27+ items.extend([Item::Edit(preset.id), Item::Delete(preset.id)]);
2628 }
2729 }
30+ items.push(Item::NewPreset);
31+ if let Some(preset) = edited(buds, editing) {
32+ items.extend([Item::Rename(preset.id), Item::Done]);
33+ items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz)));
34+ }
2835 items
2936 }
3037
31−/// The custom preset in use, if one is: only its bands can be changed.
38+/// The custom preset in use, if one is.
3239 pub fn in_use(buds: &Buds) -> Option<&EqPreset> {
3340 let eq = buds.eq.as_ref()?;
3441 let current = eq.current.as_deref()?;
3542 eq.presets.iter().find(|p| p.custom && buds::key(p) == current)
3643 }
3744
38−/// `draft`: bands moved on the screen and not saved on the buds yet.
39−pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
45+/// The custom preset whose bands are open, if any.
46+pub fn edited(buds: &Buds, editing: Option<u8>) -> Option<&EqPreset> {
47+ buds.preset(editing?).filter(|preset| preset.custom)
48+}
49+
50+/// `editing`: the custom preset whose bands are open; `draft`: bands moved on the screen and not
51+/// saved on the buds yet.
52+pub fn view<'a>(
53+ look: Look,
54+ buds: &'a Buds,
55+ editing: Option<u8>,
56+ draft: Option<&'a Draft>,
57+ marks: Marks,
58+) -> Element<'a, Message> {
59+ let p = look.p;
4060 let title = ui::title(look, "Equalizer");
4161 let Some(eq) = &buds.eq else {
42− let none = text("These buds did not list their presets.")
43− .size(14)
44− .color(look.p.dim);
62+ let none = text("These buds did not list their presets.").size(14).color(p.dim);
4563 return column![title, none].spacing(12).into();
4664 };
4765 let current = eq.current.as_deref();
@@ -49,38 +67,61 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Mar
4967 .presets
5068 .iter()
5169 .map(|preset| {
70+ let id = preset.id;
5271 let chosen = current == Some(buds::key(preset).as_str());
53− let subtitle = if marks.pending.contains(&Item::Preset(preset.id)) {
72+ let subtitle = if marks.pending.contains(&Item::Preset(id)) {
5473 SAVING
74+ } else if preset.custom && editing == Some(id) {
75+ "Your preset, open below"
5576 } else if preset.custom {
5677 "Your preset"
5778 } else {
5879 "Built in"
5980 };
6081 let mark: Element<'a, Message> = if chosen {
61− icons::icon(Icon::Check, 20.0, look.p.icon)
82+ icons::icon(Icon::Check, 20.0, p.icon)
6283 } else {
6384 space().width(20).into()
6485 };
86+ let trailing: Element<'a, Message> = if preset.custom {
87+ row![
88+ mark,
89+ ui::icon_button(
90+ look,
91+ Icon::Pencil,
92+ marks.focus == Some(Item::Edit(id)),
93+ Message::EditPreset(id)
94+ ),
95+ ui::icon_button(
96+ look,
97+ Icon::Trash,
98+ marks.focus == Some(Item::Delete(id)),
99+ Message::DeletePreset(id)
100+ ),
101+ ]
102+ .spacing(6)
103+ .align_y(Alignment::Center)
104+ .into()
105+ } else {
106+ mark
107+ };
65108 ui::setting(
66109 look,
67110 Icon::Equalizer,
68111 buds::shown_name(preset),
69112 Some(subtitle.to_owned()),
70− mark,
71− Some(Message::Preset(preset.id)),
72− marks.focus == Some(Item::Preset(preset.id)),
113+ trailing,
114+ Some(Message::Preset(id)),
115+ marks.focus == Some(Item::Preset(id)),
73116 )
74117 })
75118 .collect();
76− let editor = match in_use(buds) {
119+ let editor: Element<'a, Message> = match edited(buds, editing) {
77120 Some(preset) => bands(look, preset, draft, marks),
78− None => {
79− let note = text("Built-in presets cannot be changed. Pick one of your presets to change its bands.")
80− .size(14)
81− .color(look.p.dim);
82− ui::panel(look, note.into())
83− }
121+ None => text("Use the pencil on one of your presets to change its bands.")
122+ .size(13)
123+ .color(p.dim)
124+ .into(),
84125 };
85126 column![title, ui::card(look, rows), new_preset(look, marks), editor]
86127 .spacing(18)
@@ -142,13 +183,16 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks:
142183 ))
143184 .push(ui::pill_with(
144185 look,
145− "Delete",
186+ "Done",
146187 false,
147− marks.focus == Some(Item::Delete(id)),
148− Message::DeletePreset(id),
188+ marks.focus == Some(Item::Done),
189+ Message::DoneEditing,
149190 ))
150191 .spacing(10)
151192 .align_y(Alignment::Center);
193+ let note = text("Moving a band saves this preset and puts it in use, so you hear it.")
194+ .size(13)
195+ .color(p.dim);
152196 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
153197 let focused = marks.focus == Some(Item::Band(id, band.hz));
154198 let hz = band.hz;
@@ -197,7 +241,7 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks:
197241 }),
198242 )
199243 });
200− ui::panel(look, column![heading, sliders].spacing(16).into())
244+ ui::panel(look, column![heading, note, sliders].spacing(14).into())
201245 }
202246
203247 /// `3` → "+3", `0` → "0", `-2` → "-2".
@@ -220,19 +264,21 @@ mod tests {
220264
221265 #[test]
222266 fn items_follow_the_page() {
223− let items = items(&buds::demo());
224− assert_eq!(items.len(), 5 + 1 + 2 + 6);
225− assert_eq!(items.first(), Some(&Item::Preset(0)));
226− assert_eq!(items.get(5), Some(&Item::NewPreset));
227− assert_eq!(items.get(6), Some(&Item::Rename(4)));
228− assert_eq!(items.get(7), Some(&Item::Delete(4)));
229− assert_eq!(items.get(8), Some(&Item::Band(4, 50)));
230− assert_eq!(items.last(), Some(&Item::Band(4, 16000)));
231− // With a built-in preset in use, there is nothing to edit.
232− let mut buds = buds::demo();
233− buds.set_eq("pure-bass".to_owned());
234− assert_eq!(super::items(&buds).last(), Some(&Item::NewPreset));
235− assert!(super::items(&buds::unknown()).is_empty());
267+ let buds = buds::demo();
268+ let closed = items(&buds, None);
269+ assert_eq!(closed.len(), 4 + 3 + 1);
270+ assert_eq!(closed.first(), Some(&Item::Preset(0)));
271+ assert_eq!(
272+ closed.get(4..),
273+ Some(&[Item::Preset(4), Item::Edit(4), Item::Delete(4), Item::NewPreset][..])
274+ );
275+ let open = items(&buds, Some(4));
276+ assert_eq!(open.len(), 8 + 2 + 6);
277+ assert_eq!(open.get(8..10), Some(&[Item::Rename(4), Item::Done][..]));
278+ assert_eq!(open.last(), Some(&Item::Band(4, 16000)));
279+ // A built-in preset has no bands to open.
280+ assert_eq!(items(&buds, Some(0)).len(), 8);
281+ assert!(items(&buds::unknown(), None).is_empty());
236282 }
237283
238284 #[test]
M crates/budctl-gui/src/icons.rs View
@@ -28,6 +28,8 @@ pub enum Icon {
2828 Info,
2929 Chip,
3030 Keys,
31+ Pencil,
32+ Trash,
3133 }
3234
3335 /// `icon` at `size` pixels, in `color`.
@@ -256,6 +258,17 @@ fn draw_icon(pen: &mut Pen, icon: Icon) {
256258 pen.line(from, to);
257259 }
258260 }
261+ Icon::Pencil => {
262+ pen.polyline(&[(4.5, 19.5), (5.5, 15.0), (15.5, 5.0), (19.0, 8.5), (9.0, 18.5)], true);
263+ pen.line((13.0, 7.5), (16.5, 11.0));
264+ }
265+ Icon::Trash => {
266+ pen.line((4.0, 6.5), (20.0, 6.5));
267+ pen.polyline(&[(9.0, 6.5), (9.5, 3.5), (14.5, 3.5), (15.0, 6.5)], false);
268+ pen.polyline(&[(6.0, 6.5), (7.0, 20.5), (17.0, 20.5), (18.0, 6.5)], false);
269+ pen.line((10.0, 10.5), (10.0, 16.5));
270+ pen.line((14.0, 10.5), (14.0, 16.5));
271+ }
259272 Icon::Info => {
260273 pen.circle(12.0, 12.0, 9.0);
261274 pen.line((12.0, 11.0), (12.0, 16.5));
M crates/budctl-gui/src/main.rs View
@@ -198,8 +198,12 @@ enum Item {
198198 NewPreset,
199199 /// "Rename", on a custom EQ preset's card.
200200 Rename(u8),
201− /// "Delete", on a custom EQ preset's card.
201+ /// The bin on a custom EQ preset's row.
202202 Delete(u8),
203+ /// The pencil on a custom EQ preset's row: open its bands.
204+ Edit(u8),
205+ /// "Done", on the open bands.
206+ Done,
203207 }
204208
205209 /// The dialog that asks for a preset's name.
@@ -299,6 +303,10 @@ enum Message {
299303 NameInput(String),
300304 SubmitName,
301305 CancelName,
306+ /// Open a custom preset's (by its id) bands, or close them if they are open.
307+ EditPreset(u8),
308+ /// Close the open bands.
309+ DoneEditing,
302310 /// Delete a custom preset (by its id), after a confirmation.
303311 DeletePreset(u8),
304312 /// The answer to "Delete the preset?".
@@ -356,6 +364,8 @@ struct App {
356364 prompt: Option<NamePrompt>,
357365 /// "Delete the preset?" is open, for this preset.
358366 confirm_delete: Option<u8>,
367+ /// The custom preset whose bands are open on the Equalizer page.
368+ editing: Option<u8>,
359369 }
360370
361371 impl App {
@@ -395,6 +405,7 @@ impl App {
395405 palette_problem: None,
396406 prompt: None,
397407 confirm_delete: None,
408+ editing: None,
398409 };
399410 app.refresh_palette();
400411 app
@@ -450,12 +461,19 @@ impl App {
450461 }
451462 Message::SubmitName => self.submit_name(),
452463 Message::CancelName => self.prompt = None,
464+ Message::EditPreset(id) => {
465+ self.editing = if self.editing == Some(id) { None } else { Some(id) };
466+ }
467+ Message::DoneEditing => self.editing = None,
453468 Message::DeletePreset(id) => self.confirm_delete = Some(id),
454469 Message::ConfirmDelete(yes) => {
455470 if let (true, Some(id)) = (yes, self.confirm_delete.take())
456471 && let Some(preset) = self.buds.preset(id)
457472 {
458473 let command = Command::DeleteEq(buds::key(preset));
474+ if self.editing == Some(id) {
475+ self.editing = None;
476+ }
459477 self.write(Item::Preset(id), command);
460478 }
461479 self.confirm_delete = None;
@@ -530,6 +548,7 @@ impl App {
530548 let model = customs().next().cloned();
531549 let id = customs().map(|p| p.id).max().map_or(4, |id| id.saturating_add(1));
532550 if let Some(model) = model {
551+ self.editing = Some(id);
533552 self.buds.set_custom(EqPreset {
534553 name,
535554 id,
@@ -664,9 +683,13 @@ impl App {
664683 }
665684 }
666685 Event::Presets(eq) => {
667− self.in_flight.pop_front();
686+ let done = self.in_flight.pop_front();
668687 self.buds.set_presets(eq);
669688 self.link = Link::Live;
689+ // A new preset opens its bands: it is the one in use.
690+ if done == Some(Some(Item::NewPreset)) {
691+ self.editing = equalizer::in_use(&self.buds).map(|preset| preset.id);
692+ }
670693 }
671694 Event::Bass(level) => {
672695 self.in_flight.pop_front();
@@ -981,7 +1004,7 @@ impl App {
9811004 fn items(&self) -> Vec<Item> {
9821005 match self.page {
9831006 Page::Overview if self.has_reading() => overview::items(&self.buds),
984− Page::Equalizer if self.has_reading() => equalizer::items(&self.buds),
1007+ Page::Equalizer if self.has_reading() => equalizer::items(&self.buds, self.editing),
9851008 Page::Gestures if self.has_reading() => gestures::items(&self.buds),
9861009 Page::Earbuds if self.has_reading() => earbuds::items(),
9871010 _ => Vec::new(),
@@ -1093,6 +1116,8 @@ impl App {
10931116 Item::NewPreset => (action != Action::Left).then_some(Message::NewPreset),
10941117 Item::Rename(id) => (action != Action::Left).then_some(Message::RenamePreset(id)),
10951118 Item::Delete(id) => (action != Action::Left).then_some(Message::DeletePreset(id)),
1119+ Item::Edit(id) => (action != Action::Left).then_some(Message::EditPreset(id)),
1120+ Item::Done => (action != Action::Left).then_some(Message::DoneEditing),
10961121 Item::Bass => {
10971122 let (low, high) = self.buds.bass_range?;
10981123 let now = self.bass_draft.or(self.buds.bass);
@@ -1186,7 +1211,7 @@ impl App {
11861211 focus: self.focus(),
11871212 pending: &pending,
11881213 };
1189− equalizer::view(look, &self.buds, self.draft.as_ref(), marks)
1214+ equalizer::view(look, &self.buds, self.editing, self.draft.as_ref(), marks)
11901215 }
11911216 Page::Gestures if self.has_reading() => {
11921217 let marks = Marks {
@@ -1855,12 +1880,19 @@ mod tests {
18551880 press(&mut app, "j l");
18561881 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
18571882 assert_eq!(app.buds.eq_name(), "Pure raw");
1858− // Back to Custom1, the fifth preset: its bands come back.
1859− press(&mut app, "j j j j l");
1860− assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
1883+ // Custom1's pencil, after the four built-in presets and Custom1's row, opens its bands.
1884+ press(&mut app, "j j j j j Enter");
1885+ assert_eq!(app.editing, Some(4));
1886+ assert_eq!(
1887+ app.buds.status.eq.as_deref(),
1888+ Some("pure-raw"),
1889+ "opening selects nothing"
1890+ );
18611891 press(&mut app, "G");
18621892 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
1893+ // Moving a band saves the preset, which puts it in use.
18631894 press(&mut app, "l");
1895+ assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
18641896 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
18651897 assert_eq!(last(&app), 3);
18661898 press(&mut app, "l l l l l");
@@ -1872,6 +1904,7 @@ mod tests {
18721904 let (mut app, commands) = live();
18731905 let _ = commands.try_recv();
18741906 let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
1907+ let _ = app.update(Message::EditPreset(4));
18751908 press(&mut app, "J G G l");
18761909 let gain = |bands: &[EqBand]| bands.last().map(|band| band.gain);
18771910 assert!(matches!(
@@ -2050,6 +2083,7 @@ mod tests {
20502083 .find(|(_, name)| *name == "Gym")
20512084 .map(|(id, _)| id)
20522085 .unwrap();
2086+ assert_eq!(app.editing, Some(gym), "and its bands open");
20532087
20542088 let _ = app.update(Message::RenamePreset(gym));
20552089 assert_eq!(app.prompt.as_ref().map(|p| p.text.as_str()), Some("Gym"));
@@ -2080,6 +2114,7 @@ mod tests {
20802114 presets.current = Some("custom-5".to_owned());
20812115 let _ = app.update(Message::Worker(Event::Presets(presets)));
20822116 assert_eq!(app.buds.eq_name(), "Gym");
2117+ assert_eq!(app.editing, Some(5));
20832118 assert!(app.pending().is_empty());
20842119
20852120 let _ = app.update(Message::RenamePreset(4));
@@ -2114,11 +2149,19 @@ mod tests {
21142149 }
21152150
21162151 #[test]
2117− fn only_the_preset_in_use_shows_its_bands() {
2152+ fn the_pencil_opens_the_bands_and_done_closes_them() {
21182153 let mut app = app();
2119− press(&mut app, "J j l");
2120− assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
2121− assert!(!app.items().iter().any(|item| matches!(item, Item::Band(..))));
2154+ press(&mut app, "J");
2155+ let bands = |app: &App| app.items().iter().any(|item| matches!(item, Item::Band(..)));
2156+ assert!(!bands(&app));
2157+ let _ = app.update(Message::EditPreset(4));
2158+ assert!(bands(&app));
2159+ let _ = app.update(Message::DoneEditing);
2160+ assert!(!bands(&app));
2161+ // The pencil again closes them too.
2162+ let _ = app.update(Message::EditPreset(4));
2163+ let _ = app.update(Message::EditPreset(4));
2164+ assert!(!bands(&app));
21222165 }
21232166
21242167 #[test]
@@ -2172,7 +2215,11 @@ mod tests {
21722215 shots.push(("overview-full".to_owned(), 1240.0, on(Page::Overview, "")));
21732216 shots.push(("overview-cursor".to_owned(), 800.0, on(Page::Overview, "j j j j")));
21742217 shots.push(("keys".to_owned(), 800.0, on(Page::Overview, "?")));
2175− shots.push(("equalizer-cursor".to_owned(), 1000.0, on(Page::Equalizer, "G G k k")));
2218+ let mut editing = on(Page::Equalizer, "");
2219+ let _ = editing.update(Message::EditPreset(4));
2220+ press(&mut editing, "G G k k");
2221+ shots.push(("equalizer-cursor".to_owned(), 1100.0, editing));
2222+ shots.push(("equalizer-pencil".to_owned(), 800.0, on(Page::Equalizer, "j j j j j j")));
21762223 shots.push(("gestures-cursor".to_owned(), 1000.0, on(Page::Gestures, "j j")));
21772224 shots.push(("find-confirm".to_owned(), 800.0, on(Page::Earbuds, "j Enter")));
21782225 shots.push((
M crates/budctl-gui/src/ui.rs View
@@ -270,6 +270,25 @@ pub fn pill_with<'a>(
270270 .into()
271271 }
272272
273+/// A round button with just an icon, like the pencil on a preset's row.
274+pub fn icon_button<'a>(look: Look, icon: Icon, focused: bool, on_press: Message) -> Element<'a, Message> {
275+ let p = look.p;
276+ button(icons::icon(icon, 19.0, if focused { p.fg } else { p.dim }))
277+ .on_press(on_press)
278+ .padding(8)
279+ .style(move |_, status| button::Style {
280+ background: (focused || hovered(status)).then_some(p.surface_highest.into()),
281+ text_color: p.fg,
282+ border: Border {
283+ color: p.accent,
284+ width: if focused { 2.0 } else { 0.0 },
285+ radius: 999.0.into(),
286+ },
287+ ..button::Style::default()
288+ })
289+ .into()
290+}
291+
273292 /// A wide button with an outline, like "Disconnect".
274293 pub fn outline_button<'a>(look: Look, label: &'a str, on_press: Message) -> Element<'a, Message> {
275294 let p = look.p;