slider.rs raw

//! A slider: a value you drag or arrow along a track.
//!
//! `:value` is a fraction from 0 to 1, and the application scales it to
//! whatever it means. That is a decision rather than an omission: a slider
//! carrying its own minimum, maximum, and step would be doing arithmetic the
//! application is already doing, in units the framework cannot check — and the
//! fraction is what the accessibility layer wants anyway, as a percentage.
//!
//! Controlled, like every value in this framework: dragging sets the widget's
//! own position optimistically and reports
//! [`EventData::ValueChanged`](crate::event::EventData::ValueChanged); the
//! binding is what confirms it, and a programmatic set is silent so a binding
//! cannot loop.

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size};
use guiduck_scene::paint::{Brush, Color};
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;

/// How far an arrow key moves the value, as a fraction of the whole.
const ARROW_STEP: f64 = 0.05;

pub struct Slider {
    value: f64,
    tokens: Tokens,
    dirt: Dirt,
    emitted: Vec<EventData>,
    /// The width of the last layout, so a drag can turn a position into a
    /// fraction without asking the tree where it is.
    width: f64,
    focused: bool,
}

impl Default for Slider {
    fn default() -> Self {
        Self {
            value: 0.0,
            tokens: Tokens::default(),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
            width: 0.0,
            focused: false,
        }
    }
}

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

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

    /// Set the position without reporting it — the controlled contract, so a
    /// `:value` binding writing back what the user just did cannot loop.
    pub fn set_value(&mut self, value: f64) {
        let value = value.clamp(0.0, 1.0);
        if self.value != value {
            self.value = value;
            self.dirt.mark_paint();
        }
    }

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

    /// Move under user input: set the position and say so.
    fn move_to(&mut self, value: f64) {
        let value = value.clamp(0.0, 1.0);
        if self.value == value {
            return;
        }
        self.value = value;
        self.emitted.push(EventData::ValueChanged(value));
        self.dirt.mark_paint();
    }

    /// The fraction a point along the track stands for. The thumb has width,
    /// so the travel is shorter than the track: without this the value would
    /// never quite reach either end.
    fn fraction_at(&self, x: f64) -> f64 {
        let travel = (self.width - self.tokens.thumb_size).max(f64::EPSILON);
        ((x - self.tokens.thumb_size / 2.0) / travel).clamp(0.0, 1.0)
    }
}

struct Tokens {
    track: Brush,
    fill: Brush,
    thumb: Brush,
    thumb_size: f64,
    thickness: f64,
    corner_radius: f64,
    focus_ring: Brush,
    focus_ring_width: f64,
}

impl Tokens {
    fn blank() -> Self {
        Self {
            track: Color::TRANSPARENT.into(),
            fill: Color::TRANSPARENT.into(),
            thumb: Color::TRANSPARENT.into(),
            thumb_size: 0.0,
            thickness: 0.0,
            corner_radius: 0.0,
            focus_ring: 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.track, "track-color");
        reader.brush(&mut self.fill, "fill-color");
        reader.brush(&mut self.thumb, "thumb-color");
        reader.number(&mut self.thumb_size, "thumb-size");
        reader.number(&mut self.thickness, "thickness");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.brush(&mut self.focus_ring, "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("slider"));
        tokens
    }
}

impl Widget for Slider {
    fn measure(
        &mut self,
        _text: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        taffy::Size {
            width: known.width.unwrap_or(0.0),
            height: known.height.unwrap_or(self.tokens.thumb_size 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 thumb = self.tokens.thumb_size;
        let mid = size.height / 2.0;
        let half = self.tokens.thickness / 2.0;
        let track = Rect::new(0.0, mid - half, size.width, mid + half);
        fragment.fill(
            track.to_rounded_rect(self.tokens.corner_radius),
            self.tokens.track.clone(),
        );

        // The thumb's centre travels between the two half-widths, so the
        // filled part is measured to the centre rather than to the edge.
        let centre = thumb / 2.0 + (size.width - thumb).max(0.0) * self.value;
        if centre > 0.0 {
            fragment.fill(
                Rect::new(0.0, mid - half, centre, mid + half)
                    .to_rounded_rect(self.tokens.corner_radius),
                self.tokens.fill.clone(),
            );
        }

        let knob = Rect::new(
            centre - thumb / 2.0,
            mid - thumb / 2.0,
            centre + thumb / 2.0,
            mid + thumb / 2.0,
        );
        if self.focused && !is_transparent(&self.tokens.focus_ring) {
            let ring = knob.inflate(self.tokens.focus_ring_width, self.tokens.focus_ring_width);
            fragment.fill(
                ring.to_rounded_rect(ring.height() / 2.0),
                self.tokens.focus_ring.clone(),
            );
        }
        fragment.fill(knob.to_rounded_rect(thumb / 2.0), self.tokens.thumb.clone());
    }

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

    fn accessibility(&self, node: &mut accesskit::Node) {
        node.set_numeric_value(self.value * 100.0);
        node.set_min_numeric_value(0.0);
        node.set_max_numeric_value(100.0);
    }

    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 target = match key.key {
            Key::Left | Key::Down => self.value - ARROW_STEP,
            Key::Right | Key::Up => self.value + ARROW_STEP,
            Key::Home => 0.0,
            Key::End => 1.0,
            _ => return false,
        };
        // A key that cannot move it further is *not* consumed: at the end of
        // the range the keystroke should still reach Tab or a shortcut, the
        // same rule a scroll area follows.
        let before = self.value;
        self.move_to(target);
        self.value != before
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        event: &PointerEvent,
        _text: &mut TextContext,
        _clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        match kind {
            // A press jumps to where it landed and captures, so the drag that
            // follows keeps moving the value.
            EventKind::PointerDown | EventKind::PointerMove => {
                self.move_to(self.fraction_at(event.local.x));
                true
            }
            _ => false,
        }
    }

    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 {
        "slider"
    }

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

#[cfg(test)]
mod tests;