// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use std::sync::Arc; use crate::types::{ Point, Rect }; use crate::render::Canvas; use super::Element; mod theme; #[ cfg( test ) ] mod tests; /// Which axis a slider tracks. Used by input dispatch to pick the right /// `value_from_*_in_rect` formula for a [`Slider`] (horizontal) or /// [`crate::widget::vslider::VSlider`] (vertical). #[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] pub enum SliderAxis { Horizontal, Vertical, } /// Compute the slider value `[0.0, 1.0]` from a pointer position within a /// slider's layout rect, dispatching to the axis-specific formula. /// /// Exposed alongside [`value_from_x_in_rect`] so `input.rs` can drive both /// [`Slider`] and [`crate::widget::vslider::VSlider`] through the same call /// site by consulting the [`SliderAxis`] stored in the widget's handler /// snapshot. pub fn value_from_pos_in_rect( rect: Rect, pos: Point, axis: SliderAxis, thumb_px: f32 ) -> f32 { match axis { // The vertical branch maps against the full rect height and takes no // thumb inset, so `thumb_px` is unused there. SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x, thumb_px ), SliderAxis::Vertical => crate::widget::vslider::value_from_y_in_rect( rect, pos.y ), } } /// Intersect `inner` with the `saved` outer clip and return the rect /// list to install with [`crate::render::Canvas::set_clip_rects`]. /// /// `saved` is the snapshot returned by /// [`crate::render::Canvas::clip_bounds`]: empty means "no clip is /// installed" (paint everywhere), non-empty is the outer scissor list /// — typically the partial-redraw damage rects. /// /// `Slider` / [`crate::widget::vslider::VSlider`] use this when they /// need to clip the active-side fill paint to a band tighter than the /// widget rect. Calling `set_clip_rects( &[ band ] )` directly would /// REPLACE the outer clip, causing the fill to repaint outside the /// damage region during partial redraws and clobbering pixels (most /// visibly the thumb's white interior left from the previous frame). /// Routing through this helper preserves the outer clip by /// intersecting with it. /// /// Returns `Vec::new()` when there is no overlap; callers must skip /// the paint entirely in that case — installing an empty rect list /// means "no clip" which would paint outside the damage region. pub( crate ) fn intersect_clip( saved: &[ Rect ], inner: Rect ) -> Vec { if saved.is_empty() { return vec![ inner ]; } saved.iter().filter_map( |r| { let x0 = inner.x.max( r.x ); let y0 = inner.y.max( r.y ); let x1 = ( inner.x + inner.width ).min( r.x + r.width ); let y1 = ( inner.y + inner.height ).min( r.y + r.height ); if x1 <= x0 || y1 <= y0 { None } else { Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) } } ).collect() } /// Compute the slider value `[0.0, 1.0]` from a tap/drag x position within a /// slider's layout rect. Pure — depends only on `theme::THUMB_SIZE`. Lifted /// out of [`Slider`] so input handlers can call it directly from /// [`crate::widget::LaidOutWidget`] without needing the [`Element`] tree. pub fn value_from_x_in_rect( rect: Rect, x: f32, thumb_px: f32 ) -> f32 { let pad = thumb_px / 2.0; let track_start = rect.x + pad; let track_end = rect.x + rect.width - pad; let track_w = ( track_end - track_start ).max( 1.0 ); ( ( x - track_start ) / track_w ).clamp( 0.0, 1.0 ) } /// The slider thumb's design pixel size — the raw theme constant, used as a /// fallback when a widget-scaling-resolved size is not available (e.g. a /// handler snapshot built outside layout). Public for `test_support`, so the /// pure `value_from_*` helpers can be exercised with the real design thumb. pub fn thumb_design_px() -> f32 { theme::THUMB_SIZE } /// Resolve the thumb size through the widget-scaling mode. The layout pass /// stores this in the [`crate::widget::WidgetHandlers::Slider`] snapshot so /// the input path maps clicks against the same thumb the renderer drew. pub( crate ) fn resolved_thumb_px( canvas: &Canvas ) -> f32 { canvas.geom_px( theme::THUMB_SIZE ) } /// A horizontal slider for selecting a value in a range. /// /// The track defaults to a theme-resolved surface; pass /// [`Self::track_paint`] to override with a custom /// [`Paint`](crate::theme::Paint) — typically a multi-stop linear /// gradient for a hue / spectrum picker. When `track_paint` is set /// the active-side fill is suppressed (the spectrum already /// communicates the position visually through colour). /// /// ```rust,no_run /// # use ltk::{ slider, Slider }; /// # #[ derive( Clone ) ] enum Msg { SetBrightness( f32 ) } /// # struct App { brightness: f32 } /// # impl App { fn _ex( &self ) -> Slider { /// slider( self.brightness ) /// .on_change( |v| Msg::SetBrightness( v ) ) /// # }} /// ``` pub struct Slider { /// Current value in `[0.0, 1.0]`. pub( crate ) value: f32, /// Callback invoked with the new value when the slider is dragged. /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// handler snapshot for O(1) dispatch on input events. pub( crate ) on_change: Option Msg>>, /// Theme slot id for the track background pill. Defaults to the /// generic `surface-slider-track`; override per-instance to opt /// into the `-flat` (no per-surface backdrop) variant when the /// slider already lives inside a panel-wide blur, or any other /// custom slot. pub( crate ) track_surface: &'static str, /// Theme slot id for the rising / leftward fill. Same shape as /// [`Self::track_surface`] but for the active portion. pub( crate ) fill_surface: &'static str, /// Paint the thumb with the active palette's `accent` colour and /// a thicker white border (accent inner pill + 4 px white ring) /// instead of the default white-on-text-primary thumb. pub( crate ) accent_thumb: bool, /// Override the track paint with a custom /// [`Paint`](crate::theme::Paint) — typically a /// [`Paint::Linear`](crate::theme::Paint::Linear) for spectrum / /// hue pickers. When set, the active-side fill is suppressed /// because a spectrum already conveys position information /// through colour and the thumb shows where the user is. pub( crate ) track_paint: Option, } impl Slider { /// Create a slider with the given value (clamped to `[0.0, 1.0]`). pub fn new( value: f32 ) -> Self { Self { value: value.clamp( 0.0, 1.0 ), on_change: None, track_surface: theme::SURFACE_TRACK, fill_surface: theme::SURFACE_FILL, accent_thumb: false, track_paint: None, } } /// Override the track paint with a custom /// [`Paint`](crate::theme::Paint) — e.g. a multi-stop linear /// gradient for a hue / spectrum picker. Disables the active- /// side fill (the spectrum already encodes the position /// visually). pub fn track_paint( mut self, paint: crate::theme::Paint ) -> Self { self.track_paint = Some( paint ); self } /// Set the callback invoked when the slider value changes. pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self { self.on_change = Some( Arc::new( f ) ); self } /// Override the theme slot id used to paint the track background /// — pass `surface-slider-track-flat` (or any other surface) to /// drop the per-instance backdrop blur when the slider already /// lives inside a panel-wide blur. See /// [`crate::widget::vslider::VSlider::track_surface`] for the use /// case. pub fn track_surface( mut self, id: &'static str ) -> Self { self.track_surface = id; self } /// Override the theme slot id used to paint the fill. Mirrors /// [`Self::track_surface`]. pub fn fill_surface( mut self, id: &'static str ) -> Self { self.fill_surface = id; self } /// Switch the thumb to the accent-pill style: accent-coloured inner /// + 4 px white border. Default thumb stays white-on-text-primary /// for shells that haven't opted in. pub fn accent_thumb( mut self, on: bool ) -> Self { self.accent_thumb = on; self } /// Return the preferred `(width, height)`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { ( max_width, canvas.geom_px( theme::HEIGHT ) ) } /// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`. /// Uses the design thumb size; the live input path resolves the thumb /// through the widget-scaling mode via the handler snapshot instead. pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32 { value_from_x_in_rect( rect, x, thumb_design_px() ) } /// Thumb border stroke is centered on the thumb edge (which touches the /// widget's left/right edges at value 0 / 1), so half the stroke width /// plus ~1 px of antialiasing sits outside `rect`. pub fn paint_bounds( &self, rect: Rect ) -> Rect { let border_w = if self.accent_thumb { theme::ACCENT_THUMB_BORDER_W } else { theme::THUMB_BORDER_W }; rect.expand( ( border_w * 0.5 + 1.0 ).max( surface_shadow_margin( self.track_surface ) ) ) } /// Draw the slider into `canvas` at `rect`. /// /// Track + fill paint through theme slots ([`Self::track_surface`] /// / [`Self::fill_surface`]) when those slots resolve in the /// active theme — the track pill spans the full inner width and /// the fill is anchored to the same pill but scissor-clipped to /// the left band so insets stay positioned against the track rect /// rather than a shrinking fill rect. Falls back to /// `theme::track_bg()` / `theme::track_fill()` when the active /// theme has no entry for either slot — a bare-bones third-party /// theme still paints a usable slider. pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) { // Resolve the thumb / track through the widget-scaling mode. The // pad MUST match `value_from_x_in_rect`, which the input path feeds // the same mode-resolved thumb size via the handler snapshot. let thumb = canvas.geom_px( theme::THUMB_SIZE ); let track_h = canvas.geom_px( theme::TRACK_H ); let pad = thumb / 2.0; let track_y = rect.y + (rect.height - track_h) / 2.0; let track_start = rect.x + pad; let track_end = rect.x + rect.width - pad; let track_w = track_end - track_start; let radius_bg = track_h / 2.0; // Background track — full pill across the inner span. let track_rect = Rect { x: track_start, y: track_y, width: track_w, height: track_h, }; if let Some( paint ) = self.track_paint.as_ref() { // Custom paint override (hue spectrum, custom gradient, …) // short-circuits the theme-slot resolution. The // active-side fill is suppressed below because a // spectrum already encodes the position via colour. canvas.fill_paint_rect( track_rect, paint, radius_bg ); } else if let Some( ( surf, outer ) ) = crate::theme::resolve_surface( self.track_surface ) { canvas.fill_surface ( track_rect, &surf.fill, &outer, &surf.inset_shadows, radius_bg, ); } else { canvas.fill_rect( track_rect, theme::track_bg(), radius_bg ); } // Filled portion. Paint the surface across the FULL track rect // and clip the visible band to the left-aligned slice. This // keeps inset shadows / glass rims anchored to the track pill // instead of to a shrinking fill rect — the highlights don't // shift as the user drags. // // Suppressed entirely when `track_paint` is set: a custom // spectrum already conveys position through colour, and a // solid accent band painted over part of it would obscure // half the spectrum and read as a rendering bug. let fill_w = track_w * self.value; if self.track_paint.is_none() && fill_w > 0.5 { let visible = Rect { x: track_start, y: track_y, width: fill_w, height: track_h, }; let saved_clip = canvas.clip_bounds(); let band = intersect_clip( &saved_clip, visible ); if !band.is_empty() { canvas.set_clip_rects( &band ); if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface ) { canvas.fill_paint_rect( track_rect, &surf.fill, radius_bg ); // Drop insets with negative-Y offset — they live // near the top of the track pill and would slice // the fill at the active band edge. See // `VSlider::draw` for the full rationale. for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 ) { canvas.fill_shadow_inset( track_rect, inset, radius_bg ); } } else { canvas.fill_rect( visible, theme::track_fill(), radius_bg ); } canvas.set_clip_rects( &saved_clip ); } } // Thumb. let thumb_cx = track_start + fill_w; let thumb_cy = rect.y + rect.height / 2.0; if self.accent_thumb { // Inner pill paints with the track's active fill surface // so the thumb's centre always matches the line colour. let outer_r = theme::ACCENT_THUMB_OUTER / 2.0; let inner_size = ( theme::ACCENT_THUMB_OUTER - 2.0 * theme::ACCENT_THUMB_BORDER_W ).max( 0.0 ); let inner_r = inner_size / 2.0; let outer_rect = Rect { x: thumb_cx - outer_r, y: thumb_cy - outer_r, width: theme::ACCENT_THUMB_OUTER, height: theme::ACCENT_THUMB_OUTER, }; let inner_rect = Rect { x: thumb_cx - inner_r, y: thumb_cy - inner_r, width: inner_size, height: inner_size, }; canvas.fill_rect( outer_rect, crate::theme::palette().bg, outer_r ); if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface ) { canvas.fill_paint_rect( inner_rect, &surf.fill, inner_r ); } else { canvas.fill_rect( inner_rect, crate::theme::palette().accent, inner_r ); } } else { let thumb_r = thumb / 2.0; let thumb_rect = Rect { x: thumb_cx - thumb_r, y: thumb_cy - thumb_r, width: thumb, height: thumb, }; canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r ); canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r ); } } pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Slider where U: Clone + 'static, Msg: 'static, { // Wrap the existing Arc Msg> in a fresh closure // that pipes its result through the user's mapper. The original // closure is captured by the new one through `Arc::clone` so the // original Slider's storage stays valid. let on_change = self.on_change.map( |old| -> Arc U> { let mapper = Arc::clone( f ); Arc::new( move |v| ( *mapper )( ( *old )( v ) ) ) } ); Slider { value: self.value, on_change, track_surface: self.track_surface, fill_surface: self.fill_surface, accent_thumb: self.accent_thumb, track_paint: self.track_paint, } } } /// Create a [`Slider`] with the given value (clamped to `[0.0, 1.0]`). pub fn slider( value: f32 ) -> Slider { Slider::new( value ) } fn surface_shadow_margin( surface: &str ) -> f32 { crate::theme::resolve_surface( surface ) .map( |( _, shadows )| { shadows.iter() .map( |s| s.offset[0].abs().max( s.offset[1].abs() ) + s.blur.max( 0.0 ) + s.spread.max( 0.0 ) + 1.0 ) .fold( 0.0, f32::max ) } ) .unwrap_or( 0.0 ) } impl From> for Element { fn from( s: Slider ) -> Self { Element::Slider( s ) } }