Krolyxon
Commit 449c3e4

Branches

Refs →

budctlcratesbudctl-guisrcequalizer.rs

252 lines · 8.4 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/// "New preset", then, when a custom preset is in use, "Rename", "Delete" and its bands.
18pub fn items(buds: &Buds) -> Vec<Item> {
19 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)));
26 }
27 }
28 items
29}
30
31/// The custom preset in use, if one is: only its bands can be changed.
32pub 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
38/// `draft`: bands moved on the screen and not saved on the buds yet.
39pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
40 let title = ui::title(look, "Equalizer");
41 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);
45 return column![title, none].spacing(12).into();
46 };
47 let current = eq.current.as_deref();
48 let rows = eq
49 .presets
50 .iter()
51 .map(|preset| {
52 let chosen = current == Some(buds::key(preset).as_str());
53 let subtitle = if marks.pending.contains(&Item::Preset(preset.id)) {
54 SAVING
55 } else if preset.custom {
56 "Your preset"
57 } else {
58 "Built in"
59 };
60 let mark: Element<'a, Message> = if chosen {
61 icons::icon(Icon::Check, 20.0, look.p.icon)
62 } else {
63 space().width(20).into()
64 };
65 ui::setting(
66 look,
67 Icon::Equalizer,
68 buds::shown_name(preset),
69 Some(subtitle.to_owned()),
70 mark,
71 Some(Message::Preset(preset.id)),
72 marks.focus == Some(Item::Preset(preset.id)),
73 )
74 })
75 .collect();
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)
87 .into()
88}
89
90/// The button that creates a custom preset, and what it is doing.
91fn new_preset<'a>(look: Look, marks: Marks) -> Element<'a, Message> {
92 let creating = marks
93 .pending
94 .contains(&Item::NewPreset)
95 .then(|| text("Creating…").size(13).color(look.p.icon));
96 let about = text("A new preset starts flat, with the bands of your other presets, and is put to use.")
97 .size(13)
98 .color(look.p.dim)
99 .width(Length::Fill);
100 row![about]
101 .push(creating)
102 .push(ui::pill_with(
103 look,
104 "New preset",
105 false,
106 marks.focus == Some(Item::NewPreset),
107 Message::NewPreset,
108 ))
109 .spacing(14)
110 .align_y(Alignment::Center)
111 .padding([0, 8])
112 .into()
113}
114
115/// A custom preset's bands, one slider each.
116fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
117 let p = look.p;
118 let id = preset.id;
119 let gains: &[EqBand] = match draft {
120 Some(draft) if draft.preset == id => &draft.bands,
121 _ => &preset.bands,
122 };
123 let (low, high) = preset.range.unwrap_or((-6, 6));
124 let saving = marks
125 .pending
126 .contains(&Item::Preset(id))
127 .then(|| text(SAVING).size(13).color(p.icon));
128 let heading = row![
129 text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg),
130 text(format!("{low:+} to {high:+} dB"))
131 .size(13)
132 .color(p.dim)
133 .width(Length::Fill)
134 ]
135 .push(saving)
136 .push(ui::pill_with(
137 look,
138 "Rename",
139 false,
140 marks.focus == Some(Item::Rename(id)),
141 Message::RenamePreset(id),
142 ))
143 .push(ui::pill_with(
144 look,
145 "Delete",
146 false,
147 marks.focus == Some(Item::Delete(id)),
148 Message::DeletePreset(id),
149 ))
150 .spacing(10)
151 .align_y(Alignment::Center);
152 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
153 let focused = marks.focus == Some(Item::Band(id, band.hz));
154 let hz = band.hz;
155 let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| {
156 Message::Band {
157 preset: id,
158 hz,
159 gain: i8::try_from(gain).unwrap_or(0),
160 save: false,
161 }
162 })
163 .on_release(Message::SaveBands(id))
164 .height(170)
165 .style(move |_, _| slider::Style {
166 rail: slider::Rail {
167 backgrounds: (p.accent.into(), p.surface_highest.into()),
168 width: 6.0,
169 border: Border::default().rounded(3),
170 },
171 handle: slider::Handle {
172 shape: slider::HandleShape::Circle { radius: 9.0 },
173 background: p.fg.into(),
174 border_width: 2.0,
175 border_color: p.accent,
176 },
177 });
178 let band = column![
179 text(gain(band.gain)).size(14).font(look.medium()).color(p.fg),
180 slider,
181 text(frequency(hz)).size(12).color(p.dim)
182 ]
183 .spacing(10)
184 .align_x(Alignment::Center);
185 sliders.push(
186 container(band)
187 .center_x(Length::Fill)
188 .padding([12, 0])
189 .style(move |_| container::Style {
190 background: focused.then_some(p.surface_high.into()),
191 border: Border {
192 color: p.accent,
193 width: if focused { 2.0 } else { 0.0 },
194 radius: 12.0.into(),
195 },
196 ..container::Style::default()
197 }),
198 )
199 });
200 ui::panel(look, column![heading, sliders].spacing(16).into())
201}
202
203/// `3` → "+3", `0` → "0", `-2` → "-2".
204pub fn gain(db: i8) -> String {
205 if db == 0 { "0".to_owned() } else { format!("{db:+}") }
206}
207
208/// `50` → "50 Hz", `16000` → "16 kHz".
209fn frequency(hz: u16) -> String {
210 if hz >= 1000 && hz.is_multiple_of(1000) {
211 format!("{} kHz", hz / 1000)
212 } else {
213 format!("{hz} Hz")
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 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());
236 }
237
238 #[test]
239 fn gains() {
240 assert_eq!(gain(3), "+3");
241 assert_eq!(gain(0), "0");
242 assert_eq!(gain(-6), "-6");
243 }
244
245 #[test]
246 fn frequencies() {
247 assert_eq!(frequency(50), "50 Hz");
248 assert_eq!(frequency(1000), "1 kHz");
249 assert_eq!(frequency(16000), "16 kHz");
250 assert_eq!(frequency(62), "62 Hz");
251 }
252}