Skip to main content

zest_widget/widget/
scale.rs

1//! Passive scale: evenly spaced tick marks with optional numeric labels
2//! and an optional value marker.
3//!
4//! Two modes:
5//! - [`ScaleMode::Linear`] draws a ruler: a baseline with major/minor
6//!   ticks running left-to-right, labels beneath the major ticks.
7//! - [`ScaleMode::Circular`] draws a gauge: ticks radiating inward from
8//!   an arc, with labels just inside the major ticks. The arc itself is
9//!   drawn via [`Renderer::stroke_arc`].
10//!
11//! The host supplies the value range, the tick counts, and (optionally)
12//! whether to label major ticks with their numeric value. A scale can
13//! also highlight one value with a marker line. The widget is
14//! non-interactive. Pair it with [`Arc`](super::arc::Arc) to build a
15//! labelled gauge.
16
17use super::Widget;
18use alloc::{format, string::String};
19use core::marker::PhantomData;
20use embedded_graphics::{
21    mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
22};
23use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, arc_sin_cos};
24use zest_theme::Theme;
25
26/// Layout mode for a [`Scale`].
27#[derive(Copy, Clone, Debug, PartialEq, Eq)]
28pub enum ScaleMode {
29    /// Horizontal ruler: ticks along a baseline, labels beneath.
30    Linear,
31    /// Circular gauge: ticks radiating inward from an arc.
32    Circular,
33}
34
35/// Passive tick-mark scale (ruler or gauge).
36pub struct Scale<'a, C: PixelColor, M: Clone> {
37    rect: Rectangle,
38    mode: ScaleMode,
39    min: f32,
40    max: f32,
41    /// Number of major divisions (so `major_ticks + 1` major tick marks).
42    major_ticks: u32,
43    /// Minor ticks drawn between each pair of major ticks.
44    minor_per_major: u32,
45    /// Show numeric labels at major ticks.
46    labels: bool,
47    /// Circular-mode geometry (ignored in linear mode).
48    start_deg: i32,
49    sweep_deg: i32,
50    marker_value: Option<f32>,
51    color: Option<C>,
52    label_color: Option<C>,
53    marker_color: Option<C>,
54    font: Option<&'a MonoFont<'a>>,
55    w: Length,
56    h: Length,
57    _phantom: PhantomData<M>,
58}
59
60impl<'a, C: PixelColor, M: Clone> Scale<'a, C, M> {
61    /// New scale over `min..=max`. Defaults: linear, 5 major divisions,
62    /// 4 minor ticks between majors, labels on, theme colors.
63    pub fn new(min: f32, max: f32) -> Self {
64        let (min, max) = if min <= max { (min, max) } else { (max, min) };
65        Self {
66            rect: Rectangle::zero(),
67            mode: ScaleMode::Linear,
68            min,
69            max,
70            major_ticks: 5,
71            minor_per_major: 4,
72            labels: true,
73            start_deg: 225,
74            sweep_deg: -270,
75            marker_value: None,
76            color: None,
77            label_color: None,
78            marker_color: None,
79            font: None,
80            w: Length::Fill,
81            h: Length::Fill,
82            _phantom: PhantomData,
83        }
84    }
85
86    /// Layout mode (linear ruler or circular gauge).
87    #[must_use]
88    pub fn mode(mut self, mode: ScaleMode) -> Self {
89        self.mode = mode;
90        self
91    }
92
93    /// Number of major divisions (`n` divisions ⇒ `n + 1`
94    /// major ticks). Clamped to at least 1.
95    #[must_use]
96    pub fn major_ticks(mut self, n: u32) -> Self {
97        self.major_ticks = n.max(1);
98        self
99    }
100
101    /// Minor ticks drawn between each pair of major ticks.
102    #[must_use]
103    pub fn minor_per_major(mut self, n: u32) -> Self {
104        self.minor_per_major = n;
105        self
106    }
107
108    /// Toggle numeric labels at major ticks.
109    #[must_use]
110    pub fn labels(mut self, on: bool) -> Self {
111        self.labels = on;
112        self
113    }
114
115    /// Starting angle for circular mode (0° points right).
116    #[must_use]
117    pub fn start_deg(mut self, start_deg: i32) -> Self {
118        self.start_deg = start_deg;
119        self
120    }
121
122    /// Total sweep for circular mode in degrees.
123    #[must_use]
124    pub fn sweep_deg(mut self, sweep_deg: i32) -> Self {
125        self.sweep_deg = sweep_deg;
126        self
127    }
128
129    /// Highlight one value on the scale with a marker line.
130    #[must_use]
131    pub fn value_marker(mut self, value: f32) -> Self {
132        self.marker_value = Some(value);
133        self
134    }
135
136    /// Override tick/baseline color (default:
137    /// `theme.background.on_base`).
138    #[must_use]
139    pub fn color(mut self, color: C) -> Self {
140        self.color = Some(color);
141        self
142    }
143
144    /// Override label color (default: `theme.palette.neutral_2`).
145    #[must_use]
146    pub fn label_color(mut self, color: C) -> Self {
147        self.label_color = Some(color);
148        self
149    }
150
151    /// Override marker color (default: `theme.accent.base`).
152    #[must_use]
153    pub fn marker_color(mut self, color: C) -> Self {
154        self.marker_color = Some(color);
155        self
156    }
157
158    /// Override label font (default: `theme.typography.caption`).
159    #[must_use]
160    pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
161        self.font = Some(font);
162        self
163    }
164
165    /// Width sizing intent.
166    #[must_use]
167    pub fn width(mut self, width: impl Into<Length>) -> Self {
168        self.w = width.into();
169        self
170    }
171
172    /// Height sizing intent.
173    #[must_use]
174    pub fn height(mut self, height: impl Into<Length>) -> Self {
175        self.h = height.into();
176        self
177    }
178
179    /// Value at major-tick index `i` (`0..=major_ticks`).
180    fn value_at(&self, i: u32) -> f32 {
181        let t = i as f32 / self.major_ticks as f32;
182        self.min + (self.max - self.min) * t
183    }
184
185    /// Format a numeric tick value compactly (drops a trailing `.0`).
186    fn label_for(v: f32) -> String {
187        if (v - libm_round(v)).abs() < 0.05 {
188            format!("{}", libm_round(v) as i64)
189        } else {
190            format!("{v:.1}")
191        }
192    }
193
194    /// Total tick count between (and including) the two ends.
195    fn total_ticks(&self) -> u32 {
196        self.major_ticks * (self.minor_per_major + 1)
197    }
198
199    fn marker_fraction(&self) -> Option<f32> {
200        let value = self.marker_value?;
201        let span = self.max - self.min;
202        if span <= 0.0 {
203            Some(0.0)
204        } else {
205            Some(((value - self.min) / span).clamp(0.0, 1.0))
206        }
207    }
208}
209
210/// Round-half-to-even substitute that avoids `std`/`libm`: round to the
211/// nearest integer toward the closer side.
212fn libm_round(v: f32) -> f32 {
213    if v >= 0.0 {
214        (v + 0.5) as i64 as f32
215    } else {
216        (v - 0.5) as i64 as f32
217    }
218}
219
220impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Scale<'a, C, M> {
221    fn measure(&mut self, constraints: Constraints) -> Size {
222        let w = self.w.resolve(constraints.max.width, constraints.max.width);
223        let h = self
224            .h
225            .resolve(constraints.max.height, constraints.max.height);
226        constraints.clamp(Size::new(w, h))
227    }
228
229    fn preferred_size(&self) -> (Length, Length) {
230        (self.w, self.h)
231    }
232
233    fn arrange(&mut self, rect: Rectangle) {
234        self.rect = rect;
235    }
236
237    fn rect(&self) -> Rectangle {
238        self.rect
239    }
240
241    fn handle_touch(&mut self, _point: Point, _phase: TouchPhase) -> Option<M> {
242        None
243    }
244
245    fn draw<'t>(
246        &self,
247        renderer: &mut dyn Renderer<C>,
248        theme: &Theme<'t, C>,
249    ) -> Result<(), RenderError> {
250        let tick = self.color.unwrap_or(theme.background.on_base);
251        let label_color = self.label_color.unwrap_or(theme.palette.neutral_2);
252        let marker = self.marker_color.unwrap_or(theme.accent.base);
253        let font = self.font.unwrap_or(theme.typography.caption);
254
255        match self.mode {
256            ScaleMode::Linear => self.draw_linear(renderer, tick, label_color, marker, font),
257            ScaleMode::Circular => self.draw_circular(renderer, tick, label_color, marker, font),
258        }
259    }
260}
261
262impl<'a, C: PixelColor, M: Clone> Scale<'a, C, M> {
263    fn draw_linear(
264        &self,
265        renderer: &mut dyn Renderer<C>,
266        tick: C,
267        label_color: C,
268        marker: C,
269        font: &MonoFont<'_>,
270    ) -> Result<(), RenderError> {
271        let r = self.rect;
272        if r.size.width == 0 || r.size.height == 0 {
273            return Ok(());
274        }
275        let major_len = (r.size.height / 3).max(4) as i32;
276        let minor_len = (major_len / 2).max(2);
277        // Baseline near the top so labels have room beneath the ruler.
278        let baseline_y = r.top_left.y + 1;
279        let left = r.top_left.x;
280        let width = r.size.width.saturating_sub(1) as i32;
281
282        // Baseline.
283        renderer.stroke_line(
284            Point::new(left, baseline_y),
285            Point::new(left + width, baseline_y),
286            tick,
287            1,
288        )?;
289
290        let total = self.total_ticks();
291        for i in 0..=total {
292            let is_major = i % (self.minor_per_major + 1) == 0;
293            let x = left + (width * i as i32) / total as i32;
294            let len = if is_major { major_len } else { minor_len };
295            renderer.stroke_line(
296                Point::new(x, baseline_y),
297                Point::new(x, baseline_y + len),
298                tick,
299                1,
300            )?;
301            if is_major && self.labels {
302                let major_index = i / (self.minor_per_major + 1);
303                let text = Self::label_for(self.value_at(major_index));
304                let label_y = baseline_y + major_len + 2 + font.character_size.height as i32;
305                renderer.draw_text(
306                    &text,
307                    Point::new(x, label_y),
308                    font,
309                    label_color,
310                    Alignment::Center,
311                )?;
312            }
313        }
314
315        if let Some(frac) = self.marker_fraction() {
316            let x = left + (width as f32 * frac) as i32;
317            renderer.stroke_line(
318                Point::new(x, baseline_y.saturating_sub(3)),
319                Point::new(x, baseline_y + major_len + 4),
320                marker,
321                2,
322            )?;
323        }
324        Ok(())
325    }
326
327    fn draw_circular(
328        &self,
329        renderer: &mut dyn Renderer<C>,
330        tick: C,
331        label_color: C,
332        marker: C,
333        font: &MonoFont<'_>,
334    ) -> Result<(), RenderError> {
335        let r = self.rect;
336        let center = Point::new(
337            r.top_left.x + r.size.width as i32 / 2,
338            r.top_left.y + r.size.height as i32 / 2,
339        );
340        let smaller = r.size.width.min(r.size.height);
341        let outer = (smaller / 2).saturating_sub(2);
342        if outer == 0 {
343            return Ok(());
344        }
345        let major_len = (outer / 6).max(4);
346        let minor_len = (major_len / 2).max(2);
347
348        // The reference arc the ticks hang from.
349        renderer.stroke_arc(center, outer, self.start_deg, self.sweep_deg, 2, tick)?;
350
351        let total = self.total_ticks();
352        let outer_r = outer as f32;
353        // Labels sit inside the major ticks.
354        let label_r = (outer as f32) - major_len as f32 - font.character_size.height as f32;
355
356        for i in 0..=total {
357            let is_major = i % (self.minor_per_major + 1) == 0;
358            let frac = i as f32 / total as f32;
359            let deg = self.start_deg + (self.sweep_deg as f32 * frac) as i32;
360            let (s, c) = arc_sin_cos(deg);
361            let inner_r = outer_r - if is_major { major_len } else { minor_len } as f32;
362
363            let p_outer = Point::new(
364                center.x + (c * outer_r) as i32,
365                center.y - (s * outer_r) as i32,
366            );
367            let p_inner = Point::new(
368                center.x + (c * inner_r) as i32,
369                center.y - (s * inner_r) as i32,
370            );
371            renderer.stroke_line(p_outer, p_inner, tick, 1)?;
372
373            if is_major && self.labels && label_r > 0.0 {
374                let major_index = i / (self.minor_per_major + 1);
375                let text = Self::label_for(self.value_at(major_index));
376                let lp = Point::new(
377                    center.x + (c * label_r) as i32,
378                    // Nudge the baseline so the glyph centers vertically.
379                    center.y - (s * label_r) as i32 + font.character_size.height as i32 / 3,
380                );
381                renderer.draw_text(&text, lp, font, label_color, Alignment::Center)?;
382            }
383        }
384
385        if let Some(frac) = self.marker_fraction() {
386            let deg = self.start_deg + (self.sweep_deg as f32 * frac) as i32;
387            let (s, c) = arc_sin_cos(deg);
388            let marker_outer = outer_r;
389            let marker_inner = (outer_r - major_len as f32 - 6.0).max(0.0);
390            let p_outer = Point::new(
391                center.x + (c * marker_outer) as i32,
392                center.y - (s * marker_outer) as i32,
393            );
394            let p_inner = Point::new(
395                center.x + (c * marker_inner) as i32,
396                center.y - (s * marker_inner) as i32,
397            );
398            renderer.stroke_line(p_outer, p_inner, marker, 2)?;
399        }
400        Ok(())
401    }
402}