Krolyxon
Commit e8c78b9

Branches

Refs →

budctlcratesbudctl-guisrcequalizer.rs

192 lines · 6.3 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/// then each band of each custom preset.
18pub fn items(buds: &Buds) -> Vec<Item> {
19 let Some(eq) = &buds.eq else {
20 return Vec::new();
21 };
22 let mut items: Vec<Item> = eq.presets.iter().map(|p| Item::Preset(p.id)).collect();
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 items
27}
28
29/// `draft`: bands moved on the screen and not saved on the buds yet.
30pub fn view<'a>(look: Look, buds: &'a Buds, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
31 let title = ui::title(look, "Equalizer");
32 let Some(eq) = &buds.eq else {
33 let none = text("These buds did not list their presets.")
34 .size(14)
35 .color(look.p.dim);
36 return column![title, none].spacing(12).into();
37 };
38 let current = eq.current.as_deref();
39 let rows = eq
40 .presets
41 .iter()
42 .map(|preset| {
43 let chosen = current == Some(buds::key(preset).as_str());
44 let subtitle = if marks.pending.contains(&Item::Preset(preset.id)) {
45 SAVING
46 } else if preset.custom {
47 "Your preset"
48 } else {
49 "Built in"
50 };
51 let mark: Element<'a, Message> = if chosen {
52 icons::icon(Icon::Check, 20.0, look.p.icon)
53 } else {
54 space().width(20).into()
55 };
56 ui::setting(
57 look,
58 Icon::Equalizer,
59 buds::shown_name(preset),
60 Some(subtitle.to_owned()),
61 mark,
62 Message::Preset(preset.id),
63 marks.focus == Some(Item::Preset(preset.id)),
64 )
65 })
66 .collect();
67 eq.presets
68 .iter()
69 .filter(|p| p.custom)
70 .fold(column![title, ui::card(look, rows)].spacing(18), |page, preset| {
71 page.push(bands(look, preset, draft, marks))
72 })
73 .into()
74}
75
76/// A custom preset's bands, one slider each.
77fn bands<'a>(look: Look, preset: &'a EqPreset, draft: Option<&'a Draft>, marks: Marks) -> Element<'a, Message> {
78 let p = look.p;
79 let id = preset.id;
80 let gains: &[EqBand] = match draft {
81 Some(draft) if draft.preset == id => &draft.bands,
82 _ => &preset.bands,
83 };
84 let (low, high) = preset.range.unwrap_or((-6, 6));
85 let saving = marks
86 .pending
87 .contains(&Item::Preset(id))
88 .then(|| text(SAVING).size(13).color(p.icon));
89 let heading = row![
90 text(preset.name.as_str()).size(16).font(look.medium()).color(p.fg),
91 text(format!("{low:+} to {high:+} dB"))
92 .size(13)
93 .color(p.dim)
94 .width(Length::Fill)
95 ]
96 .push(saving)
97 .spacing(10)
98 .align_y(Alignment::Center);
99 let sliders = gains.iter().fold(Row::new().spacing(6), |sliders, &band| {
100 let focused = marks.focus == Some(Item::Band(id, band.hz));
101 let hz = band.hz;
102 let slider = vertical_slider(i16::from(low)..=i16::from(high), i16::from(band.gain), move |gain| {
103 Message::Band {
104 preset: id,
105 hz,
106 gain: i8::try_from(gain).unwrap_or(0),
107 save: false,
108 }
109 })
110 .on_release(Message::SaveBands(id))
111 .height(170)
112 .style(move |_, _| slider::Style {
113 rail: slider::Rail {
114 backgrounds: (p.accent.into(), p.surface_highest.into()),
115 width: 6.0,
116 border: Border::default().rounded(3),
117 },
118 handle: slider::Handle {
119 shape: slider::HandleShape::Circle { radius: 9.0 },
120 background: p.fg.into(),
121 border_width: 2.0,
122 border_color: p.accent,
123 },
124 });
125 let band = column![
126 text(gain(band.gain)).size(14).font(look.medium()).color(p.fg),
127 slider,
128 text(frequency(hz)).size(12).color(p.dim)
129 ]
130 .spacing(10)
131 .align_x(Alignment::Center);
132 sliders.push(
133 container(band)
134 .center_x(Length::Fill)
135 .padding([12, 0])
136 .style(move |_| container::Style {
137 background: focused.then_some(p.surface_high.into()),
138 border: Border {
139 color: p.accent,
140 width: if focused { 2.0 } else { 0.0 },
141 radius: 12.0.into(),
142 },
143 ..container::Style::default()
144 }),
145 )
146 });
147 ui::panel(look, column![heading, sliders].spacing(16).into())
148}
149
150/// `3` → "+3", `0` → "0", `-2` → "-2".
151fn gain(db: i8) -> String {
152 if db == 0 { "0".to_owned() } else { format!("{db:+}") }
153}
154
155/// `50` → "50 Hz", `16000` → "16 kHz".
156fn frequency(hz: u16) -> String {
157 if hz >= 1000 && hz.is_multiple_of(1000) {
158 format!("{} kHz", hz / 1000)
159 } else {
160 format!("{hz} Hz")
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn items_follow_the_page() {
170 let items = items(&buds::demo());
171 assert_eq!(items.len(), 5 + 6);
172 assert_eq!(items.first(), Some(&Item::Preset(0)));
173 assert_eq!(items.get(5), Some(&Item::Band(4, 50)));
174 assert_eq!(items.last(), Some(&Item::Band(4, 16000)));
175 assert!(super::items(&buds::unknown()).is_empty());
176 }
177
178 #[test]
179 fn gains() {
180 assert_eq!(gain(3), "+3");
181 assert_eq!(gain(0), "0");
182 assert_eq!(gain(-6), "-6");
183 }
184
185 #[test]
186 fn frequencies() {
187 assert_eq!(frequency(50), "50 Hz");
188 assert_eq!(frequency(1000), "1 kHz");
189 assert_eq!(frequency(16000), "16 kHz");
190 assert_eq!(frequency(62), "62 Hz");
191 }
192}