// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; use crate::types::WidgetId; use crate::widget::Element; /// A vertically scrollable viewport that clips its child to its allocated rect. /// /// The child can be any element — typically a [`Column`](crate::layout::column::Column) /// for lists or a [`WrapGrid`](crate::layout::wrap_grid::WrapGrid) for icon grids. /// /// Scroll offset is driven by touch/pointer drag gestures within the viewport. /// Dragging inside a `Scroll` does **not** trigger the app-level swipe-up gesture. /// /// ```rust,no_run /// # use std::sync::Arc; /// # use ltk::{ column, grid, icon_button, scroll, text, Element }; /// # #[ derive( Clone ) ] enum Msg {} /// # fn _ex( rgba: Arc>, w: u32, h: u32 ) -> ( Element, Element ) { /// // Scrollable list /// let list = scroll( column().spacing( 8.0 ).push( text( "Item 1" ) ).push( text( "Item 2" ) ) ); /// /// // App-drawer style grid /// let drawer = scroll( grid( 4 ).padding( 16.0 ).spacing( 12.0 ).push( icon_button( rgba, w, h ) ) ); /// # ( list.into(), drawer.into() ) /// # } /// ``` pub struct Scroll { /// The single child element drawn inside the scrollable viewport. pub child: Box>, /// Optional stable identifier — used as scroll state key. pub id: Option, } impl Scroll { /// Assign a stable identifier to this scroll widget. pub fn id( mut self, id: WidgetId ) -> Self { self.id = Some( id ); self } /// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in /// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer). /// The actual viewport height is determined at render time from the rect the /// parent assigns. pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32) { ( max_width, 0.0 ) } /// No-op — rendering is handled entirely by `layout_and_draw` in `draw.rs`. pub fn draw( &self ) {} pub( crate ) fn map_msg( self, f: &super::MapFn ) -> Scroll where U: Clone + 'static, Msg: 'static, { Scroll { child: Box::new( self.child.map_arc( f ) ), id: self.id, } } } impl From> for Element { fn from( s: Scroll ) -> Self { Element::Scroll( s ) } } /// Compute the clamped scroll offset given raw offset, content height, and viewport height. /// /// Extracted as a pure function so it can be unit-tested without a Wayland surface. pub(crate) fn clamp_offset( offset: f32, content_h: f32, viewport_h: f32 ) -> f32 { let max = (content_h - viewport_h).max( 0.0 ); offset.clamp( 0.0, max ) } #[cfg(test)] mod tests { use super::clamp_offset; #[test] fn offset_zero_when_content_fits() { // Content shorter than viewport — no scrolling possible assert_eq!( clamp_offset( 50.0, 300.0, 500.0 ), 0.0 ); } #[test] fn offset_clamped_to_zero_when_negative() { assert_eq!( clamp_offset( -10.0, 600.0, 400.0 ), 0.0 ); } #[test] fn offset_clamped_to_max() { // max = 600 - 400 = 200; offset 999 → clamped to 200 assert_eq!( clamp_offset( 999.0, 600.0, 400.0 ), 200.0 ); } #[test] fn offset_within_range_unchanged() { // max = 600 - 400 = 200; offset 100 stays 100 assert_eq!( clamp_offset( 100.0, 600.0, 400.0 ), 100.0 ); } #[test] fn zero_offset_stays_zero() { assert_eq!( clamp_offset( 0.0, 600.0, 400.0 ), 0.0 ); } #[test] fn exact_max_offset_is_valid() { assert_eq!( clamp_offset( 200.0, 600.0, 400.0 ), 200.0 ); } #[test] fn content_equal_to_viewport_gives_zero_max() { // No overflow — max = 0 assert_eq!( clamp_offset( 10.0, 400.0, 400.0 ), 0.0 ); } } /// Create a scrollable viewport wrapping `child`. /// /// The parent layout controls the viewport size by assigning a rect to this widget. /// Content that overflows vertically is scrolled via drag gesture. /// /// ```rust,no_run /// # use ltk::{ column, scroll, text, Element }; /// # #[ derive( Clone ) ] enum Msg {} /// # fn _ex() -> Element { /// scroll( column().push( text( "A" ) ).push( text( "B" ) ) ) /// .into() /// # } /// ``` pub fn scroll( child: impl Into> ) -> Scroll { Scroll { child: Box::new( child.into() ), id: None } }