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 ) }
}