Krolyxon
Commit 9500e1e

Branches

Refs →

budctlcratesbudctl-guisrcdots.rs

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