| 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 | |
| 4 | use std::f32::consts::{FRAC_PI_2, TAU}; |
| 5 | |
| 6 | use iced::widget::canvas::{self, Event, Frame, Path}; |
| 7 | use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse}; |
| 8 | |
| 9 | use 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. |
| 13 | pub 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 | |
| 20 | struct Ring { |
| 21 | lit: Option<f32>, |
| 22 | on: Color, |
| 23 | off: Color, |
| 24 | } |
| 25 | |
| 26 | impl 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. |
| 54 | fn 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)`. |
| 67 | pub 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 | |
| 78 | impl<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 | |
| 91 | impl<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. |
| 122 | const DOT_BIG: f32 = 11.0; |
| 123 | const DOT: f32 = 5.0; |
| 124 | |
| 125 | #[derive(Default)] |
| 126 | pub struct Drag { |
| 127 | held: bool, |
| 128 | } |
| 129 | |
| 130 | impl<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)] |
| 227 | mod 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 | } |