| 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 | |
| 4 | use std::f32::consts::{FRAC_PI_2, TAU}; |
| 5 | |
| 6 | use iced::alignment::Vertical; |
| 7 | use iced::widget::canvas::{self, Event, Frame, Path}; |
| 8 | use iced::widget::text; |
| 9 | use iced::{Color, Element, Font, Length, Point, Rectangle, Renderer, Theme, mouse}; |
| 10 | |
| 11 | use crate::Message; |
| 12 | use crate::kept::Kept; |
| 13 | use 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. |
| 18 | pub 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)] |
| 31 | struct Ring { |
| 32 | level: Option<u8>, |
| 33 | on: Color, |
| 34 | off: Color, |
| 35 | figure: Color, |
| 36 | font: Font, |
| 37 | } |
| 38 | |
| 39 | impl 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. |
| 78 | const FIGURE: f32 = 44.0; |
| 79 | |
| 80 | /// How many of `n` dots the fraction `f` lights. |
| 81 | fn 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)`. |
| 94 | pub 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 | |
| 105 | impl<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 | |
| 118 | impl<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. |
| 149 | const DOT_BIG: f32 = 11.0; |
| 150 | const DOT: f32 = 5.0; |
| 151 | |
| 152 | /// What a slider's drawing shows: its range, its value, which way it runs, and its colors. |
| 153 | type Shows = (i16, i16, i16, bool, Color, Color); |
| 154 | |
| 155 | #[derive(Default)] |
| 156 | pub struct Drag { |
| 157 | held: bool, |
| 158 | kept: Kept<Shows>, |
| 159 | } |
| 160 | |
| 161 | impl<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 | |
| 232 | impl<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)] |
| 268 | mod 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 | } |