stepper.rs raw

//! A stepper: a number you nudge up and down.
//!
//! Unlike [`Slider`](super::Slider) this one carries a real range, because the
//! number it shows is the number the application means — there is nothing to
//! scale, and rendering "0.42" when the value is 42 would be a lie. So
//! `:value`, `:min`, `:max`, and `:step` are all its own.
//!
//! Controlled like every value here: a programmatic set is silent, user input
//! reports [`EventData::ValueChanged`](crate::event::EventData::ValueChanged).

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size, Stroke};
use guiduck_scene::paint::{Brush, Color};
use parley::{Alignment, AlignmentOptions, Layout, StyleProperty};
use taffy::AvailableSpace;

use super::{Widget, is_transparent};
use crate::dirty::Dirt;
use crate::event::{EventData, EventKind, Key, KeyInput, PointerEvent};
use crate::style::{ComputedStyle, StyleReader};
use crate::text::TextContext;
use crate::widget::CursorShape;

pub struct Stepper {
    value: f64,
    min: f64,
    max: f64,
    step: f64,
    font_size: f32,
    family: Option<String>,
    layout: Option<Layout<Brush>>,
    tokens: Tokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
    focused: bool,
    width: f64,
}

impl Default for Stepper {
    fn default() -> Self {
        Self {
            value: 0.0,
            min: 0.0,
            max: 100.0,
            step: 1.0,
            font_size: 14.0,
            family: None,
            layout: None,
            tokens: Tokens::default(),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
            focused: false,
            width: 0.0,
        }
    }
}

impl Stepper {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn value(mut self, value: f64) -> Self {
        self.set_value(value);
        self
    }

    pub fn set_value(&mut self, value: f64) {
        let value = self.clamped(value);
        if self.value != value {
            self.value = value;
            self.layout = None;
            self.dirt.mark_layout();
        }
    }

    pub fn get_value(&self) -> f64 {
        self.value
    }

    pub fn set_min(&mut self, min: f64) {
        if self.min != min {
            self.min = min;
            self.set_value(self.value);
        }
    }

    pub fn set_max(&mut self, max: f64) {
        if self.max != max {
            self.max = max;
            self.set_value(self.value);
        }
    }

    pub fn set_step(&mut self, step: f64) {
        // A step of zero would make the arrows do nothing for ever; the
        // sensible reading of "no step given" is one.
        let step = if step > 0.0 { step } else { 1.0 };
        if self.step != step {
            self.step = step;
        }
    }

    pub fn set_font_size(&mut self, size: f32) {
        if self.font_size != size {
            self.font_size = size;
            self.layout = None;
            self.dirt.mark_layout();
        }
    }

    pub fn set_family(&mut self, family: impl Into<String>) {
        let family = Some(family.into());
        if self.family != family {
            self.family = family;
            self.layout = None;
            self.dirt.mark_layout();
        }
    }

    fn clamped(&self, value: f64) -> f64 {
        if self.max >= self.min {
            value.clamp(self.min, self.max)
        } else {
            value
        }
    }

    /// Nudge under user input: move by whole steps from the minimum, so a
    /// range of 0..10 stepping by 3 offers 0, 3, 6, 9 rather than drifting off
    /// the grid after the first clamp.
    fn nudge(&mut self, steps: f64) {
        let target = self.clamped(self.value + steps * self.step);
        if self.value == target {
            return;
        }
        self.value = target;
        self.layout = None;
        self.emitted.push(EventData::ValueChanged(target));
        self.dirt.mark_layout();
    }

    fn text(&self) -> String {
        // Whole numbers read as whole numbers; a fractional step keeps enough
        // places to show what it did.
        if self.step.fract() == 0.0 && self.value.fract() == 0.0 {
            format!("{}", self.value as i64)
        } else {
            format!("{:.2}", self.value)
        }
    }

    fn layout_at(&mut self, text_cx: &mut TextContext) -> &Layout<Brush> {
        if self.layout.is_none() {
            let text = self.text();
            let mut builder =
                text_cx
                    .layout_cx
                    .ranged_builder(&mut text_cx.font_cx, &text, 1.0, true);
            if let Some(family) = &self.family {
                builder.push_default(StyleProperty::FontFamily(crate::text::font_family(family)));
            }
            builder.push_default(StyleProperty::FontSize(self.font_size));
            builder.push_default(StyleProperty::Brush(self.tokens.color.clone()));
            let mut layout: Layout<Brush> = builder.build(&text);
            layout.break_all_lines(None);
            layout.align(Alignment::Start, AlignmentOptions::default());
            self.layout = Some(layout);
        }
        self.layout.as_ref().expect("just built")
    }
}

struct Tokens {
    background: Brush,
    color: Brush,
    border_color: Brush,
    border_width: f64,
    corner_radius: f64,
    button_color: Brush,
    focus_ring_color: Brush,
    focus_ring_width: f64,
}

impl Tokens {
    fn blank() -> Self {
        Self {
            background: Color::TRANSPARENT.into(),
            color: Color::TRANSPARENT.into(),
            border_color: Color::TRANSPARENT.into(),
            border_width: 0.0,
            corner_radius: 0.0,
            button_color: Color::TRANSPARENT.into(),
            focus_ring_color: Color::TRANSPARENT.into(),
            focus_ring_width: 0.0,
        }
    }

    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.brush(&mut self.color, "color");
        reader.brush(&mut self.border_color, "border-color");
        reader.number(&mut self.border_width, "border-width");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.brush(&mut self.button_color, "button-color");
        reader.brush(&mut self.focus_ring_color, "focus-ring-color");
        reader.number(&mut self.focus_ring_width, "focus-ring-width");
        reader.changed()
    }
}

impl Default for Tokens {
    fn default() -> Self {
        let mut tokens = Self::blank();
        tokens.read(&super::fallback_style("stepper"));
        tokens
    }
}

/// Width of the up/down button column.
const BUTTON_W: f64 = 18.0;
const PAD: f64 = 6.0;

impl Widget for Stepper {
    fn measure(
        &mut self,
        text: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        let layout = self.layout_at(text);
        let (w, h) = (layout.width() as f64, layout.height() as f64);
        taffy::Size {
            width: known.width.unwrap_or((w + PAD * 2.0 + BUTTON_W) as f32),
            height: known.height.unwrap_or((h + PAD) as f32),
        }
    }

    fn finalize_layout(&mut self, _text: &mut TextContext, size: Size, _content: Size) {
        self.width = size.width;
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let bounds = Rect::new(0.0, 0.0, size.width, size.height);
        if self.focused && !is_transparent(&self.tokens.focus_ring_color) {
            let ring = bounds.inflate(self.tokens.focus_ring_width, self.tokens.focus_ring_width);
            fragment.fill(
                ring.to_rounded_rect(self.tokens.corner_radius + self.tokens.focus_ring_width),
                self.tokens.focus_ring_color.clone(),
            );
        }
        fragment.fill(
            bounds.to_rounded_rect(self.tokens.corner_radius),
            self.tokens.background.clone(),
        );
        if self.tokens.border_width > 0.0 {
            fragment.stroke(
                bounds.to_rounded_rect(self.tokens.corner_radius),
                self.tokens.border_color.clone(),
                Stroke::new(self.tokens.border_width),
            );
        }

        // Two little triangles standing for up and down.
        let x = size.width - BUTTON_W / 2.0;
        for (dir, cy) in [(-1.0, size.height * 0.3), (1.0, size.height * 0.7)] {
            let mut arrow = guiduck_scene::geom::BezPath::new();
            arrow.move_to((x - 4.0, cy - 2.0 * dir));
            arrow.line_to((x + 4.0, cy - 2.0 * dir));
            arrow.line_to((x, cy + 3.0 * dir));
            arrow.close_path();
            fragment.fill(arrow, self.tokens.button_color.clone());
        }

        if let Some(layout) = &self.layout {
            let y = (size.height - layout.height() as f64) / 2.0;
            crate::text::append_layout(fragment, layout, Point::new(PAD, y));
        }
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::SpinButton
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_numeric_value(self.value);
        node.set_min_numeric_value(self.min);
        node.set_max_numeric_value(self.max);
        node.set_value(self.text());
    }

    fn focusable(&self) -> bool {
        true
    }

    fn on_focus_changed(&mut self, focused: bool) {
        if self.focused != focused {
            self.focused = focused;
            self.dirt.mark_paint();
        }
    }

    fn cursor(&self, _local: Point) -> CursorShape {
        CursorShape::Pointer
    }

    fn on_key(
        &mut self,
        key: &KeyInput,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        let steps = match key.key {
            Key::Up => 1.0,
            Key::Down => -1.0,
            Key::PageUp => 10.0,
            Key::PageDown => -10.0,
            _ => return false,
        };
        let before = self.value;
        self.nudge(steps);
        self.value != before
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        event: &PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        if kind != EventKind::PointerDown || event.local.x < self.width - BUTTON_W {
            return false;
        }
        // Top half of the button column steps up, bottom half steps down.
        let midpoint = self
            .layout
            .as_ref()
            .map_or(0.0, |l| l.height() as f64)
            .max(4.0);
        let up = event.local.y < midpoint;
        self.nudge(if up { 1.0 } else { -1.0 });
        true
    }

    fn consumes_click(&self) -> bool {
        true
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn type_name(&self) -> &'static str {
        "stepper"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.layout = None;
            self.dirt.mark_layout();
        }
    }
}

#[cfg(test)]
mod tests;