Another project
1use crate::a11y::{AccessNode, AccessRange, Role};
2use crate::frame::{FrameCtx, InteractDeclaration};
3use crate::hit_test::{Interaction, Sense};
4use crate::input::{KeyCode, NamedKey};
5use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
6use crate::strings::StringKey;
7use crate::theme::{Border, Step12, StrokeWidth};
8use crate::widget_id::{WidgetId, WidgetKey};
9
10use super::keys::{TakeKey, take_key};
11use super::paint::WidgetPaint;
12use super::visuals::push_focus_ring;
13
14const MIN_THUMB_PX: f32 = 18.0;
15const LINE_STEP_PX: f32 = 24.0;
16const THUMB_INSET_PX: f32 = 2.0;
17const SCROLLBAR_WIDTH_PX: f32 = 14.0;
18
19pub struct RowWindow {
20 pub content_rect: LayoutRect,
21 pub bar: Option<LayoutRect>,
22 pub content_len: LayoutPx,
23 pub offset: LayoutPx,
24 pub first_row: usize,
25 pub last_row: usize,
26}
27
28#[must_use]
29pub fn row_window(
30 rect: LayoutRect,
31 row_count: usize,
32 row_height: LayoutPx,
33 requested: LayoutPx,
34) -> RowWindow {
35 let row_h = row_height.value();
36 let viewport_h = rect.size.height.value();
37 #[allow(
38 clippy::cast_precision_loss,
39 reason = "row count fits the f32 mantissa"
40 )]
41 let content_h = row_count as f32 * row_h;
42 let scrollable = row_h > 0.0 && content_h > viewport_h + 0.5;
43 let bar_w = if scrollable { SCROLLBAR_WIDTH_PX } else { 0.0 };
44 let content_rect = LayoutRect::new(
45 rect.origin,
46 LayoutSize::new(
47 LayoutPx::saturating_nonneg(rect.size.width.value() - bar_w),
48 rect.size.height,
49 ),
50 );
51 if !scrollable {
52 return RowWindow {
53 content_rect,
54 bar: None,
55 content_len: LayoutPx::new(content_h),
56 offset: LayoutPx::ZERO,
57 first_row: 0,
58 last_row: row_count,
59 };
60 }
61 let max_offset = (content_h - viewport_h).max(0.0);
62 let clamped = requested.value().clamp(0.0, max_offset);
63 let (first_row, fit_rows) = row_span(clamped, viewport_h, row_h);
64 let last_row = (first_row + fit_rows).min(row_count);
65 #[allow(
66 clippy::cast_precision_loss,
67 reason = "row index fits the f32 mantissa"
68 )]
69 let snapped = first_row as f32 * row_h;
70 let bar = LayoutRect::new(
71 LayoutPos::new(
72 LayoutPx::new(rect.origin.x.value() + rect.size.width.value() - bar_w),
73 rect.origin.y,
74 ),
75 LayoutSize::new(LayoutPx::new(bar_w), rect.size.height),
76 );
77 RowWindow {
78 content_rect,
79 bar: Some(bar),
80 content_len: LayoutPx::new(content_h),
81 offset: LayoutPx::new(snapped),
82 first_row,
83 last_row,
84 }
85}
86
87#[allow(
88 clippy::cast_possible_truncation,
89 clippy::cast_sign_loss,
90 reason = "non-negative row counts stay well within usize"
91)]
92fn row_span(offset_px: f32, viewport_h: f32, row_h: f32) -> (usize, usize) {
93 let first = (offset_px / row_h).floor() as usize;
94 let fit = (viewport_h / row_h).floor() as usize;
95 (first, fit)
96}
97
98#[must_use]
99pub fn wheel_scroll(ctx: &FrameCtx<'_>, rect: LayoutRect, offset: LayoutPx) -> LayoutPx {
100 if ctx.input.pointer.is_some_and(|p| rect.contains(p.position)) {
101 LayoutPx::new(offset.value() + ctx.input.scroll_y)
102 } else {
103 offset
104 }
105}
106
107#[must_use]
108pub fn window_scrollbar(
109 ctx: &mut FrameCtx<'_>,
110 container_id: WidgetId,
111 label: StringKey,
112 window: &RowWindow,
113 offset: LayoutPx,
114) -> (Vec<WidgetPaint>, LayoutPx) {
115 let Some(bar_rect) = window.bar else {
116 return (Vec::new(), offset);
117 };
118 let response = show_scrollbar(
119 ctx,
120 Scrollbar::new(
121 container_id.child(WidgetKey::new("scrollbar")),
122 bar_rect,
123 Axis::Vertical,
124 label,
125 window.content_len,
126 offset,
127 ),
128 );
129 let next = if response.changed {
130 response.offset
131 } else {
132 offset
133 };
134 (response.paint, next)
135}
136
137pub struct Scrollbar {
138 pub id: WidgetId,
139 pub rect: LayoutRect,
140 pub axis: Axis,
141 pub label: StringKey,
142 pub content: LayoutPx,
143 pub offset: LayoutPx,
144 pub disabled: bool,
145}
146
147impl Scrollbar {
148 #[must_use]
149 pub fn new(
150 id: WidgetId,
151 rect: LayoutRect,
152 axis: Axis,
153 label: StringKey,
154 content: LayoutPx,
155 offset: LayoutPx,
156 ) -> Self {
157 Self {
158 id,
159 rect,
160 axis,
161 label,
162 content,
163 offset,
164 disabled: false,
165 }
166 }
167
168 #[must_use]
169 pub fn disabled(self, disabled: bool) -> Self {
170 Self { disabled, ..self }
171 }
172}
173
174#[derive(Clone, Debug, PartialEq)]
175pub struct ScrollbarResponse {
176 pub interaction: Interaction,
177 pub offset: LayoutPx,
178 pub changed: bool,
179 pub paint: Vec<WidgetPaint>,
180}
181
182#[derive(Copy, Clone, Debug, PartialEq)]
183struct Geometry {
184 track_len: f32,
185 thumb_len: f32,
186 max_offset: f32,
187}
188
189impl Geometry {
190 fn of(rect: LayoutRect, axis: Axis, content: LayoutPx) -> Self {
191 let track_len = axis_len(rect, axis);
192 let content = content.value().max(track_len);
193 let ratio = if content > 0.0 {
194 (track_len / content).clamp(0.0, 1.0)
195 } else {
196 1.0
197 };
198 let thumb_len = (track_len * ratio).clamp(MIN_THUMB_PX.min(track_len), track_len);
199 Self {
200 track_len,
201 thumb_len,
202 max_offset: (content - track_len).max(0.0),
203 }
204 }
205
206 fn scrollable(self) -> bool {
207 self.max_offset > 0.0
208 }
209
210 fn travel(self) -> f32 {
211 (self.track_len - self.thumb_len).max(0.0)
212 }
213
214 fn thumb_start(self, offset: f32) -> f32 {
215 if self.max_offset <= 0.0 {
216 return 0.0;
217 }
218 (offset / self.max_offset).clamp(0.0, 1.0) * self.travel()
219 }
220
221 fn offset_at(self, local: f32) -> f32 {
222 let travel = self.travel();
223 if travel <= 0.0 {
224 return 0.0;
225 }
226 let unit = ((local - self.thumb_len / 2.0) / travel).clamp(0.0, 1.0);
227 unit * self.max_offset
228 }
229}
230
231#[must_use]
232#[allow(
233 clippy::needless_pass_by_value,
234 reason = "destructure consumes the scrollbar"
235)]
236pub fn show_scrollbar(ctx: &mut FrameCtx<'_>, scrollbar: Scrollbar) -> ScrollbarResponse {
237 let Scrollbar {
238 id,
239 rect,
240 axis,
241 label,
242 content,
243 offset: initial,
244 disabled,
245 } = scrollbar;
246 let geom = Geometry::of(rect, axis, content);
247 let interactive = !disabled && geom.scrollable();
248 let interaction = ctx.interact(
249 InteractDeclaration::new(id, rect, Sense::DRAGGABLE)
250 .focusable(interactive)
251 .disabled(!interactive),
252 );
253
254 let mut offset = initial.value().clamp(0.0, geom.max_offset);
255 let mut changed = false;
256
257 if interactive
258 && (interaction.click() || interaction.drag_start() || interaction.pressed())
259 && let Some(next) = pointer_offset(rect, axis, geom, ctx.input)
260 && (next - offset).abs() > f32::EPSILON
261 {
262 offset = next;
263 changed = true;
264 }
265
266 let live_focused = ctx.is_focused(id);
267 if interactive
268 && live_focused
269 && let Some(event) = take_key(ctx.input, keyboard_targets(axis))
270 {
271 let next = apply_key(offset, geom, axis, event.code);
272 if (next - offset).abs() > f32::EPSILON {
273 offset = next;
274 changed = true;
275 }
276 }
277
278 ctx.a11y.push(
279 id,
280 rect,
281 AccessNode::new(Role::ScrollBar)
282 .with_label(label)
283 .with_disabled(!interactive)
284 .with_range(AccessRange {
285 value: f64::from(offset),
286 min: 0.0,
287 max: f64::from(geom.max_offset),
288 step: f64::from(LINE_STEP_PX),
289 }),
290 );
291
292 let thumb = thumb_rect(rect, axis, geom, offset);
293 let paint = build_paint(ctx, rect, thumb, !interactive, interaction, live_focused);
294 ScrollbarResponse {
295 interaction,
296 offset: LayoutPx::new(offset),
297 changed,
298 paint,
299 }
300}
301
302fn keyboard_targets(axis: Axis) -> &'static [TakeKey] {
303 const VERTICAL: [TakeKey; 6] = [
304 TakeKey::named(NamedKey::ArrowUp),
305 TakeKey::named(NamedKey::ArrowDown),
306 TakeKey::named(NamedKey::PageUp),
307 TakeKey::named(NamedKey::PageDown),
308 TakeKey::named(NamedKey::Home),
309 TakeKey::named(NamedKey::End),
310 ];
311 const HORIZONTAL: [TakeKey; 6] = [
312 TakeKey::named(NamedKey::ArrowLeft),
313 TakeKey::named(NamedKey::ArrowRight),
314 TakeKey::named(NamedKey::PageUp),
315 TakeKey::named(NamedKey::PageDown),
316 TakeKey::named(NamedKey::Home),
317 TakeKey::named(NamedKey::End),
318 ];
319 match axis {
320 Axis::Vertical => &VERTICAL,
321 Axis::Horizontal => &HORIZONTAL,
322 }
323}
324
325fn apply_key(offset: f32, geom: Geometry, axis: Axis, code: KeyCode) -> f32 {
326 let (back, forward) = match axis {
327 Axis::Vertical => (NamedKey::ArrowUp, NamedKey::ArrowDown),
328 Axis::Horizontal => (NamedKey::ArrowLeft, NamedKey::ArrowRight),
329 };
330 let next = match code {
331 KeyCode::Named(key) if key == back => offset - LINE_STEP_PX,
332 KeyCode::Named(key) if key == forward => offset + LINE_STEP_PX,
333 KeyCode::Named(NamedKey::PageUp) => offset - geom.track_len,
334 KeyCode::Named(NamedKey::PageDown) => offset + geom.track_len,
335 KeyCode::Named(NamedKey::Home) => 0.0,
336 KeyCode::Named(NamedKey::End) => geom.max_offset,
337 _ => offset,
338 };
339 next.clamp(0.0, geom.max_offset)
340}
341
342fn pointer_offset(
343 rect: LayoutRect,
344 axis: Axis,
345 geom: Geometry,
346 input: &crate::input::InputSnapshot,
347) -> Option<f32> {
348 let pointer = input.pointer?.position;
349 let local = match axis {
350 Axis::Vertical => pointer.y.value() - rect.origin.y.value(),
351 Axis::Horizontal => pointer.x.value() - rect.origin.x.value(),
352 };
353 Some(geom.offset_at(local))
354}
355
356fn axis_len(rect: LayoutRect, axis: Axis) -> f32 {
357 match axis {
358 Axis::Vertical => rect.size.height.value(),
359 Axis::Horizontal => rect.size.width.value(),
360 }
361}
362
363fn build_paint(
364 ctx: &FrameCtx<'_>,
365 track: LayoutRect,
366 thumb: LayoutRect,
367 inactive: bool,
368 interaction: Interaction,
369 live_focused: bool,
370) -> Vec<WidgetPaint> {
371 let neutral = ctx.theme().colors.neutral;
372 let radius = ctx.theme().radius.sm;
373 let thumb_fill = if inactive {
374 neutral.step(Step12::SELECTED_BG)
375 } else if interaction.pressed() {
376 neutral.step(Step12::SOLID)
377 } else if interaction.hover() {
378 neutral.step(Step12::HOVER_BORDER)
379 } else {
380 neutral.step(Step12::BORDER)
381 };
382 let mut paint = vec![
383 WidgetPaint::Surface {
384 rect: track,
385 fill: neutral.step(Step12::SUBTLE_BG),
386 border: Some(Border {
387 width: StrokeWidth::HAIRLINE,
388 color: neutral.step(Step12::SUBTLE_BORDER),
389 }),
390 radius,
391 elevation: None,
392 },
393 WidgetPaint::Surface {
394 rect: thumb,
395 fill: thumb_fill,
396 border: Some(Border {
397 width: StrokeWidth::HAIRLINE,
398 color: neutral.step(Step12::BORDER),
399 }),
400 radius,
401 elevation: None,
402 },
403 ];
404 push_focus_ring(ctx, &mut paint, thumb, radius, live_focused);
405 paint
406}
407
408fn thumb_rect(rect: LayoutRect, axis: Axis, geom: Geometry, offset: f32) -> LayoutRect {
409 let start = geom.thumb_start(offset);
410 let inset = THUMB_INSET_PX.min(cross_len(rect, axis) / 2.0);
411 match axis {
412 Axis::Vertical => LayoutRect::new(
413 LayoutPos::new(
414 LayoutPx::new(rect.origin.x.value() + inset),
415 LayoutPx::new(rect.origin.y.value() + start),
416 ),
417 LayoutSize::new(
418 LayoutPx::new((rect.size.width.value() - 2.0 * inset).max(0.0)),
419 LayoutPx::new(geom.thumb_len),
420 ),
421 ),
422 Axis::Horizontal => LayoutRect::new(
423 LayoutPos::new(
424 LayoutPx::new(rect.origin.x.value() + start),
425 LayoutPx::new(rect.origin.y.value() + inset),
426 ),
427 LayoutSize::new(
428 LayoutPx::new(geom.thumb_len),
429 LayoutPx::new((rect.size.height.value() - 2.0 * inset).max(0.0)),
430 ),
431 ),
432 }
433}
434
435fn cross_len(rect: LayoutRect, axis: Axis) -> f32 {
436 match axis {
437 Axis::Vertical => rect.size.width.value(),
438 Axis::Horizontal => rect.size.height.value(),
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use std::sync::Arc;
445
446 use super::{Geometry, MIN_THUMB_PX, Scrollbar, ScrollbarResponse, show_scrollbar};
447 use crate::a11y::AccessTreeBuilder;
448 use crate::focus::FocusManager;
449 use crate::frame::FrameCtx;
450 use crate::hit_test::{HitFrame, HitState, resolve};
451 use crate::hotkey::HotkeyTable;
452 use crate::input::{
453 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
454 PointerButtonMask, PointerSample,
455 };
456 use crate::layout::{Axis, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
457 use crate::strings::{StringKey, StringTable};
458 use crate::theme::Theme;
459 use crate::widget_id::{WidgetId, WidgetKey};
460
461 const LABEL: StringKey = StringKey::new("scrollbar.label");
462 const TRACK_LEN: f32 = 140.0;
463
464 fn bar_id() -> WidgetId {
465 WidgetId::ROOT.child(WidgetKey::new("scrollbar"))
466 }
467
468 fn rect() -> LayoutRect {
469 LayoutRect::new(
470 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
471 LayoutSize::new(LayoutPx::new(14.0), LayoutPx::new(TRACK_LEN)),
472 )
473 }
474
475 #[test]
476 fn thumb_shrinks_with_content() {
477 let small = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(280.0));
478 let large = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(700.0));
479 assert!(small.thumb_len > large.thumb_len);
480 assert!((small.thumb_len - TRACK_LEN / 2.0).abs() < 1e-3);
481 }
482
483 #[test]
484 fn thumb_fills_track_when_content_fits() {
485 let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100.0));
486 assert!((geom.thumb_len - TRACK_LEN).abs() < 1e-3);
487 assert!(!geom.scrollable());
488 }
489
490 #[test]
491 fn thumb_never_shorter_than_minimum() {
492 let geom = Geometry::of(rect(), Axis::Vertical, LayoutPx::new(100_000.0));
493 assert!(geom.thumb_len >= MIN_THUMB_PX - 1e-3);
494 }
495
496 fn run_keys(offset: f32, content: f32, events: Vec<KeyEvent>) -> ScrollbarResponse {
497 let theme = Arc::new(Theme::light());
498 let mut focus = FocusManager::new();
499 focus.register_focusable(bar_id());
500 focus.request_focus(bar_id());
501 focus.end_frame();
502 let table = HotkeyTable::new();
503 let mut hits = HitFrame::new();
504 let prev = HitState::new();
505 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
506 input.keys_pressed = events;
507 let widget = Scrollbar::new(
508 bar_id(),
509 rect(),
510 Axis::Vertical,
511 LABEL,
512 LayoutPx::new(content),
513 LayoutPx::new(offset),
514 );
515 let mut shaper = bone_text::Shaper::new();
516 let mut a11y = AccessTreeBuilder::new();
517 let mut ctx = FrameCtx::new(
518 theme,
519 &mut input,
520 &mut focus,
521 &table,
522 StringTable::empty(),
523 &mut hits,
524 &prev,
525 &mut a11y,
526 &mut shaper,
527 );
528 show_scrollbar(&mut ctx, widget)
529 }
530
531 fn key(named: NamedKey) -> KeyEvent {
532 KeyEvent::new(KeyCode::Named(named), ModifierMask::NONE)
533 }
534
535 #[test]
536 fn arrow_down_steps_forward() {
537 let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowDown)]);
538 assert!((response.offset.value() - 24.0).abs() < 1e-3);
539 assert!(response.changed);
540 }
541
542 #[test]
543 fn arrow_up_clamps_at_top() {
544 let response = run_keys(0.0, 560.0, vec![key(NamedKey::ArrowUp)]);
545 assert!((response.offset.value() - 0.0).abs() < 1e-3);
546 assert!(!response.changed);
547 }
548
549 #[test]
550 fn end_jumps_to_max_offset() {
551 let response = run_keys(0.0, 560.0, vec![key(NamedKey::End)]);
552 assert!((response.offset.value() - (560.0 - TRACK_LEN)).abs() < 1e-3);
553 }
554
555 #[test]
556 fn page_down_steps_by_track_length() {
557 let response = run_keys(0.0, 560.0, vec![key(NamedKey::PageDown)]);
558 assert!((response.offset.value() - TRACK_LEN).abs() < 1e-3);
559 }
560
561 #[test]
562 fn horizontal_ignores_vertical_arrows() {
563 let theme = Arc::new(Theme::light());
564 let mut focus = FocusManager::new();
565 focus.register_focusable(bar_id());
566 focus.request_focus(bar_id());
567 focus.end_frame();
568 let table = HotkeyTable::new();
569 let mut hits = HitFrame::new();
570 let prev = HitState::new();
571 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
572 input.keys_pressed = vec![key(NamedKey::ArrowDown)];
573 let rect = LayoutRect::new(
574 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
575 LayoutSize::new(LayoutPx::new(TRACK_LEN), LayoutPx::new(14.0)),
576 );
577 let widget = Scrollbar::new(
578 bar_id(),
579 rect,
580 Axis::Horizontal,
581 LABEL,
582 LayoutPx::new(560.0),
583 LayoutPx::new(0.0),
584 );
585 let mut shaper = bone_text::Shaper::new();
586 let mut a11y = AccessTreeBuilder::new();
587 let mut ctx = FrameCtx::new(
588 theme,
589 &mut input,
590 &mut focus,
591 &table,
592 StringTable::empty(),
593 &mut hits,
594 &prev,
595 &mut a11y,
596 &mut shaper,
597 );
598 let response = show_scrollbar(&mut ctx, widget);
599 assert!(!response.changed);
600 }
601
602 #[test]
603 fn not_scrollable_ignores_keys() {
604 let response = run_keys(0.0, 100.0, vec![key(NamedKey::End)]);
605 assert!((response.offset.value() - 0.0).abs() < 1e-3);
606 assert!(!response.changed);
607 }
608
609 fn run_pointer_press_at(content: f32, y: f32) -> ScrollbarResponse {
610 let theme = Arc::new(Theme::light());
611 let mut focus = FocusManager::new();
612 let table = HotkeyTable::new();
613 let mut prev_state = HitState::new();
614 let mut offset = 0.0_f32;
615 let pos = LayoutPos::new(LayoutPx::new(7.0), LayoutPx::new(y));
616 let mut frame = |pressed: PointerButtonMask| -> ScrollbarResponse {
617 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
618 snap.pointer = Some(PointerSample::new(pos));
619 snap.buttons_pressed = pressed;
620 let mut hits = HitFrame::new();
621 let response = {
622 let widget = Scrollbar::new(
623 bar_id(),
624 rect(),
625 Axis::Vertical,
626 LABEL,
627 LayoutPx::new(content),
628 LayoutPx::new(offset),
629 );
630 let mut shaper = bone_text::Shaper::new();
631 let mut a11y = AccessTreeBuilder::new();
632 let mut ctx = FrameCtx::new(
633 theme.clone(),
634 &mut snap,
635 &mut focus,
636 &table,
637 StringTable::empty(),
638 &mut hits,
639 &prev_state,
640 &mut a11y,
641 &mut shaper,
642 );
643 show_scrollbar(&mut ctx, widget)
644 };
645 offset = response.offset.value();
646 prev_state = resolve(&prev_state, &hits, &snap, focus.focused());
647 response
648 };
649 let _ = frame(PointerButtonMask::just(PointerButton::Primary));
650 frame(PointerButtonMask::EMPTY)
651 }
652
653 #[test]
654 fn pointer_press_near_bottom_scrolls_toward_max() {
655 let response = run_pointer_press_at(560.0, TRACK_LEN - 1.0);
656 assert!(
657 response.offset.value() > (560.0 - TRACK_LEN) * 0.8,
658 "expected near max, got {}",
659 response.offset.value(),
660 );
661 assert!(response.changed);
662 }
663
664 #[test]
665 fn pointer_press_near_top_scrolls_toward_zero() {
666 let response = run_pointer_press_at(560.0, 1.0);
667 assert!(
668 response.offset.value() < (560.0 - TRACK_LEN) * 0.2,
669 "expected near zero, got {}",
670 response.offset.value(),
671 );
672 }
673}