Krolyxon
Commit 24133e0

Branches

Refs →

budctlcratesbudctl-guisrcdots.rs

679 lines · 21.9 KB · Rust

1//! LED dots, like the Glyph lights: battery rings, switches, the sliders for the bass level and
2//! the EQ bands, and the dots that blink or scan while something is under way. Drawn on a canvas,
3//! so they take any theme color, and kept between frames ([`Kept`]).
4//!
5//! Each one moves on its own ([`motion`]): it asks for the frames it needs and nothing more, so
6//! the window rests once it has settled.
7
8use std::f32::consts::{FRAC_PI_2, TAU};
9
10use iced::alignment::Vertical;
11use iced::time::{Duration, Instant};
12use iced::widget::canvas::{self, Event, Frame, Path};
13use iced::widget::text;
14use iced::{Color, Element, Font, Length, Point, Rectangle, Renderer, Size, Theme, mouse};
15
16use crate::Message;
17use crate::kept::Kept;
18use crate::motion::{self, Mix, Tween};
19use crate::theme::Look;
20
21/// A battery ring, `size` pixels across: a ring of dots, lit clockwise from the top up to the
22/// `level` (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it
23/// is unknown. The figure is drawn with the dots, so the page needs no layer of its own for it.
24///
25/// The dots light one after the other, like the Glyph lights: when the ring first shows, and
26/// when the level changes. The figure is the level at once.
27pub fn ring<'a>(look: Look, level: Option<u8>, size: f32) -> Element<'a, Message> {
28 let p = look.p;
29 let ring = Ring {
30 level,
31 on: p.fill,
32 off: p.s3,
33 figure: if level.is_some() { p.fg } else { p.mute },
34 font: look.dot(),
35 motion: look.motion,
36 };
37 iced::widget::canvas(ring).width(size).height(size).into()
38}
39
40#[derive(Clone, Copy, PartialEq)]
41struct Ring {
42 level: Option<u8>,
43 on: Color,
44 off: Color,
45 figure: Color,
46 font: Font,
47 motion: bool,
48}
49
50/// The dots in a ring.
51const RING_DOTS: u16 = 44;
52
53impl Ring {
54 /// How many dots the level lights.
55 fn lit(&self) -> u16 {
56 self.level
57 .map_or(0, |level| lit_count(f32::from(level) / 100.0, RING_DOTS))
58 }
59}
60
61/// A ring's dots on their way to the level.
62#[derive(Default)]
63pub struct Sweep {
64 kept: Kept<(u16, Ring)>,
65 /// How many dots are lit, as a number that moves; `None` before the first frame.
66 lit: Option<Tween<f32>>,
67 now: Option<Instant>,
68}
69
70impl Sweep {
71 /// The dots lit now: `ring`'s own when nothing has moved yet.
72 fn lit(&self, ring: &Ring) -> u16 {
73 match (self.lit, self.now) {
74 (Some(lit), Some(now)) => lit_count(lit.at(now) / f32::from(RING_DOTS), RING_DOTS),
75 _ => ring.lit(),
76 }
77 }
78}
79
80impl canvas::Program<Message> for Ring {
81 type State = Sweep;
82
83 fn update(
84 &self,
85 state: &mut Sweep,
86 event: &Event,
87 _bounds: Rectangle,
88 _cursor: mouse::Cursor,
89 ) -> Option<canvas::Action<Message>> {
90 let now = motion::redraw(event)?;
91 state.now = Some(now);
92 // First seen, the ring starts dark and sweeps up to the level.
93 let lit = state.lit.get_or_insert_with(|| Tween::at_rest(0.0));
94 lit.aim(f32::from(self.lit()), now, motion::SWEEP, self.motion);
95 lit.running(now).then(canvas::Action::request_redraw)
96 }
97
98 fn draw(
99 &self,
100 state: &Sweep,
101 renderer: &Renderer,
102 _theme: &Theme,
103 bounds: Rectangle,
104 _cursor: mouse::Cursor,
105 ) -> Vec<canvas::Geometry> {
106 const N: u16 = RING_DOTS;
107 let lit = state.lit(self);
108 let geometry = state.kept.draw(renderer, bounds.size(), (lit, *self), |frame| {
109 let center = frame.center();
110 let dot = (bounds.width * 0.045).max(3.0);
111 let radius = bounds.width / 2.0 - dot / 2.0 - 1.0;
112 for n in 0..N {
113 let angle = f32::from(n) / f32::from(N) * TAU - FRAC_PI_2;
114 let at = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin());
115 let color = if n < lit { self.on } else { self.off };
116 frame.fill(&Path::circle(at, dot / 2.0), color);
117 }
118 frame.fill_text(canvas::Text {
119 content: self.level.map_or_else(|| "--".to_owned(), |level| level.to_string()),
120 position: center,
121 color: self.figure,
122 size: FIGURE.into(),
123 font: self.font,
124 align_x: text::Alignment::Center,
125 align_y: Vertical::Center,
126 ..canvas::Text::default()
127 });
128 });
129 vec![geometry]
130 }
131}
132
133/// The size of a battery ring's figure. Doto is never set under 26 px.
134const FIGURE: f32 = 44.0;
135
136/// How many of `n` dots the fraction `f` lights.
137fn lit_count(f: f32, n: u16) -> u16 {
138 let f = f.clamp(0.0, 1.0);
139 // At most n, so the cast keeps the value.
140 #[allow(clippy::cast_possible_truncation)]
141 let lit = (f * f32::from(n)).round() as u16;
142 lit.min(n)
143}
144
145/// A dotted slider over `low..=high`, one dot per step: the dots up to the value are lit, the
146/// value's own dot is larger. With `low < 0`, as for EQ gains, the lit dots run from 0.
147///
148/// A press or a drag sends `change(value, false)`, letting go sends `release`, and the wheel
149/// steps by one with `change(value, true)`.
150pub struct Slider<F> {
151 pub low: i16,
152 pub high: i16,
153 pub value: i16,
154 pub vertical: bool,
155 pub change: F,
156 pub release: Message,
157 pub on: Color,
158 pub off: Color,
159 pub motion: bool,
160}
161
162impl<F: Fn(i16, bool) -> Message + 'static> Slider<F> {
163 /// The slider, `length` pixels along its axis.
164 pub fn view<'a>(self, length: f32) -> Element<'a, Message> {
165 let vertical = self.vertical;
166 let canvas = iced::widget::canvas(self);
167 if vertical {
168 canvas.width(28).height(length).into()
169 } else {
170 canvas.width(length).height(Length::Fixed(24.0)).into()
171 }
172 }
173}
174
175impl<F> Slider<F> {
176 fn steps(&self) -> u16 {
177 u16::try_from(self.high.saturating_sub(self.low)).unwrap_or(0).max(1)
178 }
179
180 /// Where the dot for `value` sits along the slider, from 0 to 1.
181 fn place(&self, value: i16) -> f32 {
182 f32::from(value.saturating_sub(self.low)) / f32::from(self.steps())
183 }
184
185 /// The value under the cursor at `along` (0 to 1 along the slider).
186 fn value_at(&self, along: f32) -> i16 {
187 let along = along.clamp(0.0, 1.0);
188 // Rounded and within 0..=steps, so it fits.
189 #[allow(clippy::cast_possible_truncation)]
190 let step = (along * f32::from(self.steps())).round() as u16;
191 self.low.saturating_add(i16::try_from(step).unwrap_or(0)).min(self.high)
192 }
193
194 /// 0 to 1 along the slider for a point inside `bounds`: left to right, or bottom to top.
195 fn along(&self, bounds: Rectangle, at: Point) -> f32 {
196 let pad = DOT_BIG;
197 if self.vertical {
198 (bounds.y + bounds.height - pad - at.y) / (bounds.height - 2.0 * pad).max(1.0)
199 } else {
200 (at.x - bounds.x - pad) / (bounds.width - 2.0 * pad).max(1.0)
201 }
202 }
203}
204
205/// The value's dot, and the others.
206const DOT_BIG: f32 = 11.0;
207const DOT: f32 = 5.0;
208
209/// What a slider's drawing shows: its range, its value, which way it runs, its colors, and the
210/// value it is leaving with how far the value's dot has grown.
211type Shows = (i16, i16, i16, bool, Color, Color, Option<i16>, f32);
212
213#[derive(Default)]
214pub struct Drag {
215 held: bool,
216 kept: Kept<Shows>,
217 /// The value last drawn.
218 value: Option<i16>,
219 /// The value before it, whose large dot shrinks as the new one's grows.
220 left: Option<i16>,
221 grow: Option<Tween<f32>>,
222 now: Option<Instant>,
223}
224
225impl Drag {
226 /// How far the value's dot has grown, 0 to 1.
227 fn grown(&self) -> f32 {
228 match (self.grow, self.now) {
229 (Some(grow), Some(now)) => grow.at(now),
230 _ => 1.0,
231 }
232 }
233}
234
235impl<F: Fn(i16, bool) -> Message> canvas::Program<Message> for Slider<F> {
236 type State = Drag;
237
238 fn update(
239 &self,
240 state: &mut Drag,
241 event: &Event,
242 bounds: Rectangle,
243 cursor: mouse::Cursor,
244 ) -> Option<canvas::Action<Message>> {
245 if let Some(now) = motion::redraw(event) {
246 return self.follow(state, now);
247 }
248 let Event::Mouse(event) = event else {
249 return None;
250 };
251 let change = |at: Point| {
252 let value = self.value_at(self.along(bounds, at));
253 canvas::Action::publish((self.change)(value, false)).and_capture()
254 };
255 match event {
256 mouse::Event::ButtonPressed(mouse::Button::Left) => {
257 let at = cursor.position_over(bounds)?;
258 state.held = true;
259 Some(change(at))
260 }
261 mouse::Event::CursorMoved { .. } if state.held => Some(change(cursor.position()?)),
262 mouse::Event::ButtonReleased(mouse::Button::Left) if state.held => {
263 state.held = false;
264 Some(canvas::Action::publish(self.release.clone()).and_capture())
265 }
266 mouse::Event::WheelScrolled { delta } => {
267 cursor.position_over(bounds)?;
268 let y = match delta {
269 mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => *y,
270 };
271 let to = if y > 0.0 {
272 self.value.saturating_add(1).min(self.high)
273 } else if y < 0.0 {
274 self.value.saturating_sub(1).max(self.low)
275 } else {
276 return None;
277 };
278 (to != self.value).then(|| canvas::Action::publish((self.change)(to, true)).and_capture())
279 }
280 _ => None,
281 }
282 }
283
284 fn draw(
285 &self,
286 state: &Drag,
287 renderer: &Renderer,
288 _theme: &Theme,
289 bounds: Rectangle,
290 _cursor: mouse::Cursor,
291 ) -> Vec<canvas::Geometry> {
292 let grown = state.grown();
293 let left = state.left.filter(|_| grown < 1.0);
294 let shows = (
295 self.low,
296 self.high,
297 self.value,
298 self.vertical,
299 self.on,
300 self.off,
301 left,
302 grown,
303 );
304 vec![
305 state
306 .kept
307 .draw(renderer, bounds.size(), shows, |frame| self.dots(frame, left, grown)),
308 ]
309 }
310
311 fn mouse_interaction(&self, state: &Drag, bounds: Rectangle, cursor: mouse::Cursor) -> mouse::Interaction {
312 if state.held || cursor.is_over(bounds) {
313 mouse::Interaction::Pointer
314 } else {
315 mouse::Interaction::default()
316 }
317 }
318}
319
320impl<F> Slider<F> {
321 /// At a redraw: when the value has changed, its dot starts growing and the last one's
322 /// shrinking. No travel: the large dot fades from one place to the other.
323 fn follow(&self, state: &mut Drag, now: Instant) -> Option<canvas::Action<Message>> {
324 state.now = Some(now);
325 if let Some(last) = state.value
326 && last != self.value
327 {
328 state.left = Some(last);
329 let mut grow = Tween::at_rest(0.0);
330 grow.aim(1.0, now, motion::HOVER, self.motion);
331 state.grow = Some(grow);
332 }
333 state.value = Some(self.value);
334 state
335 .grow
336 .is_some_and(|grow| grow.running(now))
337 .then(canvas::Action::request_redraw)
338 }
339
340 /// One dot per step; the value's dot is larger, grown `grown` of the way (0 to 1), and the
341 /// dot of the value `left` is shrinking back.
342 fn dots(&self, frame: &mut Frame, left: Option<i16>, grown: f32) {
343 let size = frame.size();
344 let at = |along: f32| {
345 if self.vertical {
346 Point::new(
347 size.width / 2.0,
348 size.height - DOT_BIG - along * (size.height - 2.0 * DOT_BIG),
349 )
350 } else {
351 Point::new(DOT_BIG + along * (size.width - 2.0 * DOT_BIG), size.height / 2.0)
352 }
353 };
354 let base = self.low.max(0).min(self.high);
355 let (from, to) = (base.min(self.value), base.max(self.value));
356 let mut value = self.low;
357 while value <= self.high {
358 let lit = (from..=to).contains(&value);
359 let (diameter, color) = if value == self.value {
360 (DOT.mix(DOT_BIG, grown), self.on)
361 } else if left == Some(value) {
362 (DOT_BIG.mix(DOT, grown), if lit { self.on } else { self.off })
363 } else if lit {
364 (DOT, self.on)
365 } else {
366 (DOT, self.off)
367 };
368 frame.fill(&Path::circle(at(self.place(value)), diameter / 2.0), color);
369 value = match value.checked_add(1) {
370 Some(next) => next,
371 None => break,
372 };
373 }
374 }
375}
376
377/// An on/off switch, 64 by 34: off, a dark pill with a dim knob on the left; on, the fill runs
378/// to the right end, with a dot in it. Turned, the knob stretches into the fill (or back) as its
379/// color changes and the dot fades.
380pub fn switch<'a>(look: Look, on: bool) -> Element<'a, Message> {
381 let p = look.p;
382 let switch = Switch {
383 on,
384 track: p.s2,
385 off: p.mute,
386 fill: p.fill,
387 dot: p.fill_fg,
388 motion: look.motion,
389 };
390 iced::widget::canvas(switch).width(64).height(34).into()
391}
392
393#[derive(Clone, Copy, PartialEq)]
394struct Switch {
395 on: bool,
396 track: Color,
397 off: Color,
398 fill: Color,
399 dot: Color,
400 motion: bool,
401}
402
403impl Switch {
404 fn target(&self) -> f32 {
405 if self.on { 1.0 } else { 0.0 }
406 }
407}
408
409/// How far a switch is turned on: 0 off, 1 on.
410#[derive(Default)]
411pub struct Turn {
412 kept: Kept<(f32, Switch)>,
413 on: Option<Tween<f32>>,
414 now: Option<Instant>,
415}
416
417impl canvas::Program<Message> for Switch {
418 type State = Turn;
419
420 fn update(
421 &self,
422 state: &mut Turn,
423 event: &Event,
424 _bounds: Rectangle,
425 _cursor: mouse::Cursor,
426 ) -> Option<canvas::Action<Message>> {
427 let now = motion::redraw(event)?;
428 state.now = Some(now);
429 // First seen, it is as it is; it moves only when turned.
430 let on = state.on.get_or_insert_with(|| Tween::at_rest(self.target()));
431 on.aim(self.target(), now, motion::STATE, self.motion);
432 on.running(now).then(canvas::Action::request_redraw)
433 }
434
435 fn draw(
436 &self,
437 state: &Turn,
438 renderer: &Renderer,
439 _theme: &Theme,
440 bounds: Rectangle,
441 _cursor: mouse::Cursor,
442 ) -> Vec<canvas::Geometry> {
443 let t = match (state.on, state.now) {
444 (Some(on), Some(now)) => on.at(now),
445 _ => self.target(),
446 };
447 let geometry = state.kept.draw(renderer, bounds.size(), (t, *self), |frame| {
448 let size = frame.size();
449 let track = Path::rounded_rectangle(Point::ORIGIN, size, (size.height / 2.0).into());
450 frame.fill(&track, self.track);
451 // The knob, 26 across, stretching to the track's inner width.
452 const PAD: f32 = 4.0;
453 let height = size.height - 2.0 * PAD;
454 let width = height.mix(size.width - 2.0 * PAD, t);
455 let knob = Path::rounded_rectangle(Point::new(PAD, PAD), Size::new(width, height), (height / 2.0).into());
456 frame.fill(&knob, self.off.mix(self.fill, t));
457 if t > 0.0 {
458 let center = Point::new(PAD + width - 8.0 - 5.0, size.height / 2.0);
459 let dot = Color {
460 a: self.dot.a * t,
461 ..self.dot
462 };
463 frame.fill(&Path::circle(center, 5.0), dot);
464 }
465 });
466 vec![geometry]
467 }
468}
469
470/// A dot that blinks hard, lit and dark in turn, like a recording light: something is under way.
471/// Without motion, it stays lit.
472pub fn blink<'a>(look: Look, color: Color, size: f32) -> Element<'a, Message> {
473 let blink = Blink {
474 color,
475 motion: look.motion,
476 };
477 iced::widget::canvas(blink).width(size).height(size).into()
478}
479
480#[derive(Clone, Copy, PartialEq)]
481struct Blink {
482 color: Color,
483 motion: bool,
484}
485
486#[derive(Default)]
487pub struct Blinking {
488 kept: Kept<(bool, Blink)>,
489 since: Option<Instant>,
490 dark: bool,
491}
492
493impl canvas::Program<Message> for Blink {
494 type State = Blinking;
495
496 fn update(
497 &self,
498 state: &mut Blinking,
499 event: &Event,
500 _bounds: Rectangle,
501 _cursor: mouse::Cursor,
502 ) -> Option<canvas::Action<Message>> {
503 let now = motion::redraw(event)?;
504 if !self.motion {
505 state.dark = false;
506 return None;
507 }
508 let since = *state.since.get_or_insert(now);
509 let (step, next) = steps(since, motion::BLINK, now);
510 state.dark = step % 2 == 1;
511 Some(next.map_or_else(canvas::Action::request_redraw, canvas::Action::request_redraw_at))
512 }
513
514 fn draw(
515 &self,
516 state: &Blinking,
517 renderer: &Renderer,
518 _theme: &Theme,
519 bounds: Rectangle,
520 _cursor: mouse::Cursor,
521 ) -> Vec<canvas::Geometry> {
522 let geometry = state.kept.draw(renderer, bounds.size(), (state.dark, *self), |frame| {
523 if !state.dark {
524 let size = frame.size();
525 frame.fill(
526 &Path::circle(frame.center(), size.width.min(size.height) / 2.0),
527 self.color,
528 );
529 }
530 });
531 vec![geometry]
532 }
533}
534
535/// Dots that scan from left to right with a fading tail, one step at a time, like a hardware
536/// spinner: the buds are being looked for. Without motion, the middle dot is lit and still.
537pub fn scan<'a>(look: Look, color: Color) -> Element<'a, Message> {
538 let scan = Scan {
539 on: color,
540 off: look.p.s3,
541 motion: look.motion,
542 };
543 let width = f32::from(SCAN_DOTS) * SCAN_PITCH - (SCAN_PITCH - SCAN_DOT);
544 iced::widget::canvas(scan).width(width).height(SCAN_DOT).into()
545}
546
547const SCAN_DOTS: u16 = 9;
548const SCAN_DOT: f32 = 6.0;
549const SCAN_PITCH: f32 = 12.0;
550
551#[derive(Clone, Copy, PartialEq)]
552struct Scan {
553 on: Color,
554 off: Color,
555 motion: bool,
556}
557
558#[derive(Default)]
559pub struct Scanning {
560 kept: Kept<(u16, Scan)>,
561 since: Option<Instant>,
562 /// The lit dot, by its place; past the last one, only its tail shows.
563 head: Option<u16>,
564}
565
566impl canvas::Program<Message> for Scan {
567 type State = Scanning;
568
569 fn update(
570 &self,
571 state: &mut Scanning,
572 event: &Event,
573 _bounds: Rectangle,
574 _cursor: mouse::Cursor,
575 ) -> Option<canvas::Action<Message>> {
576 let now = motion::redraw(event)?;
577 if !self.motion {
578 state.head = Some(SCAN_DOTS / 2);
579 return None;
580 }
581 let since = *state.since.get_or_insert(now);
582 let (step, next) = steps(since, motion::SCAN, now);
583 // The head runs past the end until its tail of two has left too.
584 let cycle = u128::from(SCAN_DOTS + 2);
585 state.head = u16::try_from(step % cycle).ok();
586 Some(next.map_or_else(canvas::Action::request_redraw, canvas::Action::request_redraw_at))
587 }
588
589 fn draw(
590 &self,
591 state: &Scanning,
592 renderer: &Renderer,
593 _theme: &Theme,
594 bounds: Rectangle,
595 _cursor: mouse::Cursor,
596 ) -> Vec<canvas::Geometry> {
597 let head = state.head.unwrap_or(SCAN_DOTS / 2);
598 let geometry = state.kept.draw(renderer, bounds.size(), (head, *self), |frame| {
599 let y = frame.size().height / 2.0;
600 for n in 0..SCAN_DOTS {
601 // The head at full strength, then 60% and 30%: Nothing's three steps of opacity.
602 let color = match head.checked_sub(n) {
603 Some(0) => self.on,
604 Some(1) => Color {
605 a: self.on.a * 0.6,
606 ..self.on
607 },
608 Some(2) => Color {
609 a: self.on.a * 0.3,
610 ..self.on
611 },
612 _ => self.off,
613 };
614 let x = SCAN_DOT / 2.0 + f32::from(n) * SCAN_PITCH;
615 frame.fill(&Path::circle(Point::new(x, y), SCAN_DOT / 2.0), color);
616 }
617 });
618 vec![geometry]
619 }
620}
621
622/// For something that changes every `every` from `since`: how many steps have gone by at
623/// `now`, and when the next one is due (`None` past what an instant can hold).
624fn steps(since: Instant, every: Duration, now: Instant) -> (u128, Option<Instant>) {
625 let step = now.saturating_duration_since(since).as_millis() / every.as_millis().max(1);
626 let next = u32::try_from(step.saturating_add(1))
627 .ok()
628 .and_then(|n| every.checked_mul(n))
629 .and_then(|gone| since.checked_add(gone));
630 (step, next)
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 fn slider(low: i16, high: i16) -> Slider<fn(i16, bool) -> Message> {
638 Slider {
639 low,
640 high,
641 value: 0,
642 vertical: false,
643 change: |_, _| Message::SaveBass,
644 release: Message::SaveBass,
645 on: Color::WHITE,
646 off: Color::BLACK,
647 motion: false,
648 }
649 }
650
651 #[test]
652 fn steps_come_on_time() {
653 let since = Instant::now();
654 let every = Duration::from_millis(100);
655 assert_eq!(steps(since, every, since), (0, Some(since + every)));
656 let (step, next) = steps(since, every, since + Duration::from_millis(250));
657 assert_eq!(step, 2);
658 assert_eq!(next, Some(since + Duration::from_millis(300)));
659 }
660
661 #[test]
662 fn rings_light_their_share() {
663 assert_eq!(lit_count(0.8, 44), 35);
664 assert_eq!(lit_count(1.5, 44), 44);
665 assert_eq!(lit_count(-1.0, 44), 0);
666 }
667
668 #[test]
669 fn sliders_find_the_value_under_the_cursor() {
670 let eq = slider(-6, 6);
671 assert_eq!(eq.value_at(0.0), -6);
672 assert_eq!(eq.value_at(0.5), 0);
673 assert_eq!(eq.value_at(1.0), 6);
674 assert_eq!(eq.value_at(2.0), 6);
675 assert_eq!(eq.place(0), 0.5);
676 // A slider with one value does not divide by zero.
677 assert_eq!(slider(3, 3).value_at(0.7), 3);
678 }
679}