Krolyxon
Commit 4276e2b

Branches

Refs →

budctlcratesbudctl-guisrcequalizer.rs

298 lines · 9.8 KB · Rust

1//! The Equalizer page: the presets, and the bands of the custom ones.
2
3use budctl_core::status::{EqBand, EqPreset};
4use iced::widget::{Row, column, container, row, slider, space, text, vertical_slider};
5use iced::{Alignment, Border, Element, Length};
6
7use crate::buds::{self, Buds};
8use crate::icons::{self, Icon};
9use crate::theme::Look;
10use crate::ui;
11use crate::{Draft, Item, Marks, Message};
12
13/// Shown instead of a subtitle while a write is on its way.
14const SAVING: &str = "Saving…";
15
16/// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them: each preset,
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.
19pub fn items(buds: &Buds, editing: Option<u8>) -> Vec<Item> {
20 let mut items = Vec::new();
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)]);
28 }
29 }
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 }
35 items
36}
37
38/// The custom preset in use, if one is.
39pub fn in_use(buds: &Buds) -> Option<&EqPreset> {
40 let eq = buds.eq.as_ref()?;
41 let current = eq.current.as_deref()?;
42 eq.presets.iter().find(|p| p.custom && buds::key(p) == current)
43}
44
45/// The custom preset whose bands are open, if any.
46pub 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.
52pub 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;
60 let title = ui::title(look, "Equalizer");
61 let Some(eq) = &buds.eq else {
62 let none = text("These buds did not list their presets.").size(14).color(p.dim);
63 return column![title, none].spacing(12).into();
64 };
65 let current = eq.current.as_deref();
66 let rows = eq
67 .presets
68 .iter()
69 .map(|preset| {
70 let id = preset.id;
71 let chosen = current == Some(buds::key(preset).as_str());
72 let subtitle = if marks.pending.contains(&Item::Preset(id)) {
73 SAVING
74 } else if preset.custom && editing == Some(id) {
75 "Your preset, open below"
76 } else if preset.custom {
77 "Your preset"
78 } else {
79 "Built in"
80 };
81 let mark: Element<'a, Message> = if chosen {
82 icons::icon(Icon::Check, 20.0, p.icon)
83 } else {
84 space().width(20).into()
85 };
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 };
108 ui::setting(
109 look,
110 Icon::Equalizer,
111 buds::shown_name(preset),
112 Some(subtitle.to_owned()),
113 trailing,
114 Some(Message::Preset(id)),
115 marks.focus == Some(Item::Preset(id)),
116 )
117 })
118 .collect();
119 let editor: Element<'a, Message> = match edited(buds, editing) {
120 Some(preset) => bands(look, preset, draft, marks),
121 None => text("Use the pencil on one of your presets to change its bands.")
122 .size(13)
123 .color(p.dim)
124 .into(),
125 };
126 column![title, ui::card(look, rows), new_preset(look, marks), editor]
127 .spacing(18)
128 .into()
129}
130
131/// The button that creates a custom preset, and what it is doing.
132fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> {
133 let creating = marks
134 .pending
135 .contains(&Item::NewPreset)
136 .then(|| text("Creating…").size(13).color(look.p.icon));
137 let about = text("A new preset starts flat, with the bands of your other presets, and is put to use.")
138 .size(13)
139 .color(look.p.dim)
140 .width(Length::Fill);
141 row![about]
142 .push(creating)
143 .push(ui::pill_with(
144 look,
145 "New preset",
146 false,
147 marks.focus == Some(Item::NewPreset),
148 Message::NewPreset,
149 ))
150 .spacing(14)
151 .align_y(Alignment::Center)
152 .padding([0, 8])
153 .into()
154}
155
156/// A custom preset's bands, one slider each.
157fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
158 let p = look.p;
159 let id = preset.id;
160 let gains: &[EqBand] = match draft {
161 Some(draft) if draft.preset == id => &draft.bands,
162 _ => &preset.bands,
163 };
164 let (low, high) = preset.range.unwrap_or((-6, 6));
165 let saving = marks
166 .pending
167 .contains(&Item::Preset(id))
168 .then(|| text(SAVING).size(13).color(p.icon));
169 let heading = row![
170 text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg),
171 text(format!("{low:+} to {high:+} dB"))
172 .size(13)
173 .color(p.dim)
174 .width(Length::Fill)
175 ]
176 .push(saving)
177 .push(ui::pill_with(
178 look,
179 "Rename",
180 false,
181 marks.focus == Some(Item::Rename(id)),
182 Message::RenamePreset(id),
183 ))
184 .push(ui::pill_with(
185 look,
186 "Done",
187 false,
188 marks.focus == Some(Item::Done),
189 Message::DoneEditing,
190 ))
191 .spacing(10)
192 .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);
196 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
197 let focused = marks.focus == Some(Item::Band(id, band.hz));
198 let hz = band.hz;
199 let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| {
200 Message::Band {
201 preset: id,
202 hz,
203 gain: i8::try_from(gain).unwrap_or(0),
204 save: false,
205 }
206 })
207 .on_release(Message::SaveBands(id))
208 .height(170)
209 .style(move |_, _| slider::Style {
210 rail: slider::Rail {
211 backgrounds: (p.accent.into(), p.surface_highest.into()),
212 width: 6.0,
213 border: Border::default().rounded(3),
214 },
215 handle: slider::Handle {
216 shape: slider::HandleShape::Circle { radius: 9.0 },
217 background: p.fg.into(),
218 border_width: 2.0,
219 border_color: p.accent,
220 },
221 });
222 let band = column![
223 text(gain(band.gain)).size(14).font(look.medium()).color(p.fg),
224 slider,
225 text(frequency(hz)).size(12).color(p.dim)
226 ]
227 .spacing(10)
228 .align_x(Alignment::Center);
229 sliders.push(
230 container(band)
231 .center_x(Length::Fill)
232 .padding([12, 0])
233 .style(move |_| container::Style {
234 background: focused.then_some(p.surface_high.into()),
235 border: Border {
236 color: p.accent,
237 width: if focused { 2.0 } else { 0.0 },
238 radius: 12.0.into(),
239 },
240 ..container::Style::default()
241 }),
242 )
243 });
244 ui::panel(look, column![heading, note, sliders].spacing(14).into())
245}
246
247/// `3` → "+3", `0` → "0", `-2` → "-2".
248pub fn gain(db: i8) -> String {
249 if db == 0 { "0".to_owned() } else { format!("{db:+}") }
250}
251
252/// `50` → "50 Hz", `16000` → "16 kHz".
253fn frequency(hz: u16) -> String {
254 if hz >= 1000 && hz.is_multiple_of(1000) {
255 format!("{} kHz", hz / 1000)
256 } else {
257 format!("{hz} Hz")
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn items_follow_the_page() {
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());
282 }
283
284 #[test]
285 fn gains() {
286 assert_eq!(gain(3), "+3");
287 assert_eq!(gain(0), "0");
288 assert_eq!(gain(-6), "-6");
289 }
290
291 #[test]
292 fn frequencies() {
293 assert_eq!(frequency(50), "50 Hz");
294 assert_eq!(frequency(1000), "1 kHz");
295 assert_eq!(frequency(16000), "16 kHz");
296 assert_eq!(frequency(62), "62 Hz");
297 }
298}