1#![forbid(unsafe_code)]
2
3use crate::mouse::MouseResult;
8use crate::{StatefulWidget, Widget, draw_text_span};
9use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
10use ftui_core::geometry::Rect;
11use ftui_render::frame::{Frame, HitId, HitRegion};
12use ftui_style::Style;
13use ftui_text::display_width;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ScrollbarOrientation {
18 #[default]
20 VerticalRight,
21 VerticalLeft,
23 HorizontalBottom,
25 HorizontalTop,
27}
28
29pub const SCROLLBAR_PART_TRACK: u64 = 0;
31pub const SCROLLBAR_PART_THUMB: u64 = 1;
33pub const SCROLLBAR_PART_BEGIN: u64 = 2;
35pub const SCROLLBAR_PART_END: u64 = 3;
37
38#[derive(Debug, Clone, Default)]
40pub struct Scrollbar<'a> {
41 orientation: ScrollbarOrientation,
42 thumb_style: Style,
43 track_style: Style,
44 begin_symbol: Option<&'a str>,
45 end_symbol: Option<&'a str>,
46 track_symbol: Option<&'a str>,
47 thumb_symbol: Option<&'a str>,
48 hit_id: Option<HitId>,
49}
50
51impl<'a> Scrollbar<'a> {
52 #[must_use]
54 pub fn new(orientation: ScrollbarOrientation) -> Self {
55 Self {
56 orientation,
57 thumb_style: Style::default(),
58 track_style: Style::default(),
59 begin_symbol: None,
60 end_symbol: None,
61 track_symbol: None,
62 thumb_symbol: None,
63 hit_id: None,
64 }
65 }
66
67 #[must_use]
69 pub fn thumb_style(mut self, style: Style) -> Self {
70 self.thumb_style = style;
71 self
72 }
73
74 #[must_use]
76 pub fn track_style(mut self, style: Style) -> Self {
77 self.track_style = style;
78 self
79 }
80
81 #[must_use]
83 pub fn symbols(
84 mut self,
85 track: &'a str,
86 thumb: &'a str,
87 begin: Option<&'a str>,
88 end: Option<&'a str>,
89 ) -> Self {
90 self.track_symbol = Some(track);
91 self.thumb_symbol = Some(thumb);
92 self.begin_symbol = begin;
93 self.end_symbol = end;
94 self
95 }
96
97 #[must_use]
99 pub fn hit_id(mut self, id: HitId) -> Self {
100 self.hit_id = Some(id);
101 self
102 }
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct ScrollbarState {
108 pub content_length: usize,
110 pub position: usize,
112 pub viewport_length: usize,
114}
115
116impl ScrollbarState {
117 #[must_use]
119 pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
120 Self {
121 content_length,
122 position,
123 viewport_length,
124 }
125 }
126
127 pub fn handle_mouse(
141 &mut self,
142 event: &MouseEvent,
143 hit: Option<(HitId, HitRegion, u64)>,
144 expected_id: HitId,
145 ) -> MouseResult {
146 match event.kind {
147 MouseEventKind::Down(MouseButton::Left) => {
148 if let Some((id, HitRegion::Scrollbar, data)) = hit
149 && id == expected_id
150 {
151 let part = data >> 56;
152 match part {
153 SCROLLBAR_PART_BEGIN => {
154 self.scroll_up(1);
155 return MouseResult::Scrolled;
156 }
157 SCROLLBAR_PART_END => {
158 self.scroll_down(1);
159 return MouseResult::Scrolled;
160 }
161 SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB => {
162 let track_pos = (data & 0x00FF_FFFF_FFFF_FFFF) as usize;
168 let max_pos = self.content_length.saturating_sub(self.viewport_length);
169 let track_len = self.viewport_length.max(1);
170 let denom = track_len.saturating_sub(1).max(1);
171 let clamped_pos = track_pos.min(denom);
172 self.position = if max_pos == 0 {
173 0
174 } else {
175 let num = (clamped_pos as u128) * (max_pos as u128);
177 let pos = (num + (denom as u128 / 2)) / denom as u128;
178 pos as usize
179 };
180 return MouseResult::Scrolled;
181 }
182 _ => {}
183 }
184 }
185 MouseResult::Ignored
186 }
187 MouseEventKind::Drag(MouseButton::Left) => {
188 if let Some((id, HitRegion::Scrollbar, data)) = hit
189 && id == expected_id
190 {
191 let part = data >> 56;
192 if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
193 let track_pos = (data & 0x00FF_FFFF_FFFF_FFFF) as usize;
194 let max_pos = self.content_length.saturating_sub(self.viewport_length);
195 let track_len = self.viewport_length.max(1);
196 let denom = track_len.saturating_sub(1).max(1);
197 let clamped_pos = track_pos.min(denom);
198 self.position = if max_pos == 0 {
199 0
200 } else {
201 let num = (clamped_pos as u128) * (max_pos as u128);
202 let pos = (num + (denom as u128 / 2)) / denom as u128;
203 pos as usize
204 };
205 return MouseResult::Scrolled;
206 }
207 }
208 MouseResult::Ignored
209 }
210 MouseEventKind::ScrollUp => {
211 self.scroll_up(3);
212 MouseResult::Scrolled
213 }
214 MouseEventKind::ScrollDown => {
215 self.scroll_down(3);
216 MouseResult::Scrolled
217 }
218 _ => MouseResult::Ignored,
219 }
220 }
221
222 pub fn scroll_up(&mut self, lines: usize) {
224 self.position = self.position.saturating_sub(lines);
225 }
226
227 pub fn scroll_down(&mut self, lines: usize) {
231 let max_pos = self.content_length.saturating_sub(self.viewport_length);
232 self.position = self.position.saturating_add(lines).min(max_pos);
233 }
234}
235
236impl<'a> StatefulWidget for Scrollbar<'a> {
237 type State = ScrollbarState;
238
239 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
240 #[cfg(feature = "tracing")]
241 let _span = tracing::debug_span!(
242 "widget_render",
243 widget = "Scrollbar",
244 x = area.x,
245 y = area.y,
246 w = area.width,
247 h = area.height
248 )
249 .entered();
250
251 if !frame.buffer.degradation.render_decorative() {
253 return;
254 }
255
256 if area.is_empty() || state.content_length == 0 {
257 return;
258 }
259
260 let is_vertical = match self.orientation {
261 ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
262 ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
263 };
264
265 let length = if is_vertical { area.height } else { area.width } as usize;
266 if length == 0 {
267 return;
268 }
269
270 let track_len = length;
273
274 let viewport_ratio = state.viewport_length as f64 / state.content_length as f64;
276 let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
277 let thumb_size = thumb_size.min(track_len);
278
279 let max_pos = state.content_length.saturating_sub(state.viewport_length);
280 let pos_ratio = if max_pos == 0 {
281 0.0
282 } else {
283 state.position.min(max_pos) as f64 / max_pos as f64
284 };
285
286 let available_track = track_len.saturating_sub(thumb_size);
287 let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
288
289 let track_char = self
291 .track_symbol
292 .unwrap_or(if is_vertical { "│" } else { "─" });
293 let thumb_char = self.thumb_symbol.unwrap_or("█");
294 let begin_char = self
295 .begin_symbol
296 .unwrap_or(if is_vertical { "▲" } else { "◄" });
297 let end_char = self
298 .end_symbol
299 .unwrap_or(if is_vertical { "▼" } else { "►" });
300
301 let mut next_draw_index = 0;
303 for i in 0..track_len {
304 if i < next_draw_index {
305 continue;
306 }
307
308 let is_thumb = i >= thumb_offset && i < thumb_offset + thumb_size;
309 let (symbol, part) = if is_thumb {
310 (thumb_char, SCROLLBAR_PART_THUMB)
311 } else if i == 0 && self.begin_symbol.is_some() {
312 (begin_char, SCROLLBAR_PART_BEGIN)
313 } else if i == track_len - 1 && self.end_symbol.is_some() {
314 (end_char, SCROLLBAR_PART_END)
315 } else {
316 (track_char, SCROLLBAR_PART_TRACK)
317 };
318
319 let symbol_width = display_width(symbol);
320 if is_vertical {
321 next_draw_index = i + 1;
322 } else {
323 next_draw_index = i + symbol_width;
324 }
325
326 let style = if !frame.buffer.degradation.apply_styling() {
327 Style::default()
328 } else if is_thumb {
329 self.thumb_style
330 } else {
331 self.track_style
332 };
333
334 let (x, y) = if is_vertical {
335 let x = match self.orientation {
336 ScrollbarOrientation::VerticalRight => area
338 .right()
339 .saturating_sub(symbol_width.max(1) as u16)
340 .max(area.left()),
341 ScrollbarOrientation::VerticalLeft => area.left(),
342 _ => unreachable!(),
343 };
344 (x, area.top().saturating_add(i as u16))
345 } else {
346 let y = match self.orientation {
347 ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
348 ScrollbarOrientation::HorizontalTop => area.top(),
349 _ => unreachable!(),
350 };
351 (area.left().saturating_add(i as u16), y)
352 };
353
354 if x < area.right() && y < area.bottom() {
356 draw_text_span(frame, x, y, symbol, style, area.right());
359
360 if let Some(id) = self.hit_id {
361 let data = (part << 56) | (i as u64);
362 let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
364 frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
365 }
366 }
367 }
368 }
369}
370
371impl<'a> Widget for Scrollbar<'a> {
372 fn render(&self, area: Rect, frame: &mut Frame) {
373 let mut state = ScrollbarState::default();
374 StatefulWidget::render(self, area, frame, &mut state);
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use ftui_render::grapheme_pool::GraphemePool;
382
383 #[test]
384 fn scrollbar_empty_area() {
385 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
386 let area = Rect::new(0, 0, 0, 0);
387 let mut pool = GraphemePool::new();
388 let mut frame = Frame::new(1, 1, &mut pool);
389 let mut state = ScrollbarState::new(100, 0, 10);
390 StatefulWidget::render(&sb, area, &mut frame, &mut state);
391 }
392
393 #[test]
394 fn scrollbar_zero_content() {
395 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
396 let area = Rect::new(0, 0, 1, 10);
397 let mut pool = GraphemePool::new();
398 let mut frame = Frame::new(1, 10, &mut pool);
399 let mut state = ScrollbarState::new(0, 0, 10);
400 StatefulWidget::render(&sb, area, &mut frame, &mut state);
401 }
403
404 #[test]
405 fn scrollbar_vertical_right_renders() {
406 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
407 let area = Rect::new(0, 0, 1, 10);
408 let mut pool = GraphemePool::new();
409 let mut frame = Frame::new(1, 10, &mut pool);
410 let mut state = ScrollbarState::new(100, 0, 10);
411 StatefulWidget::render(&sb, area, &mut frame, &mut state);
412
413 let top_cell = frame.buffer.get(0, 0).unwrap();
415 assert!(top_cell.content.as_char().is_some());
416 }
417
418 #[test]
419 fn scrollbar_vertical_left_renders() {
420 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
421 let area = Rect::new(0, 0, 1, 10);
422 let mut pool = GraphemePool::new();
423 let mut frame = Frame::new(1, 10, &mut pool);
424 let mut state = ScrollbarState::new(100, 0, 10);
425 StatefulWidget::render(&sb, area, &mut frame, &mut state);
426
427 let top_cell = frame.buffer.get(0, 0).unwrap();
428 assert!(top_cell.content.as_char().is_some());
429 }
430
431 #[test]
432 fn scrollbar_horizontal_renders() {
433 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
434 let area = Rect::new(0, 0, 10, 1);
435 let mut pool = GraphemePool::new();
436 let mut frame = Frame::new(10, 1, &mut pool);
437 let mut state = ScrollbarState::new(100, 0, 10);
438 StatefulWidget::render(&sb, area, &mut frame, &mut state);
439
440 let left_cell = frame.buffer.get(0, 0).unwrap();
441 assert!(left_cell.content.as_char().is_some());
442 }
443
444 #[test]
445 fn scrollbar_thumb_moves_with_position() {
446 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
447 let area = Rect::new(0, 0, 1, 10);
448
449 let mut pool1 = GraphemePool::new();
451 let mut frame1 = Frame::new(1, 10, &mut pool1);
452 let mut state1 = ScrollbarState::new(100, 0, 10);
453 StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
454
455 let mut pool2 = GraphemePool::new();
457 let mut frame2 = Frame::new(1, 10, &mut pool2);
458 let mut state2 = ScrollbarState::new(100, 90, 10);
459 StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
460
461 let thumb_char = '█';
463 let thumb_pos_1 = (0..10u16)
464 .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
465 let thumb_pos_2 = (0..10u16)
466 .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
467
468 assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
470 }
471
472 #[test]
473 fn scrollbar_state_constructor() {
474 let state = ScrollbarState::new(200, 50, 20);
475 assert_eq!(state.content_length, 200);
476 assert_eq!(state.position, 50);
477 assert_eq!(state.viewport_length, 20);
478 }
479
480 #[test]
481 fn scrollbar_content_fits_viewport() {
482 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
484 let area = Rect::new(0, 0, 1, 10);
485 let mut pool = GraphemePool::new();
486 let mut frame = Frame::new(1, 10, &mut pool);
487 let mut state = ScrollbarState::new(5, 0, 10);
488 StatefulWidget::render(&sb, area, &mut frame, &mut state);
489
490 let thumb_char = '█';
492 for y in 0..10u16 {
493 assert_eq!(
494 frame.buffer.get(0, y).unwrap().content.as_char(),
495 Some(thumb_char)
496 );
497 }
498 }
499
500 #[test]
501 fn scrollbar_horizontal_top_renders() {
502 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
503 let area = Rect::new(0, 0, 10, 1);
504 let mut pool = GraphemePool::new();
505 let mut frame = Frame::new(10, 1, &mut pool);
506 let mut state = ScrollbarState::new(100, 0, 10);
507 StatefulWidget::render(&sb, area, &mut frame, &mut state);
508
509 let left_cell = frame.buffer.get(0, 0).unwrap();
510 assert!(left_cell.content.as_char().is_some());
511 }
512
513 #[test]
514 fn scrollbar_custom_symbols() {
515 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
516 ".",
517 "#",
518 Some("^"),
519 Some("v"),
520 );
521 let area = Rect::new(0, 0, 1, 5);
522 let mut pool = GraphemePool::new();
523 let mut frame = Frame::new(1, 5, &mut pool);
524 let mut state = ScrollbarState::new(50, 0, 10);
525 StatefulWidget::render(&sb, area, &mut frame, &mut state);
526
527 let mut chars: Vec<Option<char>> = Vec::new();
529 for y in 0..5u16 {
530 chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
531 }
532 assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
534 }
535
536 #[test]
537 fn scrollbar_position_clamped_beyond_max() {
538 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
539 let area = Rect::new(0, 0, 1, 10);
540 let mut pool = GraphemePool::new();
541 let mut frame = Frame::new(1, 10, &mut pool);
542 let mut state = ScrollbarState::new(100, 500, 10);
544 StatefulWidget::render(&sb, area, &mut frame, &mut state);
545
546 let thumb_char = '█';
548 let thumb_pos = (0..10u16)
549 .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
550 assert!(thumb_pos.is_some());
551 }
552
553 #[test]
554 fn scrollbar_state_default() {
555 let state = ScrollbarState::default();
556 assert_eq!(state.content_length, 0);
557 assert_eq!(state.position, 0);
558 assert_eq!(state.viewport_length, 0);
559 }
560
561 #[test]
562 fn scrollbar_widget_trait_renders() {
563 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
564 let area = Rect::new(0, 0, 1, 5);
565 let mut pool = GraphemePool::new();
566 let mut frame = Frame::new(1, 5, &mut pool);
567 Widget::render(&sb, area, &mut frame);
569 }
571
572 #[test]
573 fn scrollbar_orientation_default_is_vertical_right() {
574 assert_eq!(
575 ScrollbarOrientation::default(),
576 ScrollbarOrientation::VerticalRight
577 );
578 }
579
580 #[test]
583 fn degradation_essential_only_skips_entirely() {
584 use ftui_render::budget::DegradationLevel;
585
586 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
587 let area = Rect::new(0, 0, 1, 10);
588 let mut pool = GraphemePool::new();
589 let mut frame = Frame::new(1, 10, &mut pool);
590 frame.buffer.degradation = DegradationLevel::EssentialOnly;
591 let mut state = ScrollbarState::new(100, 0, 10);
592 StatefulWidget::render(&sb, area, &mut frame, &mut state);
593
594 for y in 0..10u16 {
596 assert!(
597 frame.buffer.get(0, y).unwrap().is_empty(),
598 "cell at y={y} should be empty at EssentialOnly"
599 );
600 }
601 }
602
603 #[test]
604 fn degradation_skeleton_skips_entirely() {
605 use ftui_render::budget::DegradationLevel;
606
607 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
608 let area = Rect::new(0, 0, 1, 10);
609 let mut pool = GraphemePool::new();
610 let mut frame = Frame::new(1, 10, &mut pool);
611 frame.buffer.degradation = DegradationLevel::Skeleton;
612 let mut state = ScrollbarState::new(100, 0, 10);
613 StatefulWidget::render(&sb, area, &mut frame, &mut state);
614
615 for y in 0..10u16 {
616 assert!(
617 frame.buffer.get(0, y).unwrap().is_empty(),
618 "cell at y={y} should be empty at Skeleton"
619 );
620 }
621 }
622
623 #[test]
624 fn degradation_full_renders_scrollbar() {
625 use ftui_render::budget::DegradationLevel;
626
627 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
628 let area = Rect::new(0, 0, 1, 10);
629 let mut pool = GraphemePool::new();
630 let mut frame = Frame::new(1, 10, &mut pool);
631 frame.buffer.degradation = DegradationLevel::Full;
632 let mut state = ScrollbarState::new(100, 0, 10);
633 StatefulWidget::render(&sb, area, &mut frame, &mut state);
634
635 let top_cell = frame.buffer.get(0, 0).unwrap();
637 assert!(top_cell.content.as_char().is_some());
638 }
639
640 #[test]
641 fn degradation_simple_borders_still_renders() {
642 use ftui_render::budget::DegradationLevel;
643
644 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
645 let area = Rect::new(0, 0, 1, 10);
646 let mut pool = GraphemePool::new();
647 let mut frame = Frame::new(1, 10, &mut pool);
648 frame.buffer.degradation = DegradationLevel::SimpleBorders;
649 let mut state = ScrollbarState::new(100, 0, 10);
650 StatefulWidget::render(&sb, area, &mut frame, &mut state);
651
652 let top_cell = frame.buffer.get(0, 0).unwrap();
654 assert!(top_cell.content.as_char().is_some());
655 }
656
657 #[test]
658 fn scrollbar_wide_symbols_horizontal() {
659 let sb =
660 Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
661 let area = Rect::new(0, 0, 4, 1);
663 let mut pool = GraphemePool::new();
664 let mut frame = Frame::new(4, 1, &mut pool);
665 let mut state = ScrollbarState::new(10, 0, 10);
670
671 StatefulWidget::render(&sb, area, &mut frame, &mut state);
672
673 let c0 = frame.buffer.get(0, 0).unwrap();
675 assert!(!c0.is_empty() && !c0.is_continuation()); let c1 = frame.buffer.get(1, 0).unwrap();
678 assert!(c1.is_continuation());
679
680 let c2 = frame.buffer.get(2, 0).unwrap();
682 assert!(!c2.is_empty() && !c2.is_continuation()); let c3 = frame.buffer.get(3, 0).unwrap();
685 assert!(c3.is_continuation());
686 }
687
688 #[test]
689 fn scrollbar_wide_symbols_vertical() {
690 let sb =
691 Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
692 let area = Rect::new(0, 0, 2, 2);
694 let mut pool = GraphemePool::new();
695 let mut frame = Frame::new(2, 2, &mut pool);
696 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
699
700 let r0_c0 = frame.buffer.get(0, 0).unwrap();
702 assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); let r0_c1 = frame.buffer.get(1, 0).unwrap();
704 assert!(r0_c1.is_continuation()); let r1_c0 = frame.buffer.get(0, 1).unwrap();
708 assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); let r1_c1 = frame.buffer.get(1, 1).unwrap();
710 assert!(r1_c1.is_continuation()); }
712
713 #[test]
714 fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
715 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
717 .symbols("🔴", "👍", None, None)
718 .hit_id(HitId::new(1));
719 let area = Rect::new(0, 0, 3, 1);
720 let mut pool = GraphemePool::new();
721 let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
722 let mut state = ScrollbarState::new(3, 0, 3); StatefulWidget::render(&sb, area, &mut frame, &mut state);
725
726 let outside = frame.buffer.get(3, 0).unwrap();
728 assert!(outside.is_empty(), "cell outside area should remain empty");
729 assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
730 }
731
732 #[test]
733 fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
734 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
735 .symbols("🔴", "👍", None, None)
736 .hit_id(HitId::new(1));
737 let area = Rect::new(0, 0, 1, 2);
738 let mut pool = GraphemePool::new();
739 let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
740 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
743
744 let outside = frame.buffer.get(1, 0).unwrap();
746 assert!(outside.is_empty(), "cell outside area should remain empty");
747 assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
748 }
749
750 #[test]
751 fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
752 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
755 .symbols("🔴", "👍", None, None)
756 .hit_id(HitId::new(1));
757 let area = Rect::new(2, 0, 1, 2);
758 let mut pool = GraphemePool::new();
759 let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
760 let mut state = ScrollbarState::new(10, 0, 10);
761
762 StatefulWidget::render(&sb, area, &mut frame, &mut state);
763
764 let outside = frame.buffer.get(1, 0).unwrap();
766 assert!(outside.is_empty(), "cell left of area should remain empty");
767 assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
768 }
769
770 use crate::mouse::MouseResult;
773 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
774
775 #[test]
776 fn scrollbar_state_begin_button() {
777 let mut state = ScrollbarState::new(100, 10, 20);
778 let data = SCROLLBAR_PART_BEGIN << 56;
779 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
780 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
781 let result = state.handle_mouse(&event, hit, HitId::new(1));
782 assert_eq!(result, MouseResult::Scrolled);
783 assert_eq!(state.position, 9);
784 }
785
786 #[test]
787 fn scrollbar_state_end_button() {
788 let mut state = ScrollbarState::new(100, 10, 20);
789 let data = SCROLLBAR_PART_END << 56;
790 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
791 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
792 let result = state.handle_mouse(&event, hit, HitId::new(1));
793 assert_eq!(result, MouseResult::Scrolled);
794 assert_eq!(state.position, 11);
795 }
796
797 #[test]
798 fn scrollbar_state_track_click() {
799 let mut state = ScrollbarState::new(100, 0, 20);
800 let track_pos = 10u64;
801 let data = (SCROLLBAR_PART_TRACK << 56) | track_pos;
802 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
803 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
804 let result = state.handle_mouse(&event, hit, HitId::new(1));
805 assert_eq!(result, MouseResult::Scrolled);
806 assert_eq!(state.position, 42);
808 }
809
810 #[test]
811 fn scrollbar_state_track_click_clamps() {
812 let mut state = ScrollbarState::new(100, 0, 20);
813 let track_pos = 95u64;
814 let data = (SCROLLBAR_PART_TRACK << 56) | track_pos;
815 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
816 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
817 let result = state.handle_mouse(&event, hit, HitId::new(1));
818 assert_eq!(result, MouseResult::Scrolled);
819 assert_eq!(state.position, 80); }
821
822 #[test]
823 fn scrollbar_state_thumb_drag_updates_position() {
824 let mut state = ScrollbarState::new(100, 0, 20);
825 let track_pos = 19u64;
826 let data = (SCROLLBAR_PART_THUMB << 56) | track_pos;
827 let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
828 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
829 let result = state.handle_mouse(&event, hit, HitId::new(1));
830 assert_eq!(result, MouseResult::Scrolled);
831 assert_eq!(state.position, 80);
832 }
833
834 #[test]
835 fn scrollbar_state_scroll_wheel_up() {
836 let mut state = ScrollbarState::new(100, 10, 20);
837 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
838 let result = state.handle_mouse(&event, None, HitId::new(1));
839 assert_eq!(result, MouseResult::Scrolled);
840 assert_eq!(state.position, 7);
841 }
842
843 #[test]
844 fn scrollbar_state_scroll_wheel_down() {
845 let mut state = ScrollbarState::new(100, 10, 20);
846 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
847 let result = state.handle_mouse(&event, None, HitId::new(1));
848 assert_eq!(result, MouseResult::Scrolled);
849 assert_eq!(state.position, 13);
850 }
851
852 #[test]
853 fn scrollbar_state_scroll_down_clamps() {
854 let mut state = ScrollbarState::new(100, 78, 20);
855 state.scroll_down(5);
856 assert_eq!(state.position, 80); }
858
859 #[test]
860 fn scrollbar_state_scroll_up_clamps() {
861 let mut state = ScrollbarState::new(100, 2, 20);
862 state.scroll_up(5);
863 assert_eq!(state.position, 0);
864 }
865
866 #[test]
867 fn scrollbar_state_wrong_id_ignored() {
868 let mut state = ScrollbarState::new(100, 10, 20);
869 let data = SCROLLBAR_PART_BEGIN << 56;
870 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
871 let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
872 let result = state.handle_mouse(&event, hit, HitId::new(1));
873 assert_eq!(result, MouseResult::Ignored);
874 assert_eq!(state.position, 10);
875 }
876
877 #[test]
878 fn scrollbar_state_right_click_ignored() {
879 let mut state = ScrollbarState::new(100, 10, 20);
880 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
881 let result = state.handle_mouse(&event, None, HitId::new(1));
882 assert_eq!(result, MouseResult::Ignored);
883 }
884}