refactor: split every monolithic module into focused submodules

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.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

315
src/widget/vslider/mod.rs Normal file
View File

@@ -0,0 +1,315 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::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,
/// Fixed width of the pill in pixels. Defaults to 56.
pub width: f32,
/// Fixed height of the pill in pixels. Defaults to 160.
pub height: f32,
/// 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: theme::WIDTH,
height: 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 fixed pill `(width, height)` in pixels. Both are clamped
/// to a minimum of `2.0` so a rounded pill can always be drawn.
pub fn size( mut self, width: f32, height: f32 ) -> Self
{
self.width = width.max( 2.0 );
self.height = height.max( 2.0 );
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)
{
( self.width, self.height )
}
/// 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 ) }
}

151
src/widget/vslider/tests.rs Normal file
View File

@@ -0,0 +1,151 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 400, 400 ) }
#[ test ]
fn value_clamped_on_creation()
{
let s: VSlider<()> = vslider( 1.5 );
assert_eq!( s.value, 1.0 );
let s: VSlider<()> = vslider( -0.5 );
assert_eq!( s.value, 0.0 );
}
#[ test ]
fn value_from_y_top_is_one()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 0.0 ), 1.0 );
}
#[ test ]
fn value_from_y_bottom_is_zero()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 160.0 ), 0.0 );
}
#[ test ]
fn value_from_y_center_is_half()
{
let rect = Rect { x: 0.0, y: 0.0, width: 56.0, height: 160.0 };
let v = value_from_y_in_rect( rect, 80.0 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}
#[ test ]
fn value_from_y_above_rect_clamps_to_one()
{
let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, -50.0 ), 1.0 );
}
#[ test ]
fn value_from_y_below_rect_clamps_to_zero()
{
let rect = Rect { x: 0.0, y: 10.0, width: 56.0, height: 160.0 };
assert_eq!( value_from_y_in_rect( rect, 500.0 ), 0.0 );
}
#[ test ]
fn value_from_y_respects_rect_offset()
{
// A rect starting at y=100 with height=100: y=100 → 1.0, y=200 → 0.0.
let rect = Rect { x: 0.0, y: 100.0, width: 56.0, height: 100.0 };
assert_eq!( value_from_y_in_rect( rect, 100.0 ), 1.0 );
assert_eq!( value_from_y_in_rect( rect, 200.0 ), 0.0 );
let v = value_from_y_in_rect( rect, 150.0 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}
#[ test ]
fn size_overrides_defaults()
{
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ).size( 40.0, 200.0 );
let ( w, h ) = s.preferred_size( 500.0, &canvas );
assert_eq!( w, 40.0 );
assert_eq!( h, 200.0 );
}
#[ test ]
fn size_clamps_to_minimum()
{
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 ).size( 0.0, 0.0 );
let ( w, h ) = s.preferred_size( 500.0, &canvas );
assert_eq!( w, 2.0 );
assert_eq!( h, 2.0 );
}
#[ test ]
fn preferred_size_ignores_max_width()
{
// A VSlider is intrinsically sized — the parent's max_width doesn't
// change what we return.
let canvas = make_canvas();
let s: VSlider<()> = vslider( 0.5 );
let ( w_small, _ ) = s.preferred_size( 10.0, &canvas );
let ( w_big, _ ) = s.preferred_size( 9_999.0, &canvas );
assert_eq!( w_small, theme::WIDTH );
assert_eq!( w_big, theme::WIDTH );
}
#[ test ]
fn default_dimensions_are_the_theme_constants()
{
let s: VSlider<()> = vslider( 0.0 );
assert_eq!( s.width, theme::WIDTH );
assert_eq!( s.height, theme::HEIGHT );
}
#[ test ]
fn draw_at_value_zero_does_not_panic()
{
let mut canvas = make_canvas();
let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 0.0 );
s.draw( &mut canvas, rect, false );
}
#[ test ]
fn draw_at_value_one_does_not_panic()
{
let mut canvas = make_canvas();
let rect = Rect { x: 10.0, y: 10.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 1.0 );
s.draw( &mut canvas, rect, true );
}
#[ test ]
fn on_change_is_stored()
{
let s: VSlider<u32> = vslider( 0.5 ).on_change( |v| ( v * 100.0 ) as u32 );
let cb = s.on_change.expect( "on_change was set" );
assert_eq!( cb( 0.25 ), 25 );
}
#[ test ]
fn element_from_vslider()
{
let s: VSlider<()> = vslider( 0.5 );
let el: Element<()> = s.into();
assert!( matches!( el, Element::VSlider( _ ) ) );
}
#[ test ]
fn paint_bounds_equals_layout_rect()
{
let rect = Rect { x: 4.0, y: 8.0, width: 56.0, height: 160.0 };
let s: VSlider<()> = vslider( 0.5 );
let pb = s.paint_bounds( rect );
assert_eq!( pb.x, rect.x );
assert_eq!( pb.y, rect.y );
assert_eq!( pb.width, rect.width );
assert_eq!( pb.height, rect.height );
}

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Color;
/// Slot ids for the Glass surfaces that back the VSlider track and
/// fill. The default theme ships them; downstream themes either
/// override them or let the widget fall through to a flat-colour
/// fallback painted from the [`track_bg`] / [`track_fill`] palette
/// tokens below.
pub const SURFACE_TRACK: &str = "surface-slider-track";
pub const SURFACE_FILL: &str = "surface-slider-fill";
/// Flat-colour fallback for the unfilled track — reuses the translucent
/// raised-surface token so the pill reads on both light and dark
/// wallpapers without hardcoding.
pub fn track_bg() -> Color { crate::theme::palette().surface_alt }
/// Flat-colour fallback for the filled portion — brand accent,
/// matching horizontal [`Slider`].
pub fn track_fill() -> Color { crate::theme::palette().accent }
/// Default pill width in pixels.
pub const WIDTH: f32 = 56.0;
/// Default pill height in pixels.
pub const HEIGHT: f32 = 160.0;