ltk/widget/vslider/mod.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Length, Rect };
use crate::render::Canvas;
use super::Element;
use super::slider::intersect_clip;
mod theme;
#[ cfg( test ) ]
mod tests;
/// Compute the slider value `[0.0, 1.0]` from a tap/drag y position within a
/// slider's layout rect. `rect.top` maps to `1.0` and `rect.bottom` to `0.0`
/// — so the fill rises from the bottom as the user drags upward. Pure — no
/// theme / canvas dependency. Lifted out of [`VSlider`] so input handlers
/// can call it directly from [`crate::widget::LaidOutWidget`] without
/// needing the [`Element`] tree.
pub fn value_from_y_in_rect( rect: Rect, y: f32 ) -> f32
{
let track_h = rect.height.max( 1.0 );
( 1.0 - ( y - rect.y ) / track_h ).clamp( 0.0, 1.0 )
}
/// A vertical slider — a rounded pill that fills from bottom to top to
/// indicate its value.
///
/// Unlike [`Slider`](crate::Slider), which is horizontal and designed to
/// stretch across whatever width its parent allocates, a [`VSlider`] has
/// fixed pill dimensions (56 × 160 px by default) configurable via
/// [`VSlider::size`]. The widget reports those
/// dimensions as its preferred size and ignores the `max_width` the parent
/// offers — it is intrinsically sized, not filler.
///
/// The widget renders a rounded track in `palette.surface_alt` and, on top,
/// a rising pill in `palette.accent` whose height is proportional to
/// [`VSlider::value`]. No separate thumb is drawn; the top edge of the fill
/// itself acts as the value indicator.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
/// # struct App { volume: f32 }
/// # impl App { fn _ex( &self, speaker_rgba: Arc<Vec<u8>>, speaker_w: u32, speaker_h: u32 ) -> ltk::Element<Msg> {
/// use ltk::{ stack, vslider, img_widget, HAlign, VAlign };
///
/// // Plain vertical slider.
/// let _: ltk::VSlider<Msg> = vslider( self.volume ).on_change( Msg::SetVolume );
///
/// // With a speaker icon overlaid at the top. Stacked image children are
/// // non-interactive, so drag events still reach the slider underneath.
/// stack::<Msg>()
/// .push( vslider( self.volume ).on_change( Msg::SetVolume ) )
/// .push_aligned(
/// img_widget( speaker_rgba, speaker_w, speaker_h ),
/// HAlign::Center, VAlign::Top,
/// )
/// .into()
/// # }}
/// ```
pub struct VSlider<Msg: Clone>
{
/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
/// whole pill.
pub value: f32,
/// Width of the pill. Defaults to 56 px; accepts any [`Length`].
pub width: Length,
/// Height of the pill. Defaults to 160 px; accepts any [`Length`].
pub height: Length,
/// Callback invoked with the new value when the slider is tapped or
/// 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 unfilled track. Defaults to
/// `surface-slider-track`. Override with [`VSlider::track_surface`]
/// when the slider lives inside a panel that already provides its
/// own backdrop blur — point the slot at a `*-flat` variant
/// (no `backdrop` field) so the pipeline does not run a redundant
/// backdrop snapshot per slider per frame.
pub track_surface: &'static str,
/// Theme slot id for the filled portion. Same role as
/// [`Self::track_surface`] but for the rising fill.
pub fill_surface: &'static str,
}
impl<Msg: Clone> VSlider<Msg>
{
/// Create a vertical slider at the given value (clamped to `[0.0, 1.0]`).
pub fn new( value: f32 ) -> Self
{
Self
{
value: value.clamp( 0.0, 1.0 ),
width: Length::px( theme::WIDTH ),
height: Length::px( theme::HEIGHT ),
on_change: None,
track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL,
}
}
/// Override the theme slot id used for the unfilled track. See
/// [`Self::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 for the rising fill. See
/// [`Self::track_surface`] for the use case.
pub fn fill_surface( mut self, id: &'static str ) -> Self
{
self.fill_surface = id;
self
}
/// Override the pill size. Accepts logical `f32` pixels or any [`Length`]
/// variant (e.g. `Length::vw(16.0)` for 16 % of the viewport width). Both
/// are clamped to a minimum of `2.0` after viewport-relative values
/// resolve, so a rounded pill can always be drawn.
pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self
{
self.width = width.into();
self.height = height.into();
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
}
/// Return the preferred `(width, height)`. `max_width` is ignored — see
/// the type-level docs on intrinsic sizing.
pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let vp = canvas.viewport_layout();
let em = Length::EM_BASE_DEFAULT;
let w = self.width.resolve( vp, em ).max( 2.0 );
let h = self.height.resolve( vp, em ).max( 2.0 );
( w, h )
}
/// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`.
pub fn value_from_y( &self, rect: Rect, y: f32 ) -> f32
{
value_from_y_in_rect( rect, y )
}
/// VSlider paints strictly inside its layout rect — no hover halo, no
/// thumb overshoot. The partial-redraw path gets a tight bound.
pub fn paint_bounds( &self, rect: Rect ) -> Rect { rect }
/// Draw the slider into `canvas` at `rect`. The track fills `rect` as a
/// rounded pill; the value rises from the bottom edge.
///
/// The track and fill both resolve to Glass surfaces when the active
/// theme ships the `surface-slider-track` / `surface-slider-fill`
/// slots (the default does). When the slots are absent we fall back
/// to a flat pill in `palette.surface_alt` / `palette.accent` — this
/// is how a bare-bones third-party theme still paints a usable
/// slider without having to replicate the full inset-shadow stack.
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
{
let radius_bg = ( rect.width.min( rect.height ) ) / 2.0;
// Track outer shadow only — BG fill and insets are deferred to
// above the water line so the fill's silhouette AA doesn't pick
// up the track's translucent white as a 1-px rim.
if let Some( ( _surf, outer ) ) = crate::theme::resolve_surface( self.track_surface )
{
for shadow in &outer
{
canvas.fill_shadow_outer( rect, shadow, radius_bg );
}
}
else
{
canvas.fill_rect( rect, theme::track_bg(), radius_bg );
}
// Fill rises from the bottom as a "liquid level". Sub-pixel
// heights are skipped so we don't draw a hairline at value=0.
//
// The fill is rendered with the TRACK's full geometry (same
// rect, same radius) and scissor-clipped to the visible band
// at the bottom. The visible silhouette is the intersection of
// the track pill with the band, so:
//
// * sides and bottom of the fill follow the track's pill
// curve at all values — no "sticking out" at low fills;
// * top of the fill is a flat horizontal line — the water
// level — at all values, not a droplet cap;
// * inset shadows / backdrop of the fill's Glass surface are
// anchored to the track rect, not to a shrinking fill rect,
// so the rim / highlight geometry stays stable as the user
// drags. Only the clip band changes with value.
//
// The scissor is save/restored via `canvas.clip_bounds()` so
// the tighter clip does not stomp on any outer partial-redraw
// scissor.
let fill_h = ( rect.height * self.value ).clamp( 0.0, rect.height );
if fill_h > 0.5
{
let visible = Rect
{
x: rect.x,
y: rect.y + rect.height - fill_h,
width: rect.width,
height: fill_h,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, visible );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
// Fill paint + the fill surface's bottom-biased insets.
//
// Any inset with a negative Y offset (the top-left
// Glass highlight, `offset = [-3.6, -3.6]` in the
// default theme) lives near the TOP rim of the full
// track pill. With the surface anchored to the track
// rect and clipped to the water band, that highlight
// would be sliced by the scissor exactly at the water
// line, painting a visibly rectangular bright/dark
// edge across the liquid. Bottom-biased insets
// (offset.y >= 0) live near the track's bottom curve,
// always inside the visible band regardless of level,
// so their rim is continuous.
//
// Outer shadows / backdrop are dropped too: outer
// shadows would only be visible outside the fill
// silhouette (and the scissor kills them anyway);
// re-running the backdrop blur on the track rect
// every time the value changes is expensive and
// produces the same visible result as letting the
// track's own Glass backdrop show through.
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
{
canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 )
{
canvas.fill_shadow_inset( rect, inset, radius_bg );
}
}
else
{
canvas.fill_rect( rect, theme::track_fill(), radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
// Track BG + insets, clipped above the water line. Floor the
// height so the scissor doesn't overlap the fill scissor by
// 1 px when `fill_h` is fractional.
let above_h = ( rect.height - fill_h ).floor();
if above_h > 0.5
{
if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.track_surface )
{
let above = Rect
{
x: rect.x,
y: rect.y,
width: rect.width,
height: above_h,
};
let saved_clip = canvas.clip_bounds();
let band = intersect_clip( &saved_clip, above );
if !band.is_empty()
{
canvas.set_clip_rects( &band );
canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
for inset in &surf.inset_shadows
{
canvas.fill_shadow_inset( rect, inset, radius_bg );
}
canvas.set_clip_rects( &saved_clip );
}
}
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> VSlider<U>
where
U: Clone + 'static,
Msg: 'static,
{
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 ) ) )
} );
VSlider
{
value: self.value,
width: self.width,
height: self.height,
on_change,
track_surface: self.track_surface,
fill_surface: self.fill_surface,
}
}
}
/// Create a [`VSlider`] at the given value (clamped to `[0.0, 1.0]`).
pub fn vslider<Msg: Clone>( value: f32 ) -> VSlider<Msg>
{
VSlider::new( value )
}
impl<Msg: Clone + 'static> From<VSlider<Msg>> for Element<Msg>
{
fn from( s: VSlider<Msg> ) -> Self { Element::VSlider( s ) }
}