Skip to main content

omp_tui/components/
scroll.rs

1use crate::{
2	component::{
3		Cached, Component, EventCtx, Flow, Hit, HitTag, IntoChildren, PaintCtx, Slot, next_slot,
4	},
5	context::UiContext,
6	frame::{Frame, Rect, Size, Style},
7	input::{Key, Mouse},
8	props::{Prop, PropValue, Props},
9};
10
11#[derive(Clone, Debug, Default)]
12struct ScrollState {
13	off:       u16,
14	content_h: u16,
15	scratch:   Option<Frame>,
16}
17
18/// A vertically scrollable child stack backing the `<scroll>` markup tag.
19pub struct Scroll {
20	props:    Props,
21	slot:     Slot,
22	children: Vec<Cached>,
23	state:    ScrollState,
24}
25
26impl Scroll {
27	/// Creates an empty scrolling region.
28	pub fn new() -> Self {
29		Self {
30			props:    Props::new(),
31			slot:     next_slot(),
32			children: Vec::new(),
33			state:    ScrollState::default(),
34		}
35	}
36
37	/// Sets one scrolling-region property.
38	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
39		self.props.set(prop, value);
40		self
41	}
42
43	/// Sets one scrolling-region property from a string.
44	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
45		self.props.set(prop, value);
46		self
47	}
48
49	/// Appends child components to the scrolling region.
50	pub fn child(mut self, children: impl IntoChildren) -> Self {
51		children.extend_children(&mut self.children);
52		self
53	}
54
55	pub(crate) fn chase(&mut self, descendant_rect: Rect, view_rows: u16) -> bool {
56		let previous = self.state.off;
57		let view_bottom = previous.saturating_add(view_rows);
58		let descendant_bottom = descendant_rect.y.saturating_add(descendant_rect.height);
59		let target = if descendant_rect.y < previous {
60			descendant_rect.y
61		} else if descendant_bottom > view_bottom {
62			descendant_bottom.saturating_sub(view_rows)
63		} else {
64			previous
65		};
66		self.state.off = target.min(self.state.content_h.saturating_sub(view_rows));
67		self.state.off != previous
68	}
69
70	fn scroll_by(&mut self, delta: i32, view_rows: u16) -> bool {
71		let max_off = self.state.content_h.saturating_sub(view_rows);
72		let next = (i64::from(self.state.off) + i64::from(delta)).clamp(0, i64::from(max_off)) as u16;
73		let changed = next != self.state.off;
74		self.state.off = next;
75		changed
76	}
77}
78
79impl Default for Scroll {
80	fn default() -> Self {
81		Self::new()
82	}
83}
84
85impl Component for Scroll {
86	fn props(&self) -> &Props {
87		&self.props
88	}
89
90	fn props_mut(&mut self) -> &mut Props {
91		&mut self.props
92	}
93
94	fn slot(&self) -> Slot {
95		self.slot
96	}
97
98	fn children(&self) -> &[Cached] {
99		&self.children
100	}
101
102	fn children_mut(&mut self) -> &mut [Cached] {
103		&mut self.children
104	}
105
106	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
107		let mut min = 0;
108		let mut nat = 0;
109		for child in self.children.iter_mut().filter(|child| child.visible) {
110			let (child_min, child_nat) = child.measure(ctx);
111			min = min.max(child_min);
112			nat = nat.max(child_nat);
113		}
114		(min.saturating_add(1), nat.saturating_add(1))
115	}
116
117	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
118		8
119	}
120
121	fn place(&mut self, ctx: &UiContext, content: Rect) {
122		let inner = content.width.saturating_sub(1).max(1);
123		let mut y = 0u16;
124		for child in self.children.iter_mut().filter(|child| child.visible) {
125			let height = child.height(ctx, inner);
126			child.place(ctx, Rect::new(0, y, inner, height));
127			y = y.saturating_add(height);
128		}
129		self.state.content_h = y;
130		self.state.off = self.state.off.min(y.saturating_sub(content.height));
131	}
132
133	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
134		pc.hits
135			.push(Hit { rect, slot: self.slot, tag: HitTag::Wheel });
136		let child_hits = pc.hits.len();
137		let inner = rect.width.saturating_sub(1).max(1);
138		let content_h = self.state.content_h;
139		let window_rows = rect.height.min(pc.clip.saturating_sub(rect.y));
140		let off = self.state.off;
141		let mut scratch = self
142			.state
143			.scratch
144			.take()
145			.filter(|frame| frame.size() == Size::new(inner, content_h))
146			.unwrap_or_else(|| Frame::new(Size::new(inner, content_h)));
147		scratch.clear(Style::default());
148		// The pointer moves into scratch coordinates with the children;
149		// pointers outside the window vanish.
150		let pointer = pc.pointer.and_then(|(x, y)| {
151			let column = x.checked_sub(rect.x)?;
152			let row = y.checked_sub(rect.y)?;
153			(column < inner && row < window_rows).then(|| (column, row.saturating_add(off)))
154		});
155		{
156			let mut child_pc = pc.nested(&mut scratch, content_h);
157			child_pc.pointer = pointer;
158			for child in self.children.iter_mut().filter(|child| child.visible) {
159				child.paint(&mut child_pc);
160			}
161		}
162
163		pc.frame
164			.blit(&scratch, self.state.off, window_rows, rect.x, rect.y);
165		translate_hits(pc.hits, child_hits, rect, self.state.off, window_rows);
166		self.state.scratch = Some(scratch);
167
168		if rect.width == 0 || content_h <= rect.height {
169			return;
170		}
171		let bar_x = rect.x.saturating_add(rect.width - 1);
172		let thumb_h = (rect.height.saturating_mul(rect.height) / content_h).max(1);
173		let denom = content_h - rect.height;
174		let thumb_top = rect
175			.height
176			.saturating_sub(thumb_h)
177			.saturating_mul(self.state.off)
178			.checked_div(denom)
179			.unwrap_or(0);
180		for row in 0..window_rows {
181			let (glyph, style) = if row >= thumb_top && row < thumb_top.saturating_add(thumb_h) {
182				(pc.ctx.charset.scrollbar().1, Style::new().fg(pc.ctx.theme.accent))
183			} else {
184				(pc.ctx.charset.scrollbar().0, Style::new().fg(pc.ctx.theme.muted))
185			};
186			pc.frame
187				.put(bar_x, rect.y.saturating_add(row), glyph, style);
188		}
189		pc.hits.push(Hit {
190			rect: Rect::new(bar_x, rect.y, 1, window_rows),
191			slot: self.slot,
192			tag:  HitTag::Scrollbar,
193		});
194	}
195
196	fn focusable(&self) -> bool {
197		true
198	}
199
200	fn key(&mut self, ec: &mut EventCtx<'_>, key: Key) -> Flow {
201		let view_rows = ec.view_rows;
202		let delta = match key {
203			Key::Up => -1,
204			Key::Down => 1,
205			Key::PageUp => -i32::from(view_rows),
206			Key::PageDown => i32::from(view_rows),
207			Key::Home => -i32::from(self.state.content_h),
208			Key::End => i32::from(self.state.content_h),
209			Key::Ctrl('u') => -i32::from(view_rows / 2).max(1),
210			Key::Ctrl('d') => i32::from(view_rows / 2).max(1),
211			_ => return Flow::Skip,
212		};
213		let changed = self.scroll_by(delta, view_rows);
214		if !changed && matches!(key, Key::Up | Key::Down) {
215			Flow::Skip
216		} else {
217			Flow::Consumed
218		}
219	}
220
221	fn mouse(
222		&mut self,
223		ec: &mut EventCtx<'_>,
224		tag: HitTag,
225		at: (u16, u16),
226		rect: Rect,
227		mouse: Mouse,
228	) -> Flow {
229		match tag {
230			HitTag::Scrollbar => match mouse {
231				// Click or drag on the bar centers the thumb on the pointer
232				// row — the inverse of the thumb placement painted above.
233				Mouse::Click | Mouse::Drag => {
234					let track = rect.height;
235					let content_h = self.state.content_h;
236					if track == 0 || content_h <= track {
237						return Flow::Consumed;
238					}
239					let thumb_h = (track.saturating_mul(track) / content_h).max(1);
240					let span = track - thumb_h;
241					if span == 0 {
242						return Flow::Consumed;
243					}
244					let row = at.1.saturating_sub(rect.y).min(track - 1);
245					let grab = row.saturating_sub(thumb_h / 2).min(span);
246					let range = u32::from(content_h - track);
247					let target =
248						((u32::from(grab) * range + u32::from(span / 2)) / u32::from(span)) as u16;
249					self.state.off = target;
250					Flow::Consumed
251				},
252				// Swallow everything else on the bar so a release or stray
253				// click never falls through to occluded content.
254				Mouse::RightClick | Mouse::MiddleClick | Mouse::Release => Flow::Consumed,
255				_ => Flow::Skip,
256			},
257			HitTag::Wheel => match mouse {
258				Mouse::WheelUp | Mouse::WheelDown => {
259					let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
260					if self.scroll_by(delta, ec.view_rows) {
261						Flow::Consumed
262					} else {
263						Flow::Skip
264					}
265				},
266				// Scroll has no horizontal offset. Swallow horizontal wheels
267				// at the viewport so an underlying or ancestor widget cannot
268				// act on them.
269				Mouse::WheelLeft | Mouse::WheelRight => Flow::Consumed,
270				_ => Flow::Skip,
271			},
272			_ => Flow::Skip,
273		}
274	}
275}
276
277fn translate_hits(hits: &mut Vec<Hit>, start: usize, viewport: Rect, off: u16, rows: u16) {
278	let clip = Rect::new(viewport.x, viewport.y, viewport.width, rows);
279	let mut write = start;
280	for read in start..hits.len() {
281		let mut hit = hits[read];
282		let x = i32::from(viewport.x) + i32::from(hit.rect.x);
283		let y = i32::from(viewport.y) + i32::from(hit.rect.y) - i32::from(off);
284		let Some(rect) = translated_intersection(hit.rect, x, y, clip) else {
285			continue;
286		};
287		hit.rect = rect;
288		hits[write] = hit;
289		write += 1;
290	}
291	hits.truncate(write);
292}
293
294fn translated_intersection(source: Rect, x: i32, y: i32, clip: Rect) -> Option<Rect> {
295	let left = x.max(i32::from(clip.x));
296	let top = y.max(i32::from(clip.y));
297	let right = x
298		.saturating_add(i32::from(source.width))
299		.min(i32::from(clip.x) + i32::from(clip.width));
300	let bottom = y
301		.saturating_add(i32::from(source.height))
302		.min(i32::from(clip.y) + i32::from(clip.height));
303	if left >= right || top >= bottom {
304		return None;
305	}
306	Some(Rect::new(left as u16, top as u16, (right - left) as u16, (bottom - top) as u16))
307}
308
309#[cfg(test)]
310mod tests {
311	use super::*;
312	use crate::{components::Pre, test_support::frame_row_text};
313
314	#[test]
315	fn scroll_clamps_and_blits_from_scratch() {
316		let ctx = UiContext::default();
317		let mut scroll = Scroll::new().child(Pre::new().text("one\ntwo\nthree"));
318		scroll.place(&ctx, Rect::new(0, 0, 8, 2));
319		let mut ec = EventCtx::new(&ctx, 8, 2);
320		assert_eq!(scroll.key(&mut ec, Key::Up), Flow::Skip);
321		assert_eq!(scroll.key(&mut ec, Key::Down), Flow::Consumed);
322		assert_eq!(scroll.state.off, 1);
323		assert_eq!(scroll.key(&mut ec, Key::Down), Flow::Skip);
324		assert_eq!(
325			scroll.mouse(&mut ec, HitTag::Wheel, (0, 0), Rect::new(0, 0, 8, 2), Mouse::WheelDown),
326			Flow::Skip,
327		);
328		assert_eq!(scroll.state.off, 1);
329		assert_eq!(
330			scroll.mouse(&mut ec, HitTag::Wheel, (0, 0), Rect::new(0, 0, 8, 2), Mouse::WheelLeft),
331			Flow::Consumed,
332		);
333		assert_eq!(
334			scroll.mouse(&mut ec, HitTag::Wheel, (0, 0), Rect::new(0, 0, 8, 2), Mouse::WheelRight),
335			Flow::Consumed,
336		);
337		assert_eq!(scroll.state.off, 1);
338
339		let mut frame = Frame::new(Size::new(8, 2));
340		let mut hits = Vec::new();
341		let mut wakes = Vec::new();
342		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
343		scroll.paint(&mut pc, Rect::new(0, 0, 8, 2));
344		assert!(frame_row_text(&frame, 0).starts_with("two"));
345		assert!(frame_row_text(&frame, 1).starts_with("three"));
346	}
347
348	#[test]
349	fn focus_chase_moves_only_as_far_as_needed() {
350		let ctx = UiContext::default();
351		let mut scroll = Scroll::new().child(Pre::new().text("one\ntwo\nthree\nfour"));
352		scroll.place(&ctx, Rect::new(0, 0, 8, 2));
353		assert!(scroll.chase(Rect::new(0, 3, 4, 1), 2));
354		assert_eq!(scroll.state.off, 2);
355		assert!(!scroll.chase(Rect::new(0, 2, 4, 1), 2));
356		assert!(scroll.chase(Rect::new(0, 0, 4, 1), 2));
357		assert_eq!(scroll.state.off, 0);
358	}
359
360	#[test]
361	fn child_hits_translate_and_clip_to_the_viewport() {
362		let mut hits = vec![Hit { rect: Rect::new(0, 0, 3, 1), slot: 7, tag: HitTag::Press }, Hit {
363			rect: Rect::new(1, 2, 4, 2),
364			slot: 8,
365			tag:  HitTag::Row(0),
366		}];
367		translate_hits(&mut hits, 0, Rect::new(10, 5, 6, 2), 1, 2);
368		assert_eq!(hits.len(), 1);
369		assert_eq!(hits[0].slot, 8);
370		assert_eq!(hits[0].rect, Rect::new(11, 6, 4, 1));
371	}
372
373	#[test]
374	fn scrollbar_clicks_jump_and_drags_track_the_pointer() {
375		let ctx = UiContext::default();
376		let mut scroll =
377			Scroll::new().child(Pre::new().text("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12"));
378		scroll.place(&ctx, Rect::new(0, 0, 10, 4));
379		let mut ec = EventCtx::new(&ctx, 10, 4);
380		let bar = Rect::new(9, 0, 1, 4);
381
382		// content 12, track 4 → thumb 1, span 3, range 8.
383		let flow = scroll.mouse(&mut ec, HitTag::Scrollbar, (9, 3), bar, Mouse::Click);
384		assert_eq!(flow, Flow::Consumed);
385		assert_eq!(scroll.state.off, 8, "bottom of the track is the maximum offset");
386
387		// A drag row maps proportionally, even when the pointer leaves the
388		// bar column or the rectangle vertically.
389		scroll.mouse(&mut ec, HitTag::Scrollbar, (3, 1), bar, Mouse::Drag);
390		assert_eq!(scroll.state.off, 3);
391		scroll.mouse(&mut ec, HitTag::Scrollbar, (9, 60), bar, Mouse::Drag);
392		assert_eq!(scroll.state.off, 8);
393		scroll.mouse(&mut ec, HitTag::Scrollbar, (9, 0), bar, Mouse::Drag);
394		assert_eq!(scroll.state.off, 0);
395
396		// Releases and stray clicks on the bar are swallowed, not forwarded.
397		assert_eq!(
398			scroll.mouse(&mut ec, HitTag::Scrollbar, (9, 0), bar, Mouse::Release),
399			Flow::Consumed
400		);
401	}
402
403	#[test]
404	fn scrollbar_hit_zone_routes_through_ui_mouse_handling() {
405		let mut ui = crate::Ui::from_markup(
406			"<scroll h=4><pre>l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12</pre></scroll>",
407			10,
408			crate::UiContext::default(),
409		)
410		.unwrap();
411		assert!(crate::test_support::frame_row_text(ui.frame(), 0).starts_with("l1"));
412
413		// Click the bottom of the bar column: jump to the end.
414		ui.handle_mouse(9, 3, Mouse::Click);
415		assert!(crate::test_support::frame_row_text(ui.frame(), 0).starts_with("l9"));
416
417		// Drag capture keeps routing to the bar even off-column.
418		ui.handle_mouse(2, 1, Mouse::Drag);
419		assert!(crate::test_support::frame_row_text(ui.frame(), 0).starts_with("l4"));
420		ui.handle_mouse(2, 0, Mouse::Drag);
421		assert!(crate::test_support::frame_row_text(ui.frame(), 0).starts_with("l1"));
422		ui.handle_mouse(2, 0, Mouse::Release);
423	}
424}