Krolyxon
Commit f7d0d70

Branches

Refs →

budctlcratesbudctl-guisrcequalizer.rs

301 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: Option<Element<'a, Message>> = match edited(buds, editing) {
120 Some(preset) => Some(bands(look, preset, draft, marks)),
121 None if eq.presets.iter().any(|p| p.custom) => Some(
122 text("Use the pencil on one of your presets to change its bands.")
123 .size(13)
124 .color(p.dim)
125 .into(),
126 ),
127 None => None,
128 };
129 column![title, ui::card(look, rows), new_preset(look, marks), editor]
130 .spacing(18)
131 .into()
132}
133
134/// The button that creates a custom preset, and what it is doing.
135fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> {
136 let creating = marks
137 .pending
138 .contains(&Item::NewPreset)
139 .then(|| text("Creating…").size(13).color(look.p.icon));
140 let about = text("A new preset starts flat and is put to use.")
141 .size(13)
142 .color(look.p.dim)
143 .width(Length::Fill);
144 row![about]
145 .push(creating)
146 .push(ui::pill_with(
147 look,
148 "New preset",
149 false,
150 marks.focus == Some(Item::NewPreset),
151 Message::NewPreset,
152 ))
153 .spacing(14)
154 .align_y(Alignment::Center)
155 .padding([0, 8])
156 .into()
157}
158
159/// A custom preset's bands, one slider each.
160fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
161 let p = look.p;
162 let id = preset.id;
163 let gains: &[EqBand] = match draft {
164 Some(draft) if draft.preset == id => &draft.bands,
165 _ => &preset.bands,
166 };
167 let (low, high) = preset.range.unwrap_or((-6, 6));
168 let saving = marks
169 .pending
170 .contains(&Item::Preset(id))
171 .then(|| text(SAVING).size(13).color(p.icon));
172 let heading = row![
173 text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg),
174 text(format!("{low:+} to {high:+} dB"))
175 .size(13)
176 .color(p.dim)
177 .width(Length::Fill)
178 ]
179 .push(saving)
180 .push(ui::pill_with(
181 look,
182 "Rename",
183 false,
184 marks.focus == Some(Item::Rename(id)),
185 Message::RenamePreset(id),
186 ))
187 .push(ui::pill_with(
188 look,
189 "Done",
190 false,
191 marks.focus == Some(Item::Done),
192 Message::DoneEditing,
193 ))
194 .spacing(10)
195 .align_y(Alignment::Center);
196 let note = text("Moving a band saves this preset and puts it in use, so you hear it.")
197 .size(13)
198 .color(p.dim);
199 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
200 let focused = marks.focus == Some(Item::Band(id, band.hz));
201 let hz = band.hz;
202 let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| {
203 Message::Band {
204 preset: id,
205 hz,
206 gain: i8::try_from(gain).unwrap_or(0),
207 save: false,
208 }
209 })
210 .on_release(Message::SaveBands(id))
211 .height(170)
212 .style(move |_, _| slider::Style {
213 rail: slider::Rail {
214 backgrounds: (p.accent.into(), p.surface_highest.into()),
215 width: 6.0,
216 border: Border::default().rounded(3),
217 },
218 handle: slider::Handle {
219 shape: slider::HandleShape::Circle { radius: 9.0 },
220 background: p.fg.into(),
221 border_width: 2.0,
222 border_color: p.accent,
223 },
224 });
225 let band = column![
226 text(gain(band.gain)).size(14).font(look.medium()).color(p.fg),
227 slider,
228 text(frequency(hz)).size(12).color(p.dim)
229 ]
230 .spacing(10)
231 .align_x(Alignment::Center);
232 sliders.push(
233 container(band)
234 .center_x(Length::Fill)
235 .padding([12, 0])
236 .style(move |_| container::Style {
237 background: focused.then_some(p.surface_high.into()),
238 border: Border {
239 color: p.accent,
240 width: if focused { 2.0 } else { 0.0 },
241 radius: 12.0.into(),
242 },
243 ..container::Style::default()
244 }),
245 )
246 });
247 ui::panel(look, column![heading, note, sliders].spacing(14).into())
248}
249
250/// `3` → "+3", `0` → "0", `-2` → "-2".
251pub fn gain(db: i8) -> String {
252 if db == 0 { "0".to_owned() } else { format!("{db:+}") }
253}
254
255/// `50` → "50 Hz", `16000` → "16 kHz".
256fn frequency(hz: u16) -> String {
257 if hz >= 1000 && hz.is_multiple_of(1000) {
258 format!("{} kHz", hz / 1000)
259 } else {
260 format!("{hz} Hz")
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn items_follow_the_page() {
270 let buds = buds::demo();
271 let closed = items(&buds, None);
272 assert_eq!(closed.len(), 4 + 3 + 1);
273 assert_eq!(closed.first(), Some(&Item::Preset(0)));
274 assert_eq!(
275 closed.get(4..),
276 Some(&[Item::Preset(4), Item::Edit(4), Item::Delete(4), Item::NewPreset][..])
277 );
278 let open = items(&buds, Some(4));
279 assert_eq!(open.len(), 8 + 2 + 6);
280 assert_eq!(open.get(8..10), Some(&[Item::Rename(4), Item::Done][..]));
281 assert_eq!(open.last(), Some(&Item::Band(4, 16000)));
282 // A built-in preset has no bands to open.
283 assert_eq!(items(&buds, Some(0)).len(), 8);
284 assert!(items(&buds::unknown(), None).is_empty());
285 }
286
287 #[test]
288 fn gains() {
289 assert_eq!(gain(3), "+3");
290 assert_eq!(gain(0), "0");
291 assert_eq!(gain(-6), "-6");
292 }
293
294 #[test]
295 fn frequencies() {
296 assert_eq!(frequency(50), "50 Hz");
297 assert_eq!(frequency(1000), "1 kHz");
298 assert_eq!(frequency(16000), "16 kHz");
299 assert_eq!(frequency(62), "62 Hz");
300 }
301}