zest-widget 0.1.1

Standard widget library for the zest GUI framework.
Documentation
//! Horizontal value slider: a track with a filled portion and a draggable
//! knob.
//!
//! Immediate-mode: the host owns the `f32` value and rebuilds the widget
//! each frame, passing the current value to [`Slider::new`]. On both
//! `TouchPhase::Down` and `TouchPhase::Moved` the value is derived from the
//! touch x within the track, so no drag-origin state is kept. It is clamped
//! to the range and emitted via [`on_change`](Slider::on_change).
//!
//! Colors come from the theme's accent [`Component`](zest_theme::Component):
//! the filled portion uses `accent.base`, the unfilled track uses
//! `background.divider`, and the knob uses `accent.on_base` with an
//! `accent.border` outline.

use super::Widget;
use alloc::boxed::Box;
use core::marker::PhantomData;
use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
use zest_theme::Theme;

/// Track thickness in pixels.
const TRACK_THICKNESS: u32 = 6;
/// Knob radius in pixels.
const KNOB_RADIUS: u32 = 9;
/// Default intrinsic width when sized `Shrink`.
const INTRINSIC_W: u32 = 160;

/// Horizontal value slider. Host owns the value; dragging emits the new
/// clamped value through [`on_change`](Slider::on_change).
pub struct Slider<'a, C: PixelColor, M: Clone> {
    rect: Rectangle,
    id: Option<WidgetId>,
    value: f32,
    min: f32,
    max: f32,
    step: Option<f32>,
    on_change: Option<Box<dyn Fn(f32) -> M + 'a>>,
    focused: bool,
    pressed: bool,
    width: Length,
    height: Length,
    _color: PhantomData<C>,
}

impl<'a, C: PixelColor, M: Clone> Slider<'a, C, M> {
    /// New slider showing `value`. Default range is `0.0..=1.0`. Position
    /// and size are assigned by the parent container via `arrange`.
    pub fn new(value: f32) -> Self {
        Self {
            rect: Rectangle::zero(),
            id: None,
            value,
            min: 0.0,
            max: 1.0,
            step: None,
            on_change: None,
            focused: false,
            pressed: false,
            width: Length::Fill,
            height: Length::Fixed(2 * KNOB_RADIUS),
            _color: PhantomData,
        }
    }

    /// Inclusive value range. If `min >= max` the range collapses
    /// and the slider reports `min`.
    #[must_use]
    pub fn range(mut self, min: f32, max: f32) -> Self {
        self.min = min;
        self.max = max;
        self
    }

    /// Set a stable id so this slider can participate in focus traversal.
    #[must_use]
    pub fn id(mut self, id: WidgetId) -> Self {
        self.id = Some(id);
        self
    }

    /// Step size used for semantic increment/decrement actions.
    #[must_use]
    pub fn step(mut self, step: f32) -> Self {
        self.step = Some(step.abs());
        self
    }

    /// Callback invoked with the new clamped value whenever the
    /// knob is pressed or dragged. Without it the slider is disabled and
    /// ignores touches.
    #[must_use]
    pub fn on_change<F: Fn(f32) -> M + 'a>(mut self, f: F) -> Self {
        self.on_change = Some(Box::new(f));
        self
    }

    /// Width sizing intent.
    #[must_use]
    pub fn width(mut self, width: impl Into<Length>) -> Self {
        self.width = width.into();
        self
    }

    /// Height sizing intent.
    #[must_use]
    pub fn height(mut self, height: impl Into<Length>) -> Self {
        self.height = height.into();
        self
    }

    /// True iff a change callback is bound. Disabled sliders render dimmed
    /// and ignore touches.
    pub fn is_enabled(&self) -> bool {
        self.on_change.is_some()
    }

    /// Fraction (0.0..=1.0) of the current value within the range.
    fn fraction(&self) -> f32 {
        if self.max <= self.min {
            0.0
        } else {
            ((self.value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
        }
    }

    /// Usable span for the knob center: leaves a `KNOB_RADIUS` margin at
    /// each end so the knob stays inside the widget.
    fn span(&self) -> (i32, i32) {
        let left = self.rect.top_left.x + KNOB_RADIUS as i32;
        let right = self.rect.top_left.x + self.rect.size.width as i32 - KNOB_RADIUS as i32;
        (left, right.max(left))
    }

    fn hit_test(&self, point: Point) -> bool {
        let tl = self.rect.top_left;
        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
    }

    /// Convert a touch x into a clamped value within the range.
    fn value_at(&self, x: i32) -> f32 {
        let (left, right) = self.span();
        let frac = if right <= left {
            0.0
        } else {
            ((x - left) as f32 / (right - left) as f32).clamp(0.0, 1.0)
        };
        let v = self.min + frac * (self.max - self.min);
        v.clamp(self.min.min(self.max), self.min.max(self.max))
    }

    fn action_step(&self) -> f32 {
        if let Some(step) = self.step
            && step > 0.0
        {
            return step;
        }

        let range = (self.max - self.min).abs();
        if range <= f32::EPSILON {
            0.0
        } else {
            (range / 20.0).max(f32::EPSILON)
        }
    }

    fn adjusted_value(&self, delta: f32) -> f32 {
        (self.value + delta).clamp(self.min.min(self.max), self.min.max(self.max))
    }
}

impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Slider<'a, C, M> {
    fn measure(&mut self, constraints: Constraints) -> Size {
        let w = self.width.resolve(INTRINSIC_W, constraints.max.width);
        let h = self.height.resolve(2 * KNOB_RADIUS, constraints.max.height);
        constraints.clamp(Size::new(w, h))
    }

    fn preferred_size(&self) -> (Length, Length) {
        (self.width, self.height)
    }

    fn arrange(&mut self, rect: Rectangle) {
        self.rect = rect;
    }

    fn rect(&self) -> Rectangle {
        self.rect
    }

    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
        let Some(cb) = self.on_change.as_ref() else {
            return None;
        };
        match phase {
            // Drive value directly from x on press and during drag.
            TouchPhase::Down => {
                if self.hit_test(point) {
                    self.pressed = true;
                    Some(cb(self.value_at(point.x)))
                } else {
                    self.pressed = false;
                    None
                }
            }
            TouchPhase::Moved => {
                // Hit-test Moved too: there's no per-widget drag ownership,
                // so without it a drag could be consumed by the wrong slider.
                if self.hit_test(point) {
                    self.pressed = true;
                    Some(cb(self.value_at(point.x)))
                } else {
                    self.pressed = false;
                    None
                }
            }
            TouchPhase::Up => {
                self.pressed = false;
                None
            }
        }
    }

    fn mark_pressed(&mut self, point: Point) {
        if self.is_enabled() && self.hit_test(point) {
            self.pressed = true;
        }
    }

    fn widget_id(&self) -> Option<WidgetId> {
        self.id
    }

    fn is_focusable(&self) -> bool {
        self.id.is_some() && self.is_enabled()
    }

    fn handle_action(&mut self, action: UiAction) -> Option<M> {
        if !self.is_enabled() {
            return None;
        }

        let step = self.action_step();
        match action {
            UiAction::Increment | UiAction::NavigateRight | UiAction::NavigateUp if step > 0.0 => {
                self.on_change
                    .as_ref()
                    .map(|cb| cb(self.adjusted_value(step)))
            }
            UiAction::Decrement | UiAction::NavigateLeft | UiAction::NavigateDown if step > 0.0 => {
                self.on_change
                    .as_ref()
                    .map(|cb| cb(self.adjusted_value(-step)))
            }
            _ => None,
        }
    }

    fn sync_focus(&mut self, focused: Option<WidgetId>) {
        self.focused = self.id.is_some() && self.id == focused;
    }

    fn focus_at(&self, point: Point) -> Option<WidgetId> {
        if self.is_focusable() && self.hit_test(point) {
            self.id
        } else {
            None
        }
    }

    fn draw<'t>(
        &self,
        renderer: &mut dyn Renderer<C>,
        theme: &Theme<'t, C>,
    ) -> Result<(), RenderError> {
        let accent = &theme.accent;
        let (left, right) = self.span();
        let cy = self.rect.top_left.y + self.rect.size.height as i32 / 2;
        let track_top = cy - TRACK_THICKNESS as i32 / 2;

        let track_color = theme.background.divider;
        let fill_color = if !self.is_enabled() {
            theme.background.divider
        } else if self.pressed {
            accent.pressed
        } else {
            accent.base
        };

        // Full track.
        let track = Rectangle::new(
            Point::new(left, track_top),
            Size::new((right - left).max(0) as u32, TRACK_THICKNESS),
        );
        renderer.fill_rect(track, track_color)?;

        // Knob center x from the current fraction.
        let knob_x = left + ((right - left) as f32 * self.fraction()) as i32;

        // Filled portion from the left up to the knob.
        let filled = Rectangle::new(
            Point::new(left, track_top),
            Size::new((knob_x - left).max(0) as u32, TRACK_THICKNESS),
        );
        renderer.fill_rect(filled, fill_color)?;

        // Knob: a border ring (full radius) with the knob fill punched on
        // top, leaving a 2px accent outline — the renderer has no
        // stroke_circle primitive.
        let knob_color = if self.is_enabled() {
            accent.on_base
        } else {
            theme.background.base
        };
        let center = Point::new(knob_x, cy);
        let knob_border = if self.focused {
            accent.base
        } else {
            accent.border
        };
        renderer.fill_circle(center, KNOB_RADIUS, knob_border)?;
        renderer.fill_circle(center, KNOB_RADIUS.saturating_sub(2), knob_color)?;

        Ok(())
    }
}