Skip to main content

rlvgl_widgets/
scale.rs

1//! LVGL-parity scale widget for ticks and numeric labels.
2//!
3//! The scale widget draws a fixed integer range as linear or circular ticks.
4//! Sections, custom label providers, and style-cascade promotion are deferred
5//! by LPAR-11; this module keeps the base state and drawing contract local so
6//! those features can be added without changing the `ScaleMode` vocabulary.
7
8use libm::{cosf, sinf};
9use rlvgl_core::event::Event;
10use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
11use rlvgl_core::raster::PointF;
12use rlvgl_core::renderer::Renderer;
13use rlvgl_core::style::Style;
14use rlvgl_core::widget::{Color, Rect, Widget};
15
16const DEFAULT_TICK_COUNT: u16 = 6;
17const DEFAULT_MAJOR_TICK_EVERY: u16 = 1;
18const DEFAULT_ANGLE_RANGE: i32 = 270;
19const DEFAULT_ROTATION: i32 = 135;
20const MINOR_TICK_LENGTH: i32 = 5;
21const MAJOR_TICK_LENGTH: i32 = 9;
22const LABEL_GAP: i32 = 3;
23const LABEL_CHAR_WIDTH: i32 = 6;
24const LABEL_BASELINE_HALF: i32 = 4;
25const LABEL_BUF_LEN: usize = 12;
26
27/// Tick and label placement mode for [`Scale`].
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum ScaleMode {
30    /// Horizontal axis with ticks and labels below the axis line.
31    HorizontalBottom,
32    /// Horizontal axis with ticks and labels above the axis line.
33    HorizontalTop,
34    /// Vertical axis with ticks and labels to the left of the axis line.
35    VerticalLeft,
36    /// Vertical axis with ticks and labels to the right of the axis line.
37    VerticalRight,
38    /// Circular axis with ticks and labels directed toward the center.
39    RoundInner,
40    /// Circular axis with ticks and labels directed away from the center.
41    RoundOuter,
42}
43
44/// Integer scale widget that draws major and minor ticks plus numeric labels.
45///
46/// Tick count is the total number of ticks including both endpoints. A tick
47/// count of zero draws only the base axis. `major_tick_every == 0` disables
48/// major ticks and labels, matching the LPAR-11 LVGL parity note.
49pub struct Scale {
50    bounds: Rect,
51    min: i32,
52    max: i32,
53    tick_count: u16,
54    major_tick_every: u16,
55    label_show: bool,
56    mode: ScaleMode,
57    angle_range: i32,
58    rotation: i32,
59    /// Style for the `MAIN` axis line or circular arc.
60    ///
61    /// `border_color`, `border_width`, and `alpha` control the v1 stroke.
62    pub style: Style,
63    /// Color used for `INDICATOR` major ticks.
64    pub major_tick_color: Color,
65    /// Color used for `ITEMS` minor ticks.
66    pub minor_tick_color: Color,
67    /// Color used for generated numeric labels.
68    pub label_color: Color,
69    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
70    /// when unset.
71    font: WidgetFont,
72}
73
74#[derive(Clone, Copy)]
75struct RoundLabelAnchor {
76    center: PointF,
77    cos_t: f32,
78    sin_t: f32,
79    tick_end_radius: f32,
80    major: bool,
81}
82
83impl Scale {
84    /// Create a scale with default bounds and a `0..=100` integer range.
85    ///
86    /// The initial mode is [`ScaleMode::HorizontalBottom`], with six total
87    /// ticks, every tick classified as major, and numeric labels visible.
88    pub fn new(bounds: Rect) -> Self {
89        Self::with_range(bounds, 0, 100)
90    }
91
92    /// Create a scale with bounds and an inclusive integer range.
93    pub fn with_range(bounds: Rect, min: i32, max: i32) -> Self {
94        let style = Style {
95            border_width: 1,
96            ..Style::default()
97        };
98
99        Self {
100            bounds,
101            min,
102            max,
103            tick_count: DEFAULT_TICK_COUNT,
104            major_tick_every: DEFAULT_MAJOR_TICK_EVERY,
105            label_show: true,
106            mode: ScaleMode::HorizontalBottom,
107            angle_range: DEFAULT_ANGLE_RANGE,
108            rotation: DEFAULT_ROTATION,
109            style,
110            major_tick_color: Color(0, 0, 0, 255),
111            minor_tick_color: Color(96, 96, 96, 255),
112            label_color: Color(0, 0, 0, 255),
113            font: WidgetFont::new(),
114        }
115    }
116
117    /// Assign the font used to render this widget (FONT-00 §5); resolves to
118    /// `FONT_6X10` when unset.
119    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
120        self.font.set(font);
121    }
122
123    /// Set the scale placement mode.
124    pub fn set_mode(&mut self, mode: ScaleMode) {
125        self.mode = mode;
126    }
127
128    /// Return the current placement mode.
129    pub fn mode(&self) -> ScaleMode {
130        self.mode
131    }
132
133    /// Set the inclusive value range displayed by the scale.
134    ///
135    /// Reversed ranges are preserved and render endpoint labels in the
136    /// configured order.
137    pub fn set_range(&mut self, min: i32, max: i32) {
138        self.min = min;
139        self.max = max;
140    }
141
142    /// Configured minimum endpoint.
143    pub fn min_value(&self) -> i32 {
144        self.min
145    }
146
147    /// Configured maximum endpoint.
148    pub fn max_value(&self) -> i32 {
149        self.max
150    }
151
152    /// Set the total number of ticks, including both range endpoints.
153    ///
154    /// A value of zero is allowed and draws no ticks.
155    pub fn set_tick_count(&mut self, tick_count: u16) {
156        self.tick_count = tick_count;
157    }
158
159    /// Return the total number of ticks.
160    pub fn tick_count(&self) -> u16 {
161        self.tick_count
162    }
163
164    /// Compatibility alias for LVGL's total-tick naming.
165    pub fn set_total_tick_count(&mut self, tick_count: u16) {
166        self.set_tick_count(tick_count);
167    }
168
169    /// Compatibility alias for LVGL's total-tick naming.
170    pub fn total_tick_count(&self) -> u16 {
171        self.tick_count()
172    }
173
174    /// Set the major tick interval.
175    ///
176    /// `0` disables all major ticks and labels. Otherwise, tick index `0` and
177    /// every index evenly divisible by the interval is major.
178    pub fn set_major_tick_every(&mut self, every: u16) {
179        self.major_tick_every = every;
180    }
181
182    /// Return the major tick interval.
183    pub fn major_tick_every(&self) -> u16 {
184        self.major_tick_every
185    }
186
187    /// Show or hide generated numeric labels on major ticks.
188    pub fn set_label_visible(&mut self, visible: bool) {
189        self.label_show = visible;
190    }
191
192    /// Return whether generated numeric labels are visible.
193    pub fn label_visible(&self) -> bool {
194        self.label_show
195    }
196
197    /// Compatibility alias for LVGL's label-show naming.
198    pub fn set_label_show(&mut self, show: bool) {
199        self.set_label_visible(show);
200    }
201
202    /// Compatibility alias for LVGL's label-show naming.
203    pub fn label_show(&self) -> bool {
204        self.label_visible()
205    }
206
207    /// Set the angular span used by round modes, clamped to `0..=360`.
208    pub fn set_angle_range(&mut self, degrees: i32) {
209        self.angle_range = degrees.clamp(0, 360);
210    }
211
212    /// Return the angular span used by round modes.
213    pub fn angle_range(&self) -> i32 {
214        self.angle_range
215    }
216
217    /// Set the starting rotation used by round modes.
218    pub fn set_rotation(&mut self, degrees: i32) {
219        self.rotation = normalize_turn(degrees);
220    }
221
222    /// Return the starting rotation used by round modes.
223    pub fn rotation(&self) -> i32 {
224        self.rotation
225    }
226
227    fn draw_linear(&self, renderer: &mut dyn Renderer) {
228        let Some((axis_start, axis_end, positive_side)) = self.linear_axis() else {
229            return;
230        };
231        let axis_color = self.style.border_color.with_alpha(self.style.alpha);
232        if let Some(width) = self.stroke_width()
233            && axis_color.3 != 0
234        {
235            renderer.stroke_line_aa(axis_start, axis_end, width, axis_color);
236        }
237
238        for index in 0..usize::from(self.tick_count) {
239            let major = self.is_major_tick(index);
240            let point = self.linear_tick_point(index);
241            let length = self.tick_length(major);
242            let tick_end = match self.mode {
243                ScaleMode::HorizontalBottom | ScaleMode::HorizontalTop => {
244                    PointF::new(point.x, point.y + positive_side * length as f32)
245                }
246                ScaleMode::VerticalLeft | ScaleMode::VerticalRight => {
247                    PointF::new(point.x + positive_side * length as f32, point.y)
248                }
249                ScaleMode::RoundInner | ScaleMode::RoundOuter => point,
250            };
251            self.draw_tick(renderer, point, tick_end, major);
252            self.draw_label(renderer, index, point, positive_side, major);
253        }
254    }
255
256    fn draw_round(&self, renderer: &mut dyn Renderer) {
257        let Some((center, axis_radius)) = self.round_center_and_axis_radius() else {
258            return;
259        };
260        self.draw_round_axis(renderer, center, axis_radius);
261
262        for index in 0..usize::from(self.tick_count) {
263            let major = self.is_major_tick(index);
264            let length = self.tick_length(major) as f32;
265            let angle = self.tick_angle(index);
266            let (cos_t, sin_t) = angle_vector(angle);
267            let (from_r, to_r) = match self.mode {
268                ScaleMode::RoundInner => (axis_radius, (axis_radius - length).max(0.0)),
269                ScaleMode::RoundOuter => (axis_radius, axis_radius + length),
270                _ => (axis_radius, axis_radius),
271            };
272            let from = polar(center, cos_t, sin_t, from_r);
273            let to = polar(center, cos_t, sin_t, to_r);
274            self.draw_tick(renderer, from, to, major);
275            self.draw_round_label(
276                renderer,
277                index,
278                RoundLabelAnchor {
279                    center,
280                    cos_t,
281                    sin_t,
282                    tick_end_radius: to_r,
283                    major,
284                },
285            );
286        }
287    }
288
289    fn draw_round_axis(&self, renderer: &mut dyn Renderer, center: PointF, axis_radius: f32) {
290        let Some(width) = self.stroke_width() else {
291            return;
292        };
293        let color = self.style.border_color.with_alpha(self.style.alpha);
294        if color.3 == 0 || self.angle_range == 0 || axis_radius <= 0.0 {
295            return;
296        }
297
298        let half_width = width * 0.5;
299        let r_outer = axis_radius + half_width;
300        let r_inner = (axis_radius - half_width).max(0.0);
301        let (start_cos, start_sin) = angle_vector(self.rotation);
302        let (end_cos, end_sin) = angle_vector(self.rotation + self.angle_range);
303        let extent = self.angle_range as f32 * core::f32::consts::PI / 180.0;
304        renderer.fill_arc_aa(
305            center, r_outer, r_inner, start_cos, start_sin, end_cos, end_sin, extent, color,
306        );
307    }
308
309    fn draw_tick(&self, renderer: &mut dyn Renderer, from: PointF, to: PointF, major: bool) {
310        let Some(width) = self.stroke_width() else {
311            return;
312        };
313        let color = if major {
314            self.major_tick_color
315        } else {
316            self.minor_tick_color
317        }
318        .with_alpha(self.style.alpha);
319        if color.3 != 0 {
320            renderer.stroke_line_aa(from, to, width, color);
321        }
322    }
323
324    fn draw_label(
325        &self,
326        renderer: &mut dyn Renderer,
327        index: usize,
328        tick_point: PointF,
329        positive_side: f32,
330        major: bool,
331    ) {
332        if !self.label_show || !major {
333            return;
334        }
335
336        let label = LabelBuf::new(self.tick_value(index));
337        let text_x = match self.mode {
338            ScaleMode::HorizontalBottom | ScaleMode::HorizontalTop => {
339                tick_point.x as i32 - label.pixel_width() / 2
340            }
341            ScaleMode::VerticalRight => tick_point.x as i32 + MAJOR_TICK_LENGTH + LABEL_GAP,
342            ScaleMode::VerticalLeft => {
343                tick_point.x as i32 - MAJOR_TICK_LENGTH - LABEL_GAP - label.pixel_width()
344            }
345            ScaleMode::RoundInner | ScaleMode::RoundOuter => tick_point.x as i32,
346        };
347        let text_y = match self.mode {
348            ScaleMode::HorizontalBottom => {
349                tick_point.y as i32 + MAJOR_TICK_LENGTH + LABEL_GAP + LABEL_BASELINE_HALF
350            }
351            ScaleMode::HorizontalTop => {
352                tick_point.y as i32 - MAJOR_TICK_LENGTH - LABEL_GAP - LABEL_BASELINE_HALF
353            }
354            ScaleMode::VerticalLeft | ScaleMode::VerticalRight => {
355                tick_point.y as i32 + LABEL_BASELINE_HALF
356            }
357            ScaleMode::RoundInner | ScaleMode::RoundOuter => tick_point.y as i32,
358        };
359
360        let _ = positive_side;
361        self.draw_label_text(renderer, label.as_str(), (text_x, text_y));
362    }
363
364    fn draw_round_label(
365        &self,
366        renderer: &mut dyn Renderer,
367        index: usize,
368        anchor: RoundLabelAnchor,
369    ) {
370        if !self.label_show || !anchor.major {
371            return;
372        }
373
374        let label = LabelBuf::new(self.tick_value(index));
375        let label_radius = match self.mode {
376            ScaleMode::RoundInner => (anchor.tick_end_radius - LABEL_GAP as f32).max(0.0),
377            ScaleMode::RoundOuter => anchor.tick_end_radius + LABEL_GAP as f32,
378            _ => anchor.tick_end_radius,
379        };
380        let p = polar(anchor.center, anchor.cos_t, anchor.sin_t, label_radius);
381        self.draw_label_text(
382            renderer,
383            label.as_str(),
384            (
385                p.x as i32 - label.pixel_width() / 2,
386                p.y as i32 + LABEL_BASELINE_HALF,
387            ),
388        );
389    }
390
391    fn draw_label_text(&self, renderer: &mut dyn Renderer, text: &str, origin: (i32, i32)) {
392        let font = self.font.resolve();
393        let shaped = shape_text_ltr(font, text, origin, 0);
394        renderer.draw_text_shaped(
395            &shaped,
396            (0, 0),
397            self.label_color.with_alpha(self.style.alpha),
398        );
399    }
400
401    fn linear_axis(&self) -> Option<(PointF, PointF, f32)> {
402        match self.mode {
403            ScaleMode::HorizontalBottom => Some((
404                PointF::new(self.bounds.x as f32, self.bounds.y as f32),
405                PointF::new(
406                    (self.bounds.x + self.bounds.width) as f32,
407                    self.bounds.y as f32,
408                ),
409                1.0,
410            )),
411            ScaleMode::HorizontalTop => Some((
412                PointF::new(
413                    self.bounds.x as f32,
414                    (self.bounds.y + self.bounds.height) as f32,
415                ),
416                PointF::new(
417                    (self.bounds.x + self.bounds.width) as f32,
418                    (self.bounds.y + self.bounds.height) as f32,
419                ),
420                -1.0,
421            )),
422            ScaleMode::VerticalRight => Some((
423                PointF::new(self.bounds.x as f32, self.bounds.y as f32),
424                PointF::new(
425                    self.bounds.x as f32,
426                    (self.bounds.y + self.bounds.height) as f32,
427                ),
428                1.0,
429            )),
430            ScaleMode::VerticalLeft => Some((
431                PointF::new(
432                    (self.bounds.x + self.bounds.width) as f32,
433                    self.bounds.y as f32,
434                ),
435                PointF::new(
436                    (self.bounds.x + self.bounds.width) as f32,
437                    (self.bounds.y + self.bounds.height) as f32,
438                ),
439                -1.0,
440            )),
441            ScaleMode::RoundInner | ScaleMode::RoundOuter => None,
442        }
443    }
444
445    fn linear_tick_point(&self, index: usize) -> PointF {
446        match self.mode {
447            ScaleMode::HorizontalBottom => PointF::new(
448                self.axis_position(self.bounds.x, self.bounds.width, index) as f32,
449                self.bounds.y as f32,
450            ),
451            ScaleMode::HorizontalTop => PointF::new(
452                self.axis_position(self.bounds.x, self.bounds.width, index) as f32,
453                (self.bounds.y + self.bounds.height) as f32,
454            ),
455            ScaleMode::VerticalRight => PointF::new(
456                self.bounds.x as f32,
457                self.axis_position(self.bounds.y, self.bounds.height, index) as f32,
458            ),
459            ScaleMode::VerticalLeft => PointF::new(
460                (self.bounds.x + self.bounds.width) as f32,
461                self.axis_position(self.bounds.y, self.bounds.height, index) as f32,
462            ),
463            ScaleMode::RoundInner | ScaleMode::RoundOuter => PointF::new(0.0, 0.0),
464        }
465    }
466
467    fn round_center_and_axis_radius(&self) -> Option<(PointF, f32)> {
468        let radius = self.bounds.width.min(self.bounds.height) as f32 * 0.5;
469        if radius <= 0.0 {
470            return None;
471        }
472
473        let width = self.stroke_width().unwrap_or(1.0);
474        let axis_radius = match self.mode {
475            ScaleMode::RoundInner => radius - width,
476            ScaleMode::RoundOuter => radius - MAJOR_TICK_LENGTH as f32 - width,
477            _ => radius,
478        };
479        if axis_radius <= 0.0 {
480            return None;
481        }
482
483        Some((
484            PointF::new(
485                self.bounds.x as f32 + self.bounds.width as f32 * 0.5,
486                self.bounds.y as f32 + self.bounds.height as f32 * 0.5,
487            ),
488            axis_radius,
489        ))
490    }
491
492    fn axis_position(&self, origin: i32, length: i32, index: usize) -> i32 {
493        let count = usize::from(self.tick_count);
494        if count <= 1 {
495            return origin;
496        }
497        let den = (count - 1) as i64;
498        (i64::from(origin) + i64::from(length) * index as i64 / den) as i32
499    }
500
501    fn tick_value(&self, index: usize) -> i32 {
502        let count = usize::from(self.tick_count);
503        if count <= 1 {
504            return self.min;
505        }
506        let den = (count - 1) as i64;
507        let value =
508            i64::from(self.min) + (i64::from(self.max) - i64::from(self.min)) * index as i64 / den;
509        value as i32
510    }
511
512    fn tick_angle(&self, index: usize) -> i32 {
513        let count = usize::from(self.tick_count);
514        if count <= 1 {
515            return self.rotation;
516        }
517        let den = (count - 1) as i64;
518        self.rotation + (i64::from(self.angle_range) * index as i64 / den) as i32
519    }
520
521    fn is_major_tick(&self, index: usize) -> bool {
522        self.major_tick_every != 0 && index.is_multiple_of(usize::from(self.major_tick_every))
523    }
524
525    fn tick_length(&self, major: bool) -> i32 {
526        if major {
527            MAJOR_TICK_LENGTH
528        } else {
529            MINOR_TICK_LENGTH
530        }
531    }
532
533    fn stroke_width(&self) -> Option<f32> {
534        (self.style.border_width > 0).then_some(self.style.border_width as f32)
535    }
536}
537
538impl Widget for Scale {
539    fn bounds(&self) -> Rect {
540        self.bounds
541    }
542
543    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
544        Some(&mut self.font)
545    }
546
547    fn draw(&self, renderer: &mut dyn Renderer) {
548        if self.bounds.width <= 0 || self.bounds.height <= 0 || self.style.alpha == 0 {
549            return;
550        }
551
552        match self.mode {
553            ScaleMode::RoundInner | ScaleMode::RoundOuter => self.draw_round(renderer),
554            ScaleMode::HorizontalBottom
555            | ScaleMode::HorizontalTop
556            | ScaleMode::VerticalLeft
557            | ScaleMode::VerticalRight => self.draw_linear(renderer),
558        }
559    }
560
561    fn handle_event(&mut self, _event: &Event) -> bool {
562        false
563    }
564
565    fn set_bounds(&mut self, bounds: Rect) {
566        self.bounds = bounds;
567    }
568}
569
570struct LabelBuf {
571    bytes: [u8; LABEL_BUF_LEN],
572    start: usize,
573}
574
575impl LabelBuf {
576    fn new(value: i32) -> Self {
577        let mut bytes = [0; LABEL_BUF_LEN];
578        let mut cursor = LABEL_BUF_LEN;
579        let mut n = i64::from(value);
580        let negative = n < 0;
581        if negative {
582            n = -n;
583        }
584
585        loop {
586            cursor -= 1;
587            bytes[cursor] = b'0' + (n % 10) as u8;
588            n /= 10;
589            if n == 0 {
590                break;
591            }
592        }
593
594        if negative {
595            cursor -= 1;
596            bytes[cursor] = b'-';
597        }
598
599        Self {
600            bytes,
601            start: cursor,
602        }
603    }
604
605    fn as_str(&self) -> &str {
606        core::str::from_utf8(&self.bytes[self.start..]).unwrap_or("")
607    }
608
609    fn pixel_width(&self) -> i32 {
610        (LABEL_BUF_LEN - self.start) as i32 * LABEL_CHAR_WIDTH
611    }
612}
613
614fn normalize_turn(degrees: i32) -> i32 {
615    degrees.rem_euclid(360)
616}
617
618fn angle_vector(degrees: i32) -> (f32, f32) {
619    let radians = normalize_turn(degrees) as f32 * core::f32::consts::PI / 180.0;
620    (cosf(radians), sinf(radians))
621}
622
623fn polar(center: PointF, cos_t: f32, sin_t: f32, radius: f32) -> PointF {
624    PointF::new(center.x + cos_t * radius, center.y + sin_t * radius)
625}
626
627#[cfg(test)]
628mod tests {
629    extern crate alloc;
630
631    use super::*;
632    use alloc::string::String;
633    use alloc::vec::Vec;
634    use rlvgl_core::font::ShapedText;
635
636    #[derive(Clone, Copy, Debug, PartialEq)]
637    struct Stroke {
638        from: PointF,
639        to: PointF,
640        width: f32,
641        color: Color,
642    }
643
644    #[derive(Clone, Debug, PartialEq, Eq)]
645    struct Text {
646        position: (i32, i32),
647        content: String,
648        color: Color,
649    }
650
651    struct RecordingRenderer {
652        strokes: Vec<Stroke>,
653        texts: Vec<Text>,
654        arcs: usize,
655    }
656
657    impl RecordingRenderer {
658        fn new() -> Self {
659            Self {
660                strokes: Vec::new(),
661                texts: Vec::new(),
662                arcs: 0,
663            }
664        }
665
666        fn strokes_with_color(&self, color: Color) -> Vec<Stroke> {
667            self.strokes
668                .iter()
669                .copied()
670                .filter(|stroke| stroke.color == color)
671                .collect()
672        }
673    }
674
675    impl Renderer for RecordingRenderer {
676        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
677
678        fn draw_text(&mut self, position: (i32, i32), text: &str, color: Color) {
679            self.texts.push(Text {
680                position,
681                content: String::from(text),
682                color,
683            });
684        }
685
686        fn draw_text_shaped(&mut self, shaped: &ShapedText<'_>, _origin: (i32, i32), color: Color) {
687            self.texts.push(Text {
688                position: (shaped.bounds.x, shaped.bounds.y),
689                content: shaped.glyphs.iter().map(|glyph| glyph.ch).collect(),
690                color,
691            });
692        }
693
694        #[allow(clippy::too_many_arguments)]
695        fn fill_arc_aa(
696            &mut self,
697            _center: PointF,
698            _r_outer: f32,
699            _r_inner: f32,
700            _start_cos: f32,
701            _start_sin: f32,
702            _end_cos: f32,
703            _end_sin: f32,
704            _extent: f32,
705            _color: Color,
706        ) {
707            self.arcs += 1;
708        }
709
710        fn stroke_line_aa(&mut self, from: PointF, to: PointF, width: f32, color: Color) {
711            self.strokes.push(Stroke {
712                from,
713                to,
714                width,
715                color,
716            });
717        }
718    }
719
720    #[test]
721    fn linear_tick_classification_splits_major_and_minor() {
722        let mut scale = Scale::new(rect(10, 20, 100, 40));
723        scale.set_range(0, 100);
724        scale.set_tick_count(5);
725        scale.set_major_tick_every(2);
726        scale.set_label_visible(false);
727        scale.style.border_color = Color(1, 2, 3, 255);
728        scale.style.border_width = 2;
729        scale.major_tick_color = Color(10, 20, 30, 255);
730        scale.minor_tick_color = Color(40, 50, 60, 255);
731
732        let mut renderer = RecordingRenderer::new();
733        scale.draw(&mut renderer);
734
735        let major = renderer.strokes_with_color(scale.major_tick_color);
736        let minor = renderer.strokes_with_color(scale.minor_tick_color);
737        assert_eq!(major.len(), 3);
738        assert_eq!(minor.len(), 2);
739        assert_eq!(
740            major[0],
741            Stroke {
742                from: PointF::new(10.0, 20.0),
743                to: PointF::new(10.0, 29.0),
744                width: 2.0,
745                color: scale.major_tick_color,
746            }
747        );
748        assert_eq!(
749            minor[0],
750            Stroke {
751                from: PointF::new(35.0, 20.0),
752                to: PointF::new(35.0, 25.0),
753                width: 2.0,
754                color: scale.minor_tick_color,
755            }
756        );
757        assert!(renderer.texts.is_empty());
758    }
759
760    #[test]
761    fn linear_modes_place_ticks_on_the_configured_side() {
762        let cases = [
763            (
764                ScaleMode::HorizontalBottom,
765                PointF::new(0.0, 0.0),
766                PointF::new(0.0, 9.0),
767            ),
768            (
769                ScaleMode::HorizontalTop,
770                PointF::new(0.0, 50.0),
771                PointF::new(0.0, 41.0),
772            ),
773            (
774                ScaleMode::VerticalRight,
775                PointF::new(0.0, 0.0),
776                PointF::new(9.0, 0.0),
777            ),
778            (
779                ScaleMode::VerticalLeft,
780                PointF::new(100.0, 0.0),
781                PointF::new(91.0, 0.0),
782            ),
783        ];
784
785        for (mode, from, to) in cases {
786            let mut scale = Scale::new(rect(0, 0, 100, 50));
787            scale.set_range(0, 10);
788            scale.set_mode(mode);
789            scale.set_tick_count(2);
790            scale.set_label_visible(false);
791            scale.major_tick_color = Color(11, 22, 33, 255);
792
793            let mut renderer = RecordingRenderer::new();
794            scale.draw(&mut renderer);
795
796            let major = renderer.strokes_with_color(scale.major_tick_color);
797            assert_eq!(major[0].from, from);
798            assert_eq!(major[0].to, to);
799        }
800    }
801
802    #[test]
803    fn labels_follow_major_ticks_and_can_be_hidden() {
804        let mut scale = Scale::new(rect(0, 0, 120, 30));
805        scale.set_range(-10, 10);
806        scale.set_tick_count(3);
807        scale.set_major_tick_every(1);
808        scale.major_tick_color = Color(17, 18, 19, 255);
809        scale.label_color = Color(7, 8, 9, 255);
810
811        let mut renderer = RecordingRenderer::new();
812        scale.draw(&mut renderer);
813        assert_eq!(renderer.texts.len(), 3);
814        assert_eq!(renderer.texts[0].content, "-10");
815        assert_eq!(renderer.texts[1].content, "0");
816        assert_eq!(renderer.texts[2].content, "10");
817
818        scale.set_label_visible(false);
819        let mut hidden = RecordingRenderer::new();
820        scale.draw(&mut hidden);
821        assert!(hidden.texts.is_empty());
822        assert_eq!(hidden.strokes_with_color(scale.major_tick_color).len(), 3);
823    }
824
825    #[test]
826    fn major_every_zero_disables_major_ticks_and_labels() {
827        let mut scale = Scale::new(rect(0, 0, 90, 30));
828        scale.set_range(0, 30);
829        scale.set_tick_count(4);
830        scale.set_major_tick_every(0);
831        scale.set_label_visible(true);
832        scale.major_tick_color = Color(1, 1, 1, 255);
833        scale.minor_tick_color = Color(2, 2, 2, 255);
834
835        let mut renderer = RecordingRenderer::new();
836        scale.draw(&mut renderer);
837
838        assert!(renderer.texts.is_empty());
839        assert!(
840            renderer
841                .strokes_with_color(scale.major_tick_color)
842                .is_empty()
843        );
844        assert_eq!(renderer.strokes_with_color(scale.minor_tick_color).len(), 4);
845    }
846
847    #[test]
848    fn round_modes_draw_ticks_on_inner_and_outer_radial_sides() {
849        let mut outer = Scale::new(rect(0, 0, 100, 100));
850        outer.set_range(0, 10);
851        outer.set_mode(ScaleMode::RoundOuter);
852        outer.set_rotation(0);
853        outer.set_angle_range(90);
854        outer.set_tick_count(2);
855        outer.set_label_visible(false);
856        outer.major_tick_color = Color(3, 4, 5, 255);
857
858        let mut outer_renderer = RecordingRenderer::new();
859        outer.draw(&mut outer_renderer);
860        let outer_major = outer_renderer.strokes_with_color(outer.major_tick_color);
861        assert_eq!(outer_renderer.arcs, 1);
862        assert_eq!(outer_major[0].from, PointF::new(90.0, 50.0));
863        assert_eq!(outer_major[0].to, PointF::new(99.0, 50.0));
864
865        let mut inner = outer;
866        inner.set_mode(ScaleMode::RoundInner);
867        let mut inner_renderer = RecordingRenderer::new();
868        inner.draw(&mut inner_renderer);
869        let inner_major = inner_renderer.strokes_with_color(inner.major_tick_color);
870        assert_eq!(inner_renderer.arcs, 1);
871        assert_eq!(inner_major[0].from, PointF::new(99.0, 50.0));
872        assert_eq!(inner_major[0].to, PointF::new(90.0, 50.0));
873    }
874
875    #[test]
876    fn set_bounds_adopts_layout_bounds() {
877        let mut scale = Scale::new(rect(1, 2, 3, 4));
878        scale.set_range(0, 1);
879
880        scale.set_bounds(rect(10, 11, 120, 32));
881
882        assert_eq!(scale.bounds(), rect(10, 11, 120, 32));
883    }
884
885    fn rect(x: i32, y: i32, width: i32, height: i32) -> Rect {
886        Rect {
887            x,
888            y,
889            width,
890            height,
891        }
892    }
893}