Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
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 ) -> f32
|
|
{
|
|
match axis
|
|
{
|
|
SliderAxis::Horizontal => value_from_x_in_rect( rect, pos.x ),
|
|
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<Rect>
|
|
{
|
|
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 ) -> f32
|
|
{
|
|
let pad = theme::THUMB_SIZE / 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 )
|
|
}
|
|
|
|
/// 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<Msg> {
|
|
/// slider( self.brightness )
|
|
/// .on_change( |v| Msg::SetBrightness( v ) )
|
|
/// # }}
|
|
/// ```
|
|
pub struct Slider<Msg: Clone>
|
|
{
|
|
/// Current value in `[0.0, 1.0]`.
|
|
pub 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 on_change: Option<Arc<dyn Fn(f32) -> 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 track_surface: &'static str,
|
|
/// Theme slot id for the rising / leftward fill. Same shape as
|
|
/// [`Self::track_surface`] but for the active portion.
|
|
pub 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 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 track_paint: Option<crate::theme::Paint>,
|
|
}
|
|
|
|
impl<Msg: Clone> Slider<Msg>
|
|
{
|
|
/// 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, theme::HEIGHT )
|
|
}
|
|
|
|
/// Compute the value `[0.0, 1.0]` from a tap/drag x position within `rect`.
|
|
pub fn value_from_x( &self, rect: Rect, x: f32 ) -> f32
|
|
{
|
|
value_from_x_in_rect( rect, x )
|
|
}
|
|
|
|
/// 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 )
|
|
{
|
|
let pad = theme::THUMB_SIZE / 2.0;
|
|
let track_y = rect.y + (rect.height - theme::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 = theme::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: theme::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: theme::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 = theme::THUMB_SIZE / 2.0;
|
|
let thumb_rect = Rect
|
|
{
|
|
x: thumb_cx - thumb_r,
|
|
y: thumb_cy - thumb_r,
|
|
width: theme::THUMB_SIZE,
|
|
height: theme::THUMB_SIZE,
|
|
};
|
|
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<U>( self, f: &super::MapFn<Msg, U> ) -> Slider<U>
|
|
where
|
|
U: Clone + 'static,
|
|
Msg: 'static,
|
|
{
|
|
// Wrap the existing Arc<dyn Fn(f32) -> 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<dyn Fn( f32 ) -> 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<Msg: Clone>( value: f32 ) -> Slider<Msg>
|
|
{
|
|
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<Msg: Clone + 'static> From<Slider<Msg>> for Element<Msg>
|
|
{
|
|
fn from( s: Slider<Msg> ) -> Self
|
|
{
|
|
Element::Slider( s )
|
|
}
|
|
}
|