Krolyxon
Commit 7c15b41

Branches

Refs →

budctlcratesbudctl-guisrcdots.rs

261 lines · 8.5 KB · Rust

1//! LED dots, like the Glyph lights: battery rings, and the sliders for the bass level and the EQ
2//! bands. Drawn on a canvas, so they take any theme color.
3
4use std::f32::consts::{FRAC_PI_2, TAU};
5
6use iced::widget::canvas::{self, Event, Frame, Path};
7use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse};
8
9use crate::Message;
10
11/// A ring of `n` dots, `size` pixels across: the fraction `lit` lights clockwise from the top in
12/// `on`, the rest stay `off`. `None` lights nothing.
13pub fn ring<'a>(lit: Option<f32>, size: f32, on: Color, off: Color) -> Element<'a, Message> {
14 iced::widget::canvas(Ring { lit, on, off })
15 .width(size)
16 .height(size)
17 .into()
18}
19
20struct Ring {
21 lit: Option<f32>,
22 on: Color,
23 off: Color,
24}
25
26impl canvas::Program<Message> for Ring {
27 type State = ();
28
29 fn draw(
30 &self,
31 _state: &(),
32 renderer: &Renderer,
33 _theme: &Theme,
34 bounds: Rectangle,
35 _cursor: mouse::Cursor,
36 ) -> Vec<canvas::Geometry> {
37 const N: u16 = 44;
38 let mut frame = Frame::new(renderer, bounds.size());
39 let center = frame.center();
40 let dot = (bounds.width * 0.045).max(3.0);
41 let radius = bounds.width / 2.0 - dot / 2.0 - 1.0;
42 let lit = self.lit.map_or(0, |f| lit_count(f, N));
43 for n in 0..N {
44 let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2;
45 let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin());
46 let color = if n < lit { self.on } else { self.off };
47 frame.fill(&Path::circle(at, dot / 2.0), color);
48 }
49 vec![frame.into_geometry()]
50 }
51}
52
53/// How many of `n` dots the fraction `f` lights.
54fn lit_count(f: f32, n: u16) -> u16 {
55 let f = f.clamp(0.0, 1.0);
56 // At most n, so the cast keeps the value.
57 #[allow(clippy::cast_possible_truncation)]
58 let lit = (f * f32::from(n)).round() as u16;
59 lit.min(n)
60}
61
62/// A dotted slider over `low..=high`, one dot per step: the dots up to the value are lit, the
63/// value's own dot is larger. With `low < 0`, as for EQ gains, the lit dots run from 0.
64///
65/// A press or a drag sends `change(value, false)`, letting go sends `release`, and the wheel
66/// steps by one with `change(value, true)`.
67pub struct Slider<F> {
68 pub low: i16,
69 pub high: i16,
70 pub value: i16,
71 pub vertical: bool,
72 pub change: F,
73 pub release: Message,
74 pub on: Color,
75 pub off: Color,
76}
77
78impl<F: Fn(i16, bool) -> Message + 'static> Slider<F> {
79 /// The slider, `length` pixels along its axis.
80 pub fn view<'a>(self, length: f32) -> Element<'a, Message> {
81 let vertical = self.vertical;
82 let canvas = iced::widget::canvas(self);
83 if vertical {
84 canvas.width(28).height(length).into()
85 } else {
86 canvas.width(length).height(Length::Fixed(24.0)).into()
87 }
88 }
89}
90
91impl<F> Slider<F> {
92 fn steps(&self) -> u16 {
93 u16::try_from(self.high.saturating_sub(self.low)).unwrap_or(0).max(1)
94 }
95
96 /// Where the dot for `value` sits along the slider, from 0 to 1.
97 fn place(&self, value: i16) -> f32 {
98 f32::from(value.saturating_sub(self.low)) / f32::from(self.steps())
99 }
100
101 /// The value under the cursor at `along` (0 to 1 along the slider).
102 fn value_at(&self, along: f32) -> i16 {
103 let along = along.clamp(0.0, 1.0);
104 // Rounded and within 0..=steps, so it fits.
105 #[allow(clippy::cast_possible_truncation)]
106 let step = (along * f32::from(self.steps())).round() as u16;
107 self.low.saturating_add(i16::try_from(step).unwrap_or(0)).min(self.high)
108 }
109
110 /// 0 to 1 along the slider for a point inside `bounds`: left to right, or bottom to top.
111 fn along(&self, bounds: Rectangle, at: Point) -> f32 {
112 let pad = DOT_BIG;
113 if self.vertical {
114 (bounds.y + bounds.height - pad - at.y) / (bounds.height - 2.0 * pad).max(1.0)
115 } else {
116 (at.x - bounds.x - pad) / (bounds.width - 2.0 * pad).max(1.0)
117 }
118 }
119}
120
121/// The value's dot, and the others.
122const DOT_BIG: f32 = 11.0;
123const DOT: f32 = 5.0;
124
125#[derive(Default)]
126pub struct Drag {
127 held: bool,
128}
129
130impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
131 type State = Drag;
132
133 fn update(
134 &self,
135 state: &mut Drag,
136 event: &Event,
137 bounds: Rectangle,
138 cursor: mouse::Cursor,
139 ) -> Option<canvas::Action<Message>> {
140 let Event::Mouse(event) = event else {
141 return None;
142 };
143 let change = |at: Point| {
144 let value = self.value_at(self.along(bounds, at));
145 canvas::Action::publish((self.change)(value, false)).and_capture()
146 };
147 match event {
148 mouse::Event::ButtonPressed(mouse::Button::Left) => {
149 let at = cursor.position_over(bounds)?;
150 state.held = true;
151 Some(change(at))
152 }
153 mouse::Event::CursorMoved { .. } if state.held => Some(change(cursor.position()?)),
154 mouse::Event::ButtonReleased(mouse::Button::Left) if state.held => {
155 state.held = false;
156 Some(canvas::Action::publish(self.release.clone()).and_capture())
157 }
158 mouse::Event::WheelScrolled { delta } => {
159 cursor.position_over(bounds)?;
160 let y = match delta {
161 mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => *y,
162 };
163 let to = if y > 0.0 {
164 self.value.saturating_add(1).min(self.high)
165 } else if y < 0.0 {
166 self.value.saturating_sub(1).max(self.low)
167 } else {
168 return None;
169 };
170 (to != self.value).then(|| canvas::Action::publish((self.change)(to, true)).and_capture())
171 }
172 _ => None,
173 }
174 }
175
176 fn draw(
177 &self,
178 _state: &Drag,
179 renderer: &Renderer,
180 _theme: &Theme,
181 bounds: Rectangle,
182 _cursor: mouse::Cursor,
183 ) -> Vec<canvas::Geometry> {
184 let mut frame = Frame::new(renderer, bounds.size());
185 let size = bounds.size();
186 let at = |along: f32| {
187 if self.vertical {
188 Point::new(
189 size.width / 2.0,
190 size.height - DOT_BIG - along * (size.height - 2.0 * DOT_BIG),
191 )
192 } else {
193 Point::new(DOT_BIG + along * (size.width - 2.0 * DOT_BIG), size.height / 2.0)
194 }
195 };
196 let base = self.low.max(0).min(self.high);
197 let (from, to) = (base.min(self.value), base.max(self.value));
198 let mut value = self.low;
199 while value <= self.high {
200 let lit = (from..=to).contains(&value);
201 let (diameter, color) = if value == self.value {
202 (DOT_BIG, self.on)
203 } else if lit {
204 (DOT, self.on)
205 } else {
206 (DOT, self.off)
207 };
208 frame.fill(&Path::circle(at(self.place(value)), diameter / 2.0), color);
209 value = match value.checked_add(1) {
210 Some(next) => next,
211 None => break,
212 };
213 }
214 vec![frame.into_geometry()]
215 }
216
217 fn mouse_interaction(&self, state: &Drag, bounds: Rectangle, cursor: mouse::Cursor) -> mouse::Interaction {
218 if state.held || cursor.is_over(bounds) {
219 mouse::Interaction::Pointer
220 } else {
221 mouse::Interaction::default()
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn slider(low: i16, high: i16) -> Slider<fn(i16, bool) -> Message> {
231 Slider {
232 low,
233 high,
234 value: 0,
235 vertical: false,
236 change: |_, _| Message::SaveBass,
237 release: Message::SaveBass,
238 on: Color::WHITE,
239 off: Color::BLACK,
240 }
241 }
242
243 #[test]
244 fn rings_light_their_share() {
245 assert_eq!(lit_count(0.8, 44), 35);
246 assert_eq!(lit_count(1.5, 44), 44);
247 assert_eq!(lit_count(-1.0, 44), 0);
248 }
249
250 #[test]
251 fn sliders_find_the_value_under_the_cursor() {
252 let eq = slider(-6, 6);
253 assert_eq!(eq.value_at(0.0), -6);
254 assert_eq!(eq.value_at(0.5), 0);
255 assert_eq!(eq.value_at(1.0), 6);
256 assert_eq!(eq.value_at(2.0), 6);
257 assert_eq!(eq.place(0), 0.5);
258 // A slider with one value does not divide by zero.
259 assert_eq!(slider(3, 3).value_at(0.7), 3);
260 }
261}