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
18pub struct Scroll {
20 props: Props,
21 slot: Slot,
22 children: Vec<Cached>,
23 state: ScrollState,
24}
25
26impl Scroll {
27 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 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
39 self.props.set(prop, value);
40 self
41 }
42
43 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
45 self.props.set(prop, value);
46 self
47 }
48
49 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 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 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 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 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 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 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 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 ui.handle_mouse(9, 3, Mouse::Click);
415 assert!(crate::test_support::frame_row_text(ui.frame(), 0).starts_with("l9"));
416
417 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}