ltk/widget/text_edit/
scroll.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
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! 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<Vec<VisualLine>>,
	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<Scrollbar>
	{
		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 );
	}
}