Krolyxon
Commit a3246e9

Branches

Refs →

budctlcratesbudctl-guisrcequalizer.rs

275 lines · 9.0 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::slider::Slider;
5use iced::widget::{Row, column, container, row, slider, space, text, vertical_slider};
6use iced::{Alignment, Border, Element, Length};
7
8use crate::buds::{self, Buds};
9use crate::icons::{self, Icon};
10use crate::theme::Look;
11use crate::ui;
12use crate::{Draft, Item, Marks, Message};
13
14/// Shown instead of a subtitle while a write is on its way.
15const SAVING: &str = "Saving…";
16
17/// What the keyboard cursor can rest on, top to bottom, as [`view`] shows them: each preset,
18/// then each band of each custom preset, then the bass level.
19pub fn items(buds: &Buds) -> Vec<Item> {
20 let mut items = Vec::new();
21 if let Some(eq) = &buds.eq {
22 items.extend(eq.presets.iter().map(|p| Item::Preset(p.id)));
23 for preset in eq.presets.iter().filter(|p| p.custom) {
24 items.extend(preset.bands.iter().map(|band| Item::Band(preset.id, band.hz)));
25 }
26 }
27 if buds.bass_range.is_some() {
28 items.push(Item::Bass);
29 }
30 items
31}
32
33/// `draft`: bands moved on the screen and not saved on the buds yet; `bass`: a bass level
34/// likewise.
35pub fn view<'a>(
36 look: Look,
37 buds: &'a Buds,
38 draft: Option<&'a Draft>,
39 bass: Option<i8>,
40 marks: Marks,
41) -> Element<'a, Message> {
42 let title = ui::title(look, "Equalizer");
43 let bass = buds
44 .bass_range
45 .map(|range| bass_level(look, range, bass.or(buds.bass), marks));
46 let Some(eq) = &buds.eq else {
47 let none = text("These buds did not list their presets.")
48 .size(14)
49 .color(look.p.dim);
50 return column![title, none].push(bass).spacing(12).into();
51 };
52 let current = eq.current.as_deref();
53 let rows = eq
54 .presets
55 .iter()
56 .map(|preset| {
57 let chosen = current == Some(buds::key(preset).as_str());
58 let subtitle = if marks.pending.contains(&Item::Preset(preset.id)) {
59 SAVING
60 } else if preset.custom {
61 "Your preset"
62 } else {
63 "Built in"
64 };
65 let mark: Element<'a, Message> = if chosen {
66 icons::icon(Icon::Check, 20.0, look.p.icon)
67 } else {
68 space().width(20).into()
69 };
70 ui::setting(
71 look,
72 Icon::Equalizer,
73 buds::shown_name(preset),
74 Some(subtitle.to_owned()),
75 mark,
76 Some(Message::Preset(preset.id)),
77 marks.focus == Some(Item::Preset(preset.id)),
78 )
79 })
80 .collect();
81 eq.presets
82 .iter()
83 .filter(|p| p.custom)
84 .fold(column![title, ui::card(look, rows)].spacing(18), |page, preset| {
85 page.push(bands(look, preset, draft, marks))
86 })
87 .push(bass)
88 .into()
89}
90
91/// The bass level: the buds take it but do not report it, so the page shows the last level
92/// set from here, if any.
93fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: Marks) -> Element<'a, Message> {
94 let p = look.p;
95 let focused = marks.focus == Some(Item::Bass);
96 let saving = marks
97 .pending
98 .contains(&Item::Bass)
99 .then(|| text(SAVING).size(13).color(p.icon));
100 let shown = level.map_or_else(|| "Not set".to_owned(), gain);
101 let heading = row![
102 text("Bass level").size(16).font(look.medium()).color(p.fg),
103 text(shown)
104 .size(14)
105 .font(look.medium())
106 .color(p.dim)
107 .width(Length::Fill)
108 ]
109 .push(saving)
110 .spacing(10)
111 .align_y(Alignment::Center);
112 let note = text("The buds do not report their bass level: this is the last one set from here.")
113 .size(13)
114 .color(p.dim);
115 let slider: Slider<'a, i16, Message> = slider(
116 i16::from(low)..=i16::from(high),
117 i16::from(level.unwrap_or(0)),
118 |level| Message::Bass {
119 level: i8::try_from(level).unwrap_or(0),
120 save: false,
121 },
122 )
123 .on_release(Message::SaveBass)
124 .style(move |_, _| slider::Style {
125 rail: slider::Rail {
126 backgrounds: (p.accent.into(), p.surface_highest.into()),
127 width: 6.0,
128 border: Border::default().rounded(3),
129 },
130 handle: slider::Handle {
131 shape: slider::HandleShape::Circle { radius: 9.0 },
132 background: p.fg.into(),
133 border_width: 2.0,
134 border_color: p.accent,
135 },
136 });
137 let scale = row![
138 text(gain(low)).size(12).color(p.dim),
139 space::horizontal(),
140 text("0").size(12).color(p.dim),
141 space::horizontal(),
142 text(gain(high)).size(12).color(p.dim)
143 ];
144 let content = container(column![heading, note, slider, scale].spacing(12))
145 .padding(12)
146 .style(move |_| container::Style {
147 background: focused.then_some(p.surface_high.into()),
148 border: Border {
149 color: p.accent,
150 width: if focused { 2.0 } else { 0.0 },
151 radius: 12.0.into(),
152 },
153 ..container::Style::default()
154 });
155 ui::panel(look, content.into())
156}
157
158/// A custom preset's bands, one slider each.
159fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
160 let p = look.p;
161 let id = preset.id;
162 let gains: &[EqBand] = match draft {
163 Some(draft) if draft.preset == id => &draft.bands,
164 _ => &preset.bands,
165 };
166 let (low, high) = preset.range.unwrap_or((-6, 6));
167 let saving = marks
168 .pending
169 .contains(&Item::Preset(id))
170 .then(|| text(SAVING).size(13).color(p.icon));
171 let heading = row![
172 text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg),
173 text(format!("{low:+} to {high:+} dB"))
174 .size(13)
175 .color(p.dim)
176 .width(Length::Fill)
177 ]
178 .push(saving)
179 .spacing(10)
180 .align_y(Alignment::Center);
181 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
182 let focused = marks.focus == Some(Item::Band(id, band.hz));
183 let hz = band.hz;
184 let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| {
185 Message::Band {
186 preset: id,
187 hz,
188 gain: i8::try_from(gain).unwrap_or(0),
189 save: false,
190 }
191 })
192 .on_release(Message::SaveBands(id))
193 .height(170)
194 .style(move |_, _| slider::Style {
195 rail: slider::Rail {
196 backgrounds: (p.accent.into(), p.surface_highest.into()),
197 width: 6.0,
198 border: Border::default().rounded(3),
199 },
200 handle: slider::Handle {
201 shape: slider::HandleShape::Circle { radius: 9.0 },
202 background: p.fg.into(),
203 border_width: 2.0,
204 border_color: p.accent,
205 },
206 });
207 let band = column![
208 text(gain(band.gain)).size(14).font(look.medium()).color(p.fg),
209 slider,
210 text(frequency(hz)).size(12).color(p.dim)
211 ]
212 .spacing(10)
213 .align_x(Alignment::Center);
214 sliders.push(
215 container(band)
216 .center_x(Length::Fill)
217 .padding([12, 0])
218 .style(move |_| container::Style {
219 background: focused.then_some(p.surface_high.into()),
220 border: Border {
221 color: p.accent,
222 width: if focused { 2.0 } else { 0.0 },
223 radius: 12.0.into(),
224 },
225 ..container::Style::default()
226 }),
227 )
228 });
229 ui::panel(look, column![heading, sliders].spacing(16).into())
230}
231
232/// `3` → "+3", `0` → "0", `-2` → "-2".
233fn gain(db: i8) -> String {
234 if db == 0 { "0".to_owned() } else { format!("{db:+}") }
235}
236
237/// `50` → "50 Hz", `16000` → "16 kHz".
238fn frequency(hz: u16) -> String {
239 if hz >= 1000 && hz.is_multiple_of(1000) {
240 format!("{} kHz", hz / 1000)
241 } else {
242 format!("{hz} Hz")
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn items_follow_the_page() {
252 let items = items(&buds::demo());
253 assert_eq!(items.len(), 5 + 6 + 1);
254 assert_eq!(items.first(), Some(&Item::Preset(0)));
255 assert_eq!(items.get(5), Some(&Item::Band(4, 50)));
256 assert_eq!(items.get(10), Some(&Item::Band(4, 16000)));
257 assert_eq!(items.last(), Some(&Item::Bass));
258 assert!(super::items(&buds::unknown()).is_empty());
259 }
260
261 #[test]
262 fn gains() {
263 assert_eq!(gain(3), "+3");
264 assert_eq!(gain(0), "0");
265 assert_eq!(gain(-6), "-6");
266 }
267
268 #[test]
269 fn frequencies() {
270 assert_eq!(frequency(50), "50 Hz");
271 assert_eq!(frequency(1000), "1 kHz");
272 assert_eq!(frequency(16000), "16 kHz");
273 assert_eq!(frequency(62), "62 Hz");
274 }
275}