Krolyxon
Commit 449c3e4

Branches

Refs →

budctl449c3e4

budctl-gui: delete presets; bands only for the preset in use

The Equalizer page shows the band sliders of the custom preset in use
only, with Rename and Delete on its card; with a built-in preset in
use, a note says those cannot be changed. The cursor follows: no bands
to move unless a custom preset is in use.

Delete asks "Delete <name>?" first (only Enter or the button says yes:
the bands are lost), then the worker deletes the preset and reads the
whole list back, since ids can change. The worker gained
Command::DeleteEq.

No new dependencies.
Author
krolyxon
Date
2026-10-28 01:42 +0530
Committer
krolyxon · 2026-10-28 01:42 +0530
Parent
56c56d4
Commit
449c3e462c436018f3f0af9f1267ec8472796a65

+151 −15

3 files changed

  1. M crates/budctl-gui/src/equalizer.rs +35 −12
  2. M crates/budctl-gui/src/main.rs +105 −2
  3. M crates/budctl-gui/src/worker.rs +11 −1
M crates/budctl-gui/src/equalizer.rs View
@@ -14,20 +14,27 @@ 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 for each custom preset "Rename" and its bands.
17+/// "New preset", then, when a custom preset is in use, "Rename", "Delete" and its bands.
1818 pub fn items(buds: &Buds) -> Vec<Item> {
1919 let mut items = Vec::new();
2020 if let Some(eq) = &buds.eq {
2121 items.extend(eq.presets.iter().map(|p| Item::Preset(p.id)));
2222 items.push(Item::NewPreset);
23− for preset in eq.presets.iter().filter(|p| p.custom) {
24− items.push(Item::Rename(preset.id));
23+ if let Some(preset) = in_use(buds) {
24+ items.extend([Item::Rename(preset.id), Item::Delete(preset.id)]);
2525 items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz)));
2626 }
2727 }
2828 items
2929 }
3030
31+/// The custom preset in use, if one is: only its bands can be changed.
32+pub fn in_use(buds: &Buds) -> Option<&EqPreset> {
33+ let eq = buds.eq.as_ref()?;
34+ let current = eq.current.as_deref()?;
35+ eq.presets.iter().find(|p| p.custom && buds::key(p) == current)
36+}
37+
3138 /// `draft`: bands moved on the screen and not saved on the buds yet.
3239 pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
3340 let title = ui::title(look, "Equalizer");
@@ -66,13 +73,17 @@ pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Mar
6673 )
6774 })
6875 .collect();
69− eq.presets
70− .iter()
71− .filter(|p| p.custom)
72− .fold(
73− column![title, ui::card(look, rows), new_preset(look, marks)].spacing(18),
74− |page, preset| page.push(bands(look, preset, draft, marks)),
75− )
76+ let editor = match in_use(buds) {
77+ 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+ }
84+ };
85+ column![title, ui::card(look, rows), new_preset(look, marks), editor]
86+ .spacing(18)
7687 .into()
7788 }
7889
@@ -129,6 +140,13 @@ fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks:
129140 marks.focus == Some(Item::Rename(id)),
130141 Message::RenamePreset(id),
131142 ))
143+ .push(ui::pill_with(
144+ look,
145+ "Delete",
146+ false,
147+ marks.focus == Some(Item::Delete(id)),
148+ Message::DeletePreset(id),
149+ ))
132150 .spacing(10)
133151 .align_y(Alignment::Center);
134152 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
@@ -203,12 +221,17 @@ mod tests {
203221 #[test]
204222 fn items_follow_the_page() {
205223 let items = items(&buds::demo());
206− assert_eq!(items.len(), 5 + 1 + 1 + 6);
224+ assert_eq!(items.len(), 5 + 1 + 2 + 6);
207225 assert_eq!(items.first(), Some(&Item::Preset(0)));
208226 assert_eq!(items.get(5), Some(&Item::NewPreset));
209227 assert_eq!(items.get(6), Some(&Item::Rename(4)));
210− assert_eq!(items.get(7), Some(&Item::Band(4, 50)));
228+ assert_eq!(items.get(7), Some(&Item::Delete(4)));
229+ assert_eq!(items.get(8), Some(&Item::Band(4, 50)));
211230 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));
212235 assert!(super::items(&buds::unknown()).is_empty());
213236 }
214237
M crates/budctl-gui/src/main.rs View
@@ -198,6 +198,8 @@ 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.
202+ Delete(u8),
201203 }
202204
203205 /// The dialog that asks for a preset's name.
@@ -297,6 +299,10 @@ enum Message {
297299 NameInput(String),
298300 SubmitName,
299301 CancelName,
302+ /// Delete a custom preset (by its id), after a confirmation.
303+ DeletePreset(u8),
304+ /// The answer to "Delete the preset?".
305+ ConfirmDelete(bool),
300306 ShowHelp,
301307 /// Read the buds again.
302308 Refresh,
@@ -348,6 +354,8 @@ struct App {
348354 palette_problem: Option<String>,
349355 /// The preset-name dialog, when it is open.
350356 prompt: Option<NamePrompt>,
357+ /// "Delete the preset?" is open, for this preset.
358+ confirm_delete: Option<u8>,
351359 }
352360
353361 impl App {
@@ -386,6 +394,7 @@ impl App {
386394 confirm_find: false,
387395 palette_problem: None,
388396 prompt: None,
397+ confirm_delete: None,
389398 };
390399 app.refresh_palette();
391400 app
@@ -441,6 +450,16 @@ impl App {
441450 }
442451 Message::SubmitName => self.submit_name(),
443452 Message::CancelName => self.prompt = None,
453+ Message::DeletePreset(id) => self.confirm_delete = Some(id),
454+ Message::ConfirmDelete(yes) => {
455+ if let (true, Some(id)) = (yes, self.confirm_delete.take())
456+ && let Some(preset) = self.buds.preset(id)
457+ {
458+ let command = Command::DeleteEq(buds::key(preset));
459+ self.write(Item::Preset(id), command);
460+ }
461+ self.confirm_delete = None;
462+ }
444463 Message::Refresh => self.refresh(),
445464 Message::Worker(event) => self.answer(event),
446465 Message::Focused => {
@@ -520,6 +539,16 @@ impl App {
520539 });
521540 }
522541 }
542+ Command::DeleteEq(_) => {
543+ let id = item_preset(item);
544+ if let Some(eq) = &mut self.buds.eq {
545+ eq.presets.retain(|p| !(p.custom && p.id == id));
546+ }
547+ if self.buds.status.eq.as_deref() == Some(&format!("custom-{id}")) {
548+ self.buds.set_eq("pure-raw".to_owned());
549+ }
550+ self.draft = None;
551+ }
523552 Command::RenameEq(_, name) => {
524553 if let Some(preset) = self.buds.preset(item_preset(item)) {
525554 let preset = EqPreset { name, ..preset.clone() };
@@ -893,6 +922,11 @@ impl App {
893922 }
894923
895924 fn key(&mut self, key: &Key, modifiers: Modifiers) -> Task<Message> {
925+ if self.confirm_delete.is_some() {
926+ // Only Enter deletes; any other key says no.
927+ let yes = matches!(key, Key::Named(keyboard::key::Named::Enter));
928+ return self.update(Message::ConfirmDelete(yes));
929+ }
896930 if self.prompt.is_some() {
897931 // The name field takes the keys, and Enter in it; only Esc is ours.
898932 if matches!(key, Key::Named(keyboard::key::Named::Escape)) {
@@ -1058,6 +1092,7 @@ impl App {
10581092 Item::Find => (action != Action::Left).then_some(Message::Find),
10591093 Item::NewPreset => (action != Action::Left).then_some(Message::NewPreset),
10601094 Item::Rename(id) => (action != Action::Left).then_some(Message::RenamePreset(id)),
1095+ Item::Delete(id) => (action != Action::Left).then_some(Message::DeletePreset(id)),
10611096 Item::Bass => {
10621097 let (low, high) = self.buds.bass_range?;
10631098 let now = self.bass_draft.or(self.buds.bass);
@@ -1207,6 +1242,8 @@ impl App {
12071242 stack![window, self.confirm_find()].into()
12081243 } else if let Some(prompt) = &self.prompt {
12091244 stack![window, self.name_prompt(prompt)].into()
1245+ } else if let Some(id) = self.confirm_delete {
1246+ stack![window, self.confirm_delete(id)].into()
12101247 } else {
12111248 window.into()
12121249 }
@@ -1357,6 +1394,37 @@ impl App {
13571394 opaque(backdrop)
13581395 }
13591396
1397+ /// "Delete the preset?", over a dimmed window: its bands are lost.
1398+ fn confirm_delete(&self, id: u8) -> Element<'_, Message> {
1399+ let look = self.look;
1400+ let p = look.p;
1401+ let name = self
1402+ .buds
1403+ .preset(id)
1404+ .map_or("this preset", |preset| preset.name.as_str());
1405+ let words = column![
1406+ text(format!("Delete {name}?")).size(22).font(look.bold()).color(p.fg),
1407+ text("Its bands are lost. The buds keep their other presets.")
1408+ .size(14)
1409+ .color(p.dim),
1410+ ]
1411+ .spacing(8);
1412+ let buttons = row![
1413+ space::horizontal(),
1414+ ui::pill(look, "Cancel", false, Message::ConfirmDelete(false)),
1415+ ui::pill(look, "Delete", true, Message::ConfirmDelete(true)),
1416+ ]
1417+ .spacing(10);
1418+ let dialog = ui::panel(look, column![words, buttons].spacing(22).into());
1419+ let backdrop = container(container(dialog).width(460))
1420+ .center(Length::Fill)
1421+ .style(|_| container::Style {
1422+ background: Some(Color { a: 0.6, ..Color::BLACK }.into()),
1423+ ..container::Style::default()
1424+ });
1425+ opaque(backdrop)
1426+ }
1427+
13601428 /// The dialog that asks for a preset's name, over a dimmed window.
13611429 fn name_prompt<'a>(&'a self, prompt: &'a NamePrompt) -> Element<'a, Message> {
13621430 let look = self.look;
@@ -1787,11 +1855,12 @@ mod tests {
17871855 press(&mut app, "j l");
17881856 assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
17891857 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"));
17901861 press(&mut app, "G");
17911862 assert_eq!(app.focus(), Some(Item::Band(4, 16000)));
1792− // Moving a band saves the custom preset, which also selects it.
17931863 press(&mut app, "l");
1794− assert_eq!(app.buds.status.eq.as_deref(), Some("custom-4"));
17951864 let last = |app: &App| app.buds.preset(4).unwrap().bands.last().unwrap().gain;
17961865 assert_eq!(last(&app), 3);
17971866 press(&mut app, "l l l l l");
@@ -2022,6 +2091,36 @@ mod tests {
20222091 ));
20232092 }
20242093
2094+ #[test]
2095+ fn presets_are_deleted_after_a_yes() {
2096+ let mut app = app();
2097+ let _ = app.update(Message::DeletePreset(4));
2098+ assert_eq!(app.confirm_delete, Some(4));
2099+ press(&mut app, "j");
2100+ assert_eq!(app.confirm_delete, None, "any other key says no");
2101+ assert!(app.buds.preset(4).is_some());
2102+ let _ = app.update(Message::DeletePreset(4));
2103+ press(&mut app, "Enter");
2104+ assert!(app.buds.preset(4).is_none());
2105+ assert_eq!(app.buds.status.eq.as_deref(), Some("pure-raw"));
2106+
2107+ let (mut app, commands) = live();
2108+ let _ = commands.try_recv();
2109+ let _ = app.update(Message::Worker(Event::Read(Box::new(reading()))));
2110+ let _ = app.update(Message::DeletePreset(4));
2111+ let _ = app.update(Message::ConfirmDelete(true));
2112+ assert!(matches!(commands.try_recv(), Ok(Command::DeleteEq(preset)) if preset == "custom-4"));
2113+ assert_eq!(app.pending(), [Item::Preset(4)]);
2114+ }
2115+
2116+ #[test]
2117+ fn only_the_preset_in_use_shows_its_bands() {
2118+ 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(..))));
2122+ }
2123+
20252124 #[test]
20262125 fn steps() {
20272126 let abc = ['a', 'b', 'c'];
@@ -2086,6 +2185,10 @@ mod tests {
20862185 let _ = naming.update(Message::NameInput("Custom1 ".to_owned()));
20872186 let _ = naming.update(Message::SubmitName);
20882187 shots.push(("name-prompt".to_owned(), 800.0, naming));
2188+ shots.push(("equalizer-builtin".to_owned(), 1000.0, on(Page::Equalizer, "j l")));
2189+ let mut deleting = on(Page::Equalizer, "");
2190+ let _ = deleting.update(Message::DeletePreset(4));
2191+ shots.push(("delete-confirm".to_owned(), 800.0, deleting));
20892192 let mut connecting = App::new(palette.clone(), font, None, false);
20902193 connecting.link = Link::Connecting;
20912194 shots.push(("connecting".to_owned(), 800.0, connecting));
M crates/budctl-gui/src/worker.rs View
@@ -52,6 +52,8 @@ pub enum Command {
5252 CreateEq(String),
5353 /// Give a custom EQ preset (`custom-<id>`) a new name.
5454 RenameEq(String, String),
55+ /// Delete a custom EQ preset (`custom-<id>`).
56+ DeleteEq(String),
5557 }
5658
5759 #[derive(Debug, Clone)]
@@ -78,7 +80,8 @@ pub enum Event {
7880 Ringing(bool),
7981 /// The answer to [`Command::Bass`]: the level the buds took.
8082 Bass(i8),
81− /// The answer to [`Command::CreateEq`]: the whole preset list after it (ids can change).
83+ /// The answer to [`Command::CreateEq`] and [`Command::DeleteEq`]: the whole preset list after
84+ /// it (ids can change).
8285 Presets(EqPresets),
8386 /// The answer to a command that failed. The session is closed; the next command opens a
8487 /// new one.
@@ -226,6 +229,13 @@ impl Worker {
226229 session.eq_presets()
227230 })
228231 .map(Event::Presets),
232+ Command::DeleteEq(preset) => self
233+ .session()
234+ .and_then(|session| {
235+ session.delete_custom_eq(&preset)?;
236+ session.eq_presets()
237+ })
238+ .map(Event::Presets),
229239 Command::RenameEq(preset, name) => self
230240 .session()
231241 .and_then(|session| session.rename_custom_eq(&preset, &name))