// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Vertical scroll of a multiline field: the window of visual lines on //! screen, and how the wheel moves it. use std::rc::Rc; use crate::render::Canvas; use crate::types::Rect; use super::metrics::MultilineMetrics; use super::theme; use super::TextFont; use super::wrapping::{ compute_visual_lines, VisualLine }; /// Visual lines one wheel notch moves. pub( crate ) const WHEEL_LINES: f32 = 3.0; /// Where a multiline field was last scrolled by hand: the first visual /// line asked for, and the cursor at that moment. The view holds there /// while the cursor stays put and follows it again once it moves. #[ derive( Clone, Copy, Debug, Default, PartialEq ) ] pub struct TextScroll { pub( crate ) first_line: usize, pub( crate ) cursor_pos: usize, /// Touchpad motion short of a whole line, in pixels. pub( crate ) remainder: f32, } impl TextScroll { /// The first visual line on screen for a field `visible` lines /// tall holding `total_lines`, with the cursor on `cursor_line`. pub( crate ) fn first_visible_line( self, visible: usize, total_lines: usize, cursor_line: usize, cursor_pos: usize, ) -> usize { let max_first = total_lines.saturating_sub( visible ); let first = self.first_line.min( max_first ); if self.cursor_pos == cursor_pos { first } else if cursor_line < first { cursor_line } else if cursor_line + 1 > first + visible { cursor_line + 1 - visible } else { first } } /// The request after a wheel or touchpad motion of `delta_px` /// from a view whose first line is `first_line`, and whether the /// view moved. pub( crate ) fn scrolled( self, first_line: usize, max_first: usize, cursor_pos: usize, delta_px: f32, line_h: f32, ) -> ( Self, bool ) { let px = self.remainder + delta_px; let lines = ( px / line_h ).trunc(); let remainder = px - lines * line_h; let target = ( first_line as i64 + lines as i64 ).clamp( 0, max_first as i64 ) as usize; let next = Self { first_line: target, cursor_pos, remainder }; ( next, target != first_line ) } } /// The lines a multiline field shows, resolved once so drawing, hit /// testing and the wheel agree. pub( crate ) struct Window { pub metrics: MultilineMetrics, pub lines: Rc>, pub cursor_line: usize, pub first_line: usize, pub max_first: usize, pub visible: usize, } /// The scroll bar of a multiline field whose lines do not all fit: /// the track along its right edge, the thumb on it, and the band a /// press takes as one on the bar. #[ derive( Clone, Copy, Debug ) ] pub( crate ) struct Scrollbar { pub track: Rect, pub thumb: Rect, pub hit: Rect, } impl Window { pub( crate ) fn resolve( canvas: &Canvas, rect: Rect, value: &str, font: &TextFont, cursor_pos: usize, scroll: TextScroll, ) -> Self { let metrics = MultilineMetrics::resolve( canvas, font.size ); let lines = compute_visual_lines( canvas, font, value, metrics.inner_width( rect ), metrics.font_size ); let cursor_pos = cursor_pos.min( value.len() ); let scroll = TextScroll { cursor_pos: scroll.cursor_pos.min( value.len() ), ..scroll }; let cursor_line = lines.iter() .position( |vl| cursor_pos >= vl.start && cursor_pos <= vl.end ) .unwrap_or( lines.len().saturating_sub( 1 ) ); let visible = metrics.visible_lines( rect ); let first_line = scroll.first_visible_line( visible, lines.len(), cursor_line, cursor_pos ); let max_first = lines.len().saturating_sub( visible ); Self { metrics, lines, cursor_line, first_line, max_first, visible } } /// The lines as the field last showed them, wherever the cursor went /// since, so that a press, the wheel or a page acts on what is on /// screen; `scroll` is the view the last frame kept. pub( crate ) fn shown( canvas: &Canvas, rect: Rect, value: &str, font: &TextFont, cursor_pos: usize, scroll: TextScroll, ) -> Self { let mut window = Self::resolve( canvas, rect, value, font, cursor_pos, scroll ); window.first_line = scroll.first_line.min( window.max_first ); window } pub( crate ) fn scrollbar( &self, canvas: &Canvas, rect: Rect ) -> Option { if self.max_first == 0 { return None; } let margin = canvas.geom_px( theme::SCROLLBAR_MARGIN ); let width = canvas.geom_px( theme::SCROLLBAR_W ); let track = Rect { x: rect.x + rect.width - margin - width, y: rect.y + margin, width, height: ( rect.height - margin * 2.0 ).max( 0.0 ), }; let share = self.visible as f32 / self.lines.len() as f32; let thumb_h = ( track.height * share ).max( canvas.geom_px( theme::SCROLLBAR_MIN ) ).min( track.height ); let thumb_y = track.y + ( track.height - thumb_h ) * self.first_line as f32 / self.max_first as f32; let hit_w = canvas.geom_px( theme::SCROLLBAR_HIT ).min( rect.width ); Some( Scrollbar { track, thumb: Rect { y: thumb_y, height: thumb_h, ..track }, hit: Rect { x: rect.x + rect.width - hit_w, width: hit_w, ..rect }, } ) } /// The first line on screen with the top of `bar`'s thumb at `thumb_y`. pub( crate ) fn first_line_for( &self, bar: &Scrollbar, thumb_y: f32 ) -> usize { let travel = bar.track.height - bar.thumb.height; if travel <= 0.0 { return 0; } let share = ( ( thumb_y - bar.track.y ) / travel ).clamp( 0.0, 1.0 ); ( share * self.max_first as f32 ).round() as usize } pub( crate ) fn can_scroll( &self, delta_px: f32 ) -> bool { ( delta_px < 0.0 && self.first_line > 0 ) || ( delta_px > 0.0 && self.first_line < self.max_first ) } } #[ cfg( test ) ] mod tests { use super::TextScroll; fn at( first_line: usize, cursor_pos: usize ) -> TextScroll { TextScroll { first_line, cursor_pos, remainder: 0.0 } } #[ test ] fn without_a_request_the_view_follows_the_cursor() { let s = TextScroll::default(); assert_eq!( s.first_visible_line( 10, 30, 4, 40 ), 0 ); assert_eq!( s.first_visible_line( 10, 30, 12, 130 ), 3 ); assert_eq!( s.first_visible_line( 10, 30, 29, 300 ), 20 ); } #[ test ] fn a_request_holds_while_the_cursor_stays() { let s = at( 15, 290 ); assert_eq!( s.first_visible_line( 10, 30, 29, 290 ), 15 ); } #[ test ] fn a_request_is_clamped_to_the_last_full_window() { assert_eq!( at( 50, 0 ).first_visible_line( 10, 30, 0, 0 ), 20 ); assert_eq!( at( 5, 0 ).first_visible_line( 10, 8, 0, 0 ), 0 ); } #[ test ] fn once_the_cursor_moves_the_view_shows_it_with_the_least_motion() { let s = at( 15, 290 ); assert_eq!( s.first_visible_line( 10, 30, 29, 291 ), 20 ); assert_eq!( s.first_visible_line( 10, 30, 2, 20 ), 2 ); assert_eq!( s.first_visible_line( 10, 30, 18, 180 ), 15 ); } #[ test ] fn a_wheel_notch_moves_three_lines_and_stops_at_the_ends() { let line_h = 20.0; let ( s, moved ) = TextScroll::default().scrolled( 0, 20, 7, super::WHEEL_LINES * line_h, line_h ); assert!( moved ); assert_eq!( ( s.first_line, s.cursor_pos, s.remainder ), ( 3, 7, 0.0 ) ); let ( s, moved ) = s.scrolled( 19, 20, 7, super::WHEEL_LINES * line_h, line_h ); assert!( moved ); assert_eq!( s.first_line, 20 ); let ( s, moved ) = s.scrolled( 20, 20, 7, line_h, line_h ); assert!( !moved ); assert_eq!( s.first_line, 20 ); let ( s, moved ) = s.scrolled( 0, 20, 7, -line_h, line_h ); assert!( !moved ); assert_eq!( s.first_line, 0 ); } #[ test ] fn touchpad_motion_adds_up_to_whole_lines() { let line_h = 20.0; let ( s, moved ) = TextScroll::default().scrolled( 0, 20, 0, 12.0, line_h ); assert!( !moved ); assert_eq!( s.remainder, 12.0 ); let ( s, moved ) = s.scrolled( 0, 20, 0, 12.0, line_h ); assert!( moved ); assert_eq!( ( s.first_line, s.remainder ), ( 1, 4.0 ) ); let ( s, moved ) = s.scrolled( 1, 20, 0, -24.0, line_h ); assert!( moved ); assert_eq!( s.first_line, 0 ); assert_eq!( s.remainder, 0.0 ); } }