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 pub drag_anchor: Option<usize>,
116}
117
118impl ScrollbarState {
119 #[must_use]
121 pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
122 Self {
123 content_length,
124 position,
125 viewport_length,
126 drag_anchor: None,
127 }
128 }
129
130 fn calc_thumb_geometry(&self, track_len: usize) -> (usize, usize) {
132 if track_len == 0 {
133 return (0, 0);
134 }
135 if self.content_length == 0 {
136 return (0, track_len);
137 }
138
139 let viewport_ratio = self.viewport_length as f64 / self.content_length as f64;
140 let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
141 let thumb_size = thumb_size.min(track_len);
142
143 let max_pos = self.content_length.saturating_sub(self.viewport_length);
144 let pos_ratio = if max_pos == 0 {
145 0.0
146 } else {
147 self.position.min(max_pos) as f64 / max_pos as f64
148 };
149
150 let available_track = track_len.saturating_sub(thumb_size);
151 let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
152
153 (thumb_offset, thumb_size)
154 }
155
156 pub fn handle_mouse(
173 &mut self,
174 event: &MouseEvent,
175 hit: Option<(HitId, HitRegion, u64)>,
176 expected_id: HitId,
177 ) -> MouseResult {
178 match event.kind {
179 MouseEventKind::Down(MouseButton::Left) => {
180 if let Some((id, HitRegion::Scrollbar, data)) = hit
181 && id == expected_id
182 {
183 let part = data >> 56;
184 match part {
185 SCROLLBAR_PART_BEGIN => {
186 self.scroll_up(1);
187 return MouseResult::Scrolled;
188 }
189 SCROLLBAR_PART_END => {
190 self.scroll_down(1);
191 return MouseResult::Scrolled;
192 }
193 SCROLLBAR_PART_THUMB => {
194 let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
195 let track_pos = (data & 0x0FFF_FFFF) as usize;
196 let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
197 self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
199 return MouseResult::Ignored;
201 }
202 SCROLLBAR_PART_TRACK => {
203 let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
204 let track_pos = (data & 0x0FFF_FFFF) as usize;
205 if track_len == 0 {
206 return MouseResult::Ignored;
207 }
208
209 let max_pos = self.content_length.saturating_sub(self.viewport_length);
211 let denom = track_len.saturating_sub(1).max(1);
212 let clamped_pos = track_pos.min(denom);
213 self.position = if max_pos == 0 {
214 0
215 } else {
216 let num = (clamped_pos as u128) * (max_pos as u128);
217 let pos = (num + (denom as u128 / 2)) / denom as u128;
218 pos as usize
219 };
220 return MouseResult::Scrolled;
221 }
222 _ => {}
223 }
224 }
225 MouseResult::Ignored
226 }
227 MouseEventKind::Drag(MouseButton::Left) => {
228 if let Some((id, HitRegion::Scrollbar, data)) = hit
229 && id == expected_id
230 {
231 let part = data >> 56;
232 if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
233 let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
234 let track_pos = (data & 0x0FFF_FFFF) as usize;
235
236 if track_len == 0 {
237 return MouseResult::Ignored;
238 }
239
240 let (_, thumb_size) = self.calc_thumb_geometry(track_len);
241 let available = track_len.saturating_sub(thumb_size);
242 let denom = available.max(1);
243
244 let anchor = self.drag_anchor.unwrap_or(thumb_size / 2);
247
248 let target_thumb_top = track_pos.saturating_sub(anchor);
250 let clamped_top = target_thumb_top.min(denom);
251
252 let max_pos = self.content_length.saturating_sub(self.viewport_length);
253 self.position = if max_pos == 0 {
254 0
255 } else {
256 let num = (clamped_top as u128) * (max_pos as u128);
258 let pos = (num + (denom as u128 / 2)) / denom as u128;
260 pos as usize
261 };
262 return MouseResult::Scrolled;
263 }
264 }
265 MouseResult::Ignored
266 }
267 MouseEventKind::Up(MouseButton::Left) => {
268 self.drag_anchor = None;
269 MouseResult::Ignored
270 }
271 MouseEventKind::ScrollUp => {
272 self.scroll_up(3);
273 MouseResult::Scrolled
274 }
275 MouseEventKind::ScrollDown => {
276 self.scroll_down(3);
277 MouseResult::Scrolled
278 }
279 _ => MouseResult::Ignored,
280 }
281 }
282
283 pub fn scroll_up(&mut self, lines: usize) {
285 self.position = self.position.saturating_sub(lines);
286 }
287
288 pub fn scroll_down(&mut self, lines: usize) {
292 let max_pos = self.content_length.saturating_sub(self.viewport_length);
293 self.position = self.position.saturating_add(lines).min(max_pos);
294 }
295}
296
297impl<'a> StatefulWidget for Scrollbar<'a> {
298 type State = ScrollbarState;
299
300 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
301 #[cfg(feature = "tracing")]
302 let _span = tracing::debug_span!(
303 "widget_render",
304 widget = "Scrollbar",
305 x = area.x,
306 y = area.y,
307 w = area.width,
308 h = area.height
309 )
310 .entered();
311
312 if !frame.buffer.degradation.render_decorative() {
314 return;
315 }
316
317 if area.is_empty() || state.content_length == 0 {
318 return;
319 }
320
321 let is_vertical = match self.orientation {
322 ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
323 ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
324 };
325
326 let length = if is_vertical { area.height } else { area.width } as usize;
327 if length == 0 {
328 return;
329 }
330
331 let start_offset = if self.begin_symbol.is_some() { 1 } else { 0 };
333 let end_offset = if self.end_symbol.is_some() { 1 } else { 0 };
334
335 let track_len = length.saturating_sub(start_offset + end_offset);
337
338 let (thumb_offset, thumb_size) = state.calc_thumb_geometry(track_len);
340
341 let track_char = self
343 .track_symbol
344 .unwrap_or(if is_vertical { "│" } else { "─" });
345 let thumb_char = self.thumb_symbol.unwrap_or("█");
346 let begin_char = self
347 .begin_symbol
348 .unwrap_or(if is_vertical { "▲" } else { "◄" });
349 let end_char = self
350 .end_symbol
351 .unwrap_or(if is_vertical { "▼" } else { "►" });
352
353 let mut next_draw_index = 0;
355 for i in 0..length {
356 if i < next_draw_index {
357 continue;
358 }
359
360 let (symbol, part, rel_pos) = if i < start_offset {
362 (begin_char, SCROLLBAR_PART_BEGIN, 0)
363 } else if i >= length - end_offset {
364 (end_char, SCROLLBAR_PART_END, 0)
365 } else {
366 let track_idx = i - start_offset;
367 let is_thumb = track_idx >= thumb_offset && track_idx < thumb_offset + thumb_size;
368 if is_thumb {
369 (thumb_char, SCROLLBAR_PART_THUMB, track_idx)
370 } else {
371 (track_char, SCROLLBAR_PART_TRACK, track_idx)
372 }
373 };
374
375 let symbol_width = display_width(symbol);
376 if is_vertical {
377 next_draw_index = i + 1;
378 } else {
379 next_draw_index = i + symbol_width;
380 }
381
382 let style = if !frame.buffer.degradation.apply_styling() {
383 Style::default()
384 } else if part == SCROLLBAR_PART_THUMB {
385 self.thumb_style
386 } else {
387 self.track_style
388 };
389
390 let (x, y) = if is_vertical {
391 let x = match self.orientation {
392 ScrollbarOrientation::VerticalRight => area
393 .right()
394 .saturating_sub(symbol_width.max(1) as u16)
395 .max(area.left()),
396 ScrollbarOrientation::VerticalLeft => area.left(),
397 _ => unreachable!(),
398 };
399 (x, area.top().saturating_add(i as u16))
400 } else {
401 let y = match self.orientation {
402 ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
403 ScrollbarOrientation::HorizontalTop => area.top(),
404 _ => unreachable!(),
405 };
406 (area.left().saturating_add(i as u16), y)
407 };
408
409 if x < area.right() && y < area.bottom() {
411 draw_text_span(frame, x, y, symbol, style, area.right());
412
413 if let Some(id) = self.hit_id {
414 let data = (part << 56)
416 | ((track_len as u64 & 0x0FFF_FFFF) << 28)
417 | (rel_pos as u64 & 0x0FFF_FFFF);
418
419 let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
420 frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
421 }
422 }
423 }
424 }
425}
426
427impl<'a> Widget for Scrollbar<'a> {
428 fn render(&self, area: Rect, frame: &mut Frame) {
429 let mut state = ScrollbarState::default();
430 StatefulWidget::render(self, area, frame, &mut state);
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use ftui_render::grapheme_pool::GraphemePool;
438
439 #[test]
440 fn scrollbar_empty_area() {
441 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
442 let area = Rect::new(0, 0, 0, 0);
443 let mut pool = GraphemePool::new();
444 let mut frame = Frame::new(1, 1, &mut pool);
445 let mut state = ScrollbarState::new(100, 0, 10);
446 StatefulWidget::render(&sb, area, &mut frame, &mut state);
447 }
448
449 #[test]
450 fn scrollbar_zero_content() {
451 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
452 let area = Rect::new(0, 0, 1, 10);
453 let mut pool = GraphemePool::new();
454 let mut frame = Frame::new(1, 10, &mut pool);
455 let mut state = ScrollbarState::new(0, 0, 10);
456 StatefulWidget::render(&sb, area, &mut frame, &mut state);
457 }
459
460 #[test]
461 fn scrollbar_vertical_right_renders() {
462 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
463 let area = Rect::new(0, 0, 1, 10);
464 let mut pool = GraphemePool::new();
465 let mut frame = Frame::new(1, 10, &mut pool);
466 let mut state = ScrollbarState::new(100, 0, 10);
467 StatefulWidget::render(&sb, area, &mut frame, &mut state);
468
469 let top_cell = frame.buffer.get(0, 0).unwrap();
471 assert!(top_cell.content.as_char().is_some());
472 }
473
474 #[test]
475 fn scrollbar_vertical_left_renders() {
476 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
477 let area = Rect::new(0, 0, 1, 10);
478 let mut pool = GraphemePool::new();
479 let mut frame = Frame::new(1, 10, &mut pool);
480 let mut state = ScrollbarState::new(100, 0, 10);
481 StatefulWidget::render(&sb, area, &mut frame, &mut state);
482
483 let top_cell = frame.buffer.get(0, 0).unwrap();
484 assert!(top_cell.content.as_char().is_some());
485 }
486
487 #[test]
488 fn scrollbar_horizontal_renders() {
489 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
490 let area = Rect::new(0, 0, 10, 1);
491 let mut pool = GraphemePool::new();
492 let mut frame = Frame::new(10, 1, &mut pool);
493 let mut state = ScrollbarState::new(100, 0, 10);
494 StatefulWidget::render(&sb, area, &mut frame, &mut state);
495
496 let left_cell = frame.buffer.get(0, 0).unwrap();
497 assert!(left_cell.content.as_char().is_some());
498 }
499
500 #[test]
501 fn scrollbar_thumb_moves_with_position() {
502 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
503 let area = Rect::new(0, 0, 1, 10);
504
505 let mut pool1 = GraphemePool::new();
507 let mut frame1 = Frame::new(1, 10, &mut pool1);
508 let mut state1 = ScrollbarState::new(100, 0, 10);
509 StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
510
511 let mut pool2 = GraphemePool::new();
513 let mut frame2 = Frame::new(1, 10, &mut pool2);
514 let mut state2 = ScrollbarState::new(100, 90, 10);
515 StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
516
517 let thumb_char = '█';
519 let thumb_pos_1 = (0..10u16)
520 .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
521 let thumb_pos_2 = (0..10u16)
522 .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
523
524 assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
526 }
527
528 #[test]
529 fn scrollbar_state_constructor() {
530 let state = ScrollbarState::new(200, 50, 20);
531 assert_eq!(state.content_length, 200);
532 assert_eq!(state.position, 50);
533 assert_eq!(state.viewport_length, 20);
534 }
535
536 #[test]
537 fn scrollbar_content_fits_viewport() {
538 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
540 let area = Rect::new(0, 0, 1, 10);
541 let mut pool = GraphemePool::new();
542 let mut frame = Frame::new(1, 10, &mut pool);
543 let mut state = ScrollbarState::new(5, 0, 10);
544 StatefulWidget::render(&sb, area, &mut frame, &mut state);
545
546 let thumb_char = '█';
548 for y in 0..10u16 {
549 assert_eq!(
550 frame.buffer.get(0, y).unwrap().content.as_char(),
551 Some(thumb_char)
552 );
553 }
554 }
555
556 #[test]
557 fn scrollbar_horizontal_top_renders() {
558 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
559 let area = Rect::new(0, 0, 10, 1);
560 let mut pool = GraphemePool::new();
561 let mut frame = Frame::new(10, 1, &mut pool);
562 let mut state = ScrollbarState::new(100, 0, 10);
563 StatefulWidget::render(&sb, area, &mut frame, &mut state);
564
565 let left_cell = frame.buffer.get(0, 0).unwrap();
566 assert!(left_cell.content.as_char().is_some());
567 }
568
569 #[test]
570 fn scrollbar_custom_symbols() {
571 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
572 ".",
573 "#",
574 Some("^"),
575 Some("v"),
576 );
577 let area = Rect::new(0, 0, 1, 5);
578 let mut pool = GraphemePool::new();
579 let mut frame = Frame::new(1, 5, &mut pool);
580 let mut state = ScrollbarState::new(50, 0, 10);
581 StatefulWidget::render(&sb, area, &mut frame, &mut state);
582
583 let mut chars: Vec<Option<char>> = Vec::new();
585 for y in 0..5u16 {
586 chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
587 }
588 assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
590 }
591
592 #[test]
593 fn scrollbar_position_clamped_beyond_max() {
594 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
595 let area = Rect::new(0, 0, 1, 10);
596 let mut pool = GraphemePool::new();
597 let mut frame = Frame::new(1, 10, &mut pool);
598 let mut state = ScrollbarState::new(100, 500, 10);
600 StatefulWidget::render(&sb, area, &mut frame, &mut state);
601
602 let thumb_char = '█';
604 let thumb_pos = (0..10u16)
605 .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
606 assert!(thumb_pos.is_some());
607 }
608
609 #[test]
610 fn scrollbar_state_default() {
611 let state = ScrollbarState::default();
612 assert_eq!(state.content_length, 0);
613 assert_eq!(state.position, 0);
614 assert_eq!(state.viewport_length, 0);
615 }
616
617 #[test]
618 fn scrollbar_widget_trait_renders() {
619 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
620 let area = Rect::new(0, 0, 1, 5);
621 let mut pool = GraphemePool::new();
622 let mut frame = Frame::new(1, 5, &mut pool);
623 Widget::render(&sb, area, &mut frame);
625 }
627
628 #[test]
629 fn scrollbar_orientation_default_is_vertical_right() {
630 assert_eq!(
631 ScrollbarOrientation::default(),
632 ScrollbarOrientation::VerticalRight
633 );
634 }
635
636 #[test]
639 fn degradation_essential_only_skips_entirely() {
640 use ftui_render::budget::DegradationLevel;
641
642 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
643 let area = Rect::new(0, 0, 1, 10);
644 let mut pool = GraphemePool::new();
645 let mut frame = Frame::new(1, 10, &mut pool);
646 frame.buffer.degradation = DegradationLevel::EssentialOnly;
647 let mut state = ScrollbarState::new(100, 0, 10);
648 StatefulWidget::render(&sb, area, &mut frame, &mut state);
649
650 for y in 0..10u16 {
652 assert!(
653 frame.buffer.get(0, y).unwrap().is_empty(),
654 "cell at y={y} should be empty at EssentialOnly"
655 );
656 }
657 }
658
659 #[test]
660 fn degradation_skeleton_skips_entirely() {
661 use ftui_render::budget::DegradationLevel;
662
663 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
664 let area = Rect::new(0, 0, 1, 10);
665 let mut pool = GraphemePool::new();
666 let mut frame = Frame::new(1, 10, &mut pool);
667 frame.buffer.degradation = DegradationLevel::Skeleton;
668 let mut state = ScrollbarState::new(100, 0, 10);
669 StatefulWidget::render(&sb, area, &mut frame, &mut state);
670
671 for y in 0..10u16 {
672 assert!(
673 frame.buffer.get(0, y).unwrap().is_empty(),
674 "cell at y={y} should be empty at Skeleton"
675 );
676 }
677 }
678
679 #[test]
680 fn degradation_full_renders_scrollbar() {
681 use ftui_render::budget::DegradationLevel;
682
683 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
684 let area = Rect::new(0, 0, 1, 10);
685 let mut pool = GraphemePool::new();
686 let mut frame = Frame::new(1, 10, &mut pool);
687 frame.buffer.degradation = DegradationLevel::Full;
688 let mut state = ScrollbarState::new(100, 0, 10);
689 StatefulWidget::render(&sb, area, &mut frame, &mut state);
690
691 let top_cell = frame.buffer.get(0, 0).unwrap();
693 assert!(top_cell.content.as_char().is_some());
694 }
695
696 #[test]
697 fn degradation_simple_borders_still_renders() {
698 use ftui_render::budget::DegradationLevel;
699
700 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
701 let area = Rect::new(0, 0, 1, 10);
702 let mut pool = GraphemePool::new();
703 let mut frame = Frame::new(1, 10, &mut pool);
704 frame.buffer.degradation = DegradationLevel::SimpleBorders;
705 let mut state = ScrollbarState::new(100, 0, 10);
706 StatefulWidget::render(&sb, area, &mut frame, &mut state);
707
708 let top_cell = frame.buffer.get(0, 0).unwrap();
710 assert!(top_cell.content.as_char().is_some());
711 }
712
713 #[test]
714 fn scrollbar_wide_symbols_horizontal() {
715 let sb =
716 Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
717 let area = Rect::new(0, 0, 4, 1);
719 let mut pool = GraphemePool::new();
720 let mut frame = Frame::new(4, 1, &mut pool);
721 let mut state = ScrollbarState::new(10, 0, 10);
726
727 StatefulWidget::render(&sb, area, &mut frame, &mut state);
728
729 let c0 = frame.buffer.get(0, 0).unwrap();
731 assert!(!c0.is_empty() && !c0.is_continuation()); let c1 = frame.buffer.get(1, 0).unwrap();
734 assert!(c1.is_continuation());
735
736 let c2 = frame.buffer.get(2, 0).unwrap();
738 assert!(!c2.is_empty() && !c2.is_continuation()); let c3 = frame.buffer.get(3, 0).unwrap();
741 assert!(c3.is_continuation());
742 }
743
744 #[test]
745 fn scrollbar_wide_symbols_vertical() {
746 let sb =
747 Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
748 let area = Rect::new(0, 0, 2, 2);
750 let mut pool = GraphemePool::new();
751 let mut frame = Frame::new(2, 2, &mut pool);
752 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
755
756 let r0_c0 = frame.buffer.get(0, 0).unwrap();
758 assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); let r0_c1 = frame.buffer.get(1, 0).unwrap();
760 assert!(r0_c1.is_continuation()); let r1_c0 = frame.buffer.get(0, 1).unwrap();
764 assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); let r1_c1 = frame.buffer.get(1, 1).unwrap();
766 assert!(r1_c1.is_continuation()); }
768
769 #[test]
770 fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
771 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
773 .symbols("🔴", "👍", None, None)
774 .hit_id(HitId::new(1));
775 let area = Rect::new(0, 0, 3, 1);
776 let mut pool = GraphemePool::new();
777 let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
778 let mut state = ScrollbarState::new(3, 0, 3); StatefulWidget::render(&sb, area, &mut frame, &mut state);
781
782 let outside = frame.buffer.get(3, 0).unwrap();
784 assert!(outside.is_empty(), "cell outside area should remain empty");
785 assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
786 }
787
788 #[test]
789 fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
790 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
791 .symbols("🔴", "👍", None, None)
792 .hit_id(HitId::new(1));
793 let area = Rect::new(0, 0, 1, 2);
794 let mut pool = GraphemePool::new();
795 let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
796 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
799
800 let outside = frame.buffer.get(1, 0).unwrap();
802 assert!(outside.is_empty(), "cell outside area should remain empty");
803 assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
804 }
805
806 #[test]
807 fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
808 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
811 .symbols("🔴", "👍", None, None)
812 .hit_id(HitId::new(1));
813 let area = Rect::new(2, 0, 1, 2);
814 let mut pool = GraphemePool::new();
815 let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
816 let mut state = ScrollbarState::new(10, 0, 10);
817
818 StatefulWidget::render(&sb, area, &mut frame, &mut state);
819
820 let outside = frame.buffer.get(1, 0).unwrap();
822 assert!(outside.is_empty(), "cell left of area should remain empty");
823 assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
824 }
825
826 use crate::mouse::MouseResult;
829 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
830
831 #[test]
832 fn scrollbar_state_begin_button() {
833 let mut state = ScrollbarState::new(100, 10, 20);
834 let data = SCROLLBAR_PART_BEGIN << 56;
835 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
836 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
837 let result = state.handle_mouse(&event, hit, HitId::new(1));
838 assert_eq!(result, MouseResult::Scrolled);
839 assert_eq!(state.position, 9);
840 }
841
842 #[test]
843 fn scrollbar_state_end_button() {
844 let mut state = ScrollbarState::new(100, 10, 20);
845 let data = SCROLLBAR_PART_END << 56;
846 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
847 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
848 let result = state.handle_mouse(&event, hit, HitId::new(1));
849 assert_eq!(result, MouseResult::Scrolled);
850 assert_eq!(state.position, 11);
851 }
852
853 #[test]
854 fn scrollbar_state_track_click() {
855 let mut state = ScrollbarState::new(100, 0, 20);
856 let track_len = 20u64;
857 let track_pos = 10u64;
858 let data = (SCROLLBAR_PART_TRACK << 56)
859 | ((track_len & 0x0FFF_FFFF) << 28)
860 | (track_pos & 0x0FFF_FFFF);
861 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
862 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
863 let result = state.handle_mouse(&event, hit, HitId::new(1));
864 assert_eq!(result, MouseResult::Scrolled);
865 assert_eq!(state.position, 42);
867 }
868
869 #[test]
870 fn scrollbar_state_track_click_clamps() {
871 let mut state = ScrollbarState::new(100, 0, 20);
872 let track_len = 20u64;
873 let track_pos = 95u64;
874 let data = (SCROLLBAR_PART_TRACK << 56)
875 | ((track_len & 0x0FFF_FFFF) << 28)
876 | (track_pos & 0x0FFF_FFFF);
877 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
878 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
879 let result = state.handle_mouse(&event, hit, HitId::new(1));
880 assert_eq!(result, MouseResult::Scrolled);
881 assert_eq!(state.position, 80); }
883
884 #[test]
885 fn scrollbar_state_thumb_drag_updates_position() {
886 let mut state = ScrollbarState::new(100, 0, 20);
887 let track_len = 20u64;
888 let track_pos = 19u64;
889 let data = (SCROLLBAR_PART_THUMB << 56)
890 | ((track_len & 0x0FFF_FFFF) << 28)
891 | (track_pos & 0x0FFF_FFFF);
892 let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
893 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
894 let result = state.handle_mouse(&event, hit, HitId::new(1));
895 assert_eq!(result, MouseResult::Scrolled);
896 assert_eq!(state.position, 80);
897 }
898
899 #[test]
900 fn scrollbar_state_scroll_wheel_up() {
901 let mut state = ScrollbarState::new(100, 10, 20);
902 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
903 let result = state.handle_mouse(&event, None, HitId::new(1));
904 assert_eq!(result, MouseResult::Scrolled);
905 assert_eq!(state.position, 7);
906 }
907
908 #[test]
909 fn scrollbar_state_scroll_wheel_down() {
910 let mut state = ScrollbarState::new(100, 10, 20);
911 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
912 let result = state.handle_mouse(&event, None, HitId::new(1));
913 assert_eq!(result, MouseResult::Scrolled);
914 assert_eq!(state.position, 13);
915 }
916
917 #[test]
918 fn scrollbar_state_scroll_down_clamps() {
919 let mut state = ScrollbarState::new(100, 78, 20);
920 state.scroll_down(5);
921 assert_eq!(state.position, 80); }
923
924 #[test]
925 fn scrollbar_state_scroll_up_clamps() {
926 let mut state = ScrollbarState::new(100, 2, 20);
927 state.scroll_up(5);
928 assert_eq!(state.position, 0);
929 }
930
931 #[test]
932 fn scrollbar_state_wrong_id_ignored() {
933 let mut state = ScrollbarState::new(100, 10, 20);
934 let data = SCROLLBAR_PART_BEGIN << 56;
935 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
936 let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
937 let result = state.handle_mouse(&event, hit, HitId::new(1));
938 assert_eq!(result, MouseResult::Ignored);
939 assert_eq!(state.position, 10);
940 }
941
942 #[test]
943 fn scrollbar_state_right_click_ignored() {
944 let mut state = ScrollbarState::new(100, 10, 20);
945 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
946 let result = state.handle_mouse(&event, None, HitId::new(1));
947 assert_eq!(result, MouseResult::Ignored);
948 }
949}