1#![forbid(unsafe_code)]
2
3use crate::mouse::MouseResult;
8use crate::{StatefulWidget, Widget, clear_text_area, 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 track_layout: Option<TrackLayout>,
118}
119
120#[derive(Debug, Clone, Copy)]
121struct TrackLayout {
122 rect: Rect,
123 is_vertical: bool,
124}
125
126impl ScrollbarState {
127 #[must_use]
129 pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
130 Self {
131 content_length,
132 position,
133 viewport_length,
134 drag_anchor: None,
135 track_layout: None,
136 }
137 }
138
139 fn calc_thumb_geometry(&self, track_len: usize) -> (usize, usize) {
141 if track_len == 0 {
142 return (0, 0);
143 }
144 if self.content_length == 0 {
145 return (0, track_len);
146 }
147
148 let viewport_ratio = self.viewport_length as f64 / self.content_length as f64;
149 let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
150 let thumb_size = thumb_size.min(track_len);
151
152 let max_pos = self.content_length.saturating_sub(self.viewport_length);
153 let pos_ratio = if max_pos == 0 {
154 0.0
155 } else {
156 self.position.min(max_pos) as f64 / max_pos as f64
157 };
158
159 let available_track = track_len.saturating_sub(thumb_size);
160 let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
161
162 (thumb_offset, thumb_size)
163 }
164
165 pub fn handle_mouse(
182 &mut self,
183 event: &MouseEvent,
184 hit: Option<(HitId, HitRegion, u64)>,
185 expected_id: HitId,
186 ) -> MouseResult {
187 match event.kind {
188 MouseEventKind::Down(MouseButton::Left) => {
189 if let Some((id, HitRegion::Scrollbar, data)) = hit
190 && id == expected_id
191 {
192 let part = data >> 56;
193 match part {
194 SCROLLBAR_PART_BEGIN => {
195 self.scroll_up(1);
196 return MouseResult::Scrolled;
197 }
198 SCROLLBAR_PART_END => {
199 self.scroll_down(1);
200 return MouseResult::Scrolled;
201 }
202 SCROLLBAR_PART_THUMB => {
203 let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
204 let track_pos = (data & 0x0FFF_FFFF) as usize;
205 let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
206 self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
208 return MouseResult::Scrolled;
210 }
211 SCROLLBAR_PART_TRACK => {
212 let track_len = ((data >> 28) & 0x0FFF_FFFF) as usize;
213 let track_pos = (data & 0x0FFF_FFFF) as usize;
214 if track_len == 0 {
215 return MouseResult::Ignored;
216 }
217
218 let (_, thumb_size) = self.calc_thumb_geometry(track_len);
220 let available = track_len.saturating_sub(thumb_size);
221 let denom = available.max(1);
222
223 let target_thumb_top = track_pos.saturating_sub(thumb_size / 2);
224 let clamped_top = target_thumb_top.min(denom);
225
226 let max_pos = self.content_length.saturating_sub(self.viewport_length);
227 self.position = if max_pos == 0 {
228 0
229 } else {
230 let num = (clamped_top as u128) * (max_pos as u128);
231 let pos = (num + (denom as u128 / 2)) / denom as u128;
232 pos as usize
233 };
234
235 let (thumb_offset, _) = self.calc_thumb_geometry(track_len);
238 self.drag_anchor = Some(track_pos.saturating_sub(thumb_offset));
239
240 return MouseResult::Scrolled;
241 }
242 _ => {}
243 }
244 }
245 MouseResult::Ignored
246 }
247 MouseEventKind::Drag(MouseButton::Left) => {
248 let hit_data = if let Some((id, HitRegion::Scrollbar, data)) = hit
252 && id == expected_id
253 {
254 let part = data >> 56;
255 if matches!(part, SCROLLBAR_PART_TRACK | SCROLLBAR_PART_THUMB) {
256 let len = ((data >> 28) & 0x0FFF_FFFF) as usize;
257 let pos = (data & 0x0FFF_FFFF) as usize;
258 Some((len, pos))
259 } else {
260 None
261 }
262 } else {
263 None
264 };
265
266 let (track_len, track_pos) = if let Some((len, pos)) = hit_data {
267 (len, pos)
268 } else if self.drag_anchor.is_some()
269 && let Some(layout) = self.track_layout
270 {
271 let rel = if layout.is_vertical {
273 event.y.saturating_sub(layout.rect.y)
274 } else {
275 event.x.saturating_sub(layout.rect.x)
276 };
277 let len = if layout.is_vertical {
278 layout.rect.height as usize
279 } else {
280 layout.rect.width as usize
281 };
282 let pos = rel.min(len.saturating_sub(1) as u16) as usize;
283 (len, pos)
284 } else {
285 return MouseResult::Ignored;
286 };
287
288 if track_len == 0 {
289 return MouseResult::Ignored;
290 }
291
292 let (_, thumb_size) = self.calc_thumb_geometry(track_len);
293 let available = track_len.saturating_sub(thumb_size);
294 let denom = available.max(1);
295
296 let anchor = self.drag_anchor.unwrap_or(thumb_size / 2);
299
300 let target_thumb_top = track_pos.saturating_sub(anchor);
302 let clamped_top = target_thumb_top.min(denom);
303
304 let max_pos = self.content_length.saturating_sub(self.viewport_length);
305 self.position = if max_pos == 0 {
306 0
307 } else {
308 let num = (clamped_top as u128) * (max_pos as u128);
310 let pos = (num + (denom as u128 / 2)) / denom as u128;
312 pos as usize
313 };
314 MouseResult::Scrolled
315 }
316 MouseEventKind::Up(MouseButton::Left) => {
317 let was_dragging = self.drag_anchor.take().is_some();
318 if was_dragging {
319 MouseResult::Scrolled
320 } else {
321 MouseResult::Ignored
322 }
323 }
324 MouseEventKind::ScrollUp => {
325 self.scroll_up(3);
326 MouseResult::Scrolled
327 }
328 MouseEventKind::ScrollDown => {
329 self.scroll_down(3);
330 MouseResult::Scrolled
331 }
332 _ => MouseResult::Ignored,
333 }
334 }
335
336 pub fn scroll_up(&mut self, lines: usize) {
338 self.position = self.position.saturating_sub(lines);
339 }
340
341 pub fn scroll_down(&mut self, lines: usize) {
345 let max_pos = self.content_length.saturating_sub(self.viewport_length);
346 self.position = self.position.saturating_add(lines).min(max_pos);
347 }
348}
349
350impl<'a> StatefulWidget for Scrollbar<'a> {
351 type State = ScrollbarState;
352
353 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
354 #[cfg(feature = "tracing")]
355 let _span = tracing::debug_span!(
356 "widget_render",
357 widget = "Scrollbar",
358 x = area.x,
359 y = area.y,
360 w = area.width,
361 h = area.height
362 )
363 .entered();
364
365 if !frame.buffer.degradation.render_decorative() {
367 state.track_layout = None;
368 clear_text_area(frame, area, Style::default());
369 return;
370 }
371
372 if area.is_empty() || state.content_length == 0 {
373 state.track_layout = None;
374 clear_text_area(frame, area, Style::default());
375 return;
376 }
377
378 let is_vertical = match self.orientation {
379 ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
380 ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
381 };
382
383 let length = if is_vertical { area.height } else { area.width } as usize;
384 if length == 0 {
385 state.track_layout = None;
386 return;
387 }
388
389 let start_offset = if let Some(s) = self.begin_symbol {
391 if is_vertical { 1 } else { display_width(s) }
392 } else {
393 0
394 };
395 let end_offset = if let Some(s) = self.end_symbol {
396 if is_vertical { 1 } else { display_width(s) }
397 } else {
398 0
399 };
400
401 let track_len = length.saturating_sub(start_offset + end_offset);
403
404 let (thumb_offset, thumb_size) = state.calc_thumb_geometry(track_len);
406
407 let track_char = self
409 .track_symbol
410 .unwrap_or(if is_vertical { "│" } else { "─" });
411 let thumb_char = self.thumb_symbol.unwrap_or("█");
412 let begin_char = self
413 .begin_symbol
414 .unwrap_or(if is_vertical { "▲" } else { "◄" });
415 let end_char = self
416 .end_symbol
417 .unwrap_or(if is_vertical { "▼" } else { "►" });
418
419 let max_w = display_width(track_char)
421 .max(display_width(thumb_char))
422 .max(1);
423 let track_rect = if is_vertical {
424 let x = match self.orientation {
425 ScrollbarOrientation::VerticalRight => {
426 area.right().saturating_sub(max_w as u16).max(area.left())
427 }
428 ScrollbarOrientation::VerticalLeft => area.left(),
429 _ => unreachable!(),
430 };
431 Rect::new(
432 x,
433 area.top().saturating_add(start_offset as u16),
434 max_w as u16,
435 track_len as u16,
436 )
437 } else {
438 let y = match self.orientation {
439 ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
440 ScrollbarOrientation::HorizontalTop => area.top(),
441 _ => unreachable!(),
442 };
443 Rect::new(
444 area.left().saturating_add(start_offset as u16),
445 y,
446 track_len as u16,
447 1,
448 )
449 };
450 state.track_layout = Some(TrackLayout {
451 rect: track_rect,
452 is_vertical,
453 });
454
455 let mut next_draw_index = 0;
457 for i in 0..length {
458 if i < next_draw_index {
459 continue;
460 }
461
462 let (symbol, part, rel_pos) = if i < start_offset {
464 (begin_char, SCROLLBAR_PART_BEGIN, 0)
465 } else if i >= length.saturating_sub(end_offset) {
466 (end_char, SCROLLBAR_PART_END, 0)
467 } else {
468 let track_idx = i - start_offset;
469 let is_thumb = track_idx >= thumb_offset && track_idx < thumb_offset + thumb_size;
470 if is_thumb {
471 (thumb_char, SCROLLBAR_PART_THUMB, track_idx)
472 } else {
473 (track_char, SCROLLBAR_PART_TRACK, track_idx)
474 }
475 };
476
477 let symbol_width = display_width(symbol);
478 if is_vertical {
479 next_draw_index = i + 1;
480 } else {
481 next_draw_index = i + symbol_width;
482 }
483
484 let style = if !frame.buffer.degradation.apply_styling() {
485 Style::default()
486 } else if part == SCROLLBAR_PART_THUMB {
487 self.thumb_style
488 } else {
489 self.track_style
490 };
491
492 let (x, y) = if is_vertical {
493 let x = match self.orientation {
494 ScrollbarOrientation::VerticalRight => area
495 .right()
496 .saturating_sub(symbol_width.max(1) as u16)
497 .max(area.left()),
498 ScrollbarOrientation::VerticalLeft => area.left(),
499 _ => unreachable!(),
500 };
501 (x, area.top().saturating_add(i as u16))
502 } else {
503 let y = match self.orientation {
504 ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
505 ScrollbarOrientation::HorizontalTop => area.top(),
506 _ => unreachable!(),
507 };
508 (area.left().saturating_add(i as u16), y)
509 };
510
511 if x < area.right() && y < area.bottom() {
513 draw_text_span(frame, x, y, symbol, style, area.right());
514
515 if let Some(id) = self.hit_id {
516 let data = (part << 56)
518 | ((track_len as u64 & 0x0FFF_FFFF) << 28)
519 | (rel_pos as u64 & 0x0FFF_FFFF);
520
521 let hit_w = (symbol_width.max(1) as u16).min(area.right().saturating_sub(x));
522 frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
523 }
524 }
525 }
526 }
527}
528
529impl<'a> Widget for Scrollbar<'a> {
530 fn render(&self, area: Rect, frame: &mut Frame) {
531 let mut state = ScrollbarState::default();
532 StatefulWidget::render(self, area, frame, &mut state);
533 }
534}
535
536impl ftui_a11y::Accessible for Scrollbar<'_> {
541 fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
542 use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};
543
544 let id = crate::a11y_node_id(area);
545 let orientation = match self.orientation {
546 ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => "vertical",
547 ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => {
548 "horizontal"
549 }
550 };
551 let name = format!("{orientation} scrollbar");
552 let node = A11yNodeInfo::new(id, A11yRole::ScrollBar, area)
553 .with_name(name)
554 .with_state(A11yState {
555 ..A11yState::default()
558 });
559 vec![node]
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use ftui_render::grapheme_pool::GraphemePool;
567
568 #[test]
569 fn scrollbar_empty_area() {
570 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
571 let area = Rect::new(0, 0, 0, 0);
572 let mut pool = GraphemePool::new();
573 let mut frame = Frame::new(1, 1, &mut pool);
574 let mut state = ScrollbarState::new(100, 0, 10);
575 StatefulWidget::render(&sb, area, &mut frame, &mut state);
576 }
577
578 #[test]
579 fn scrollbar_zero_content() {
580 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
581 let area = Rect::new(0, 0, 1, 10);
582 let mut pool = GraphemePool::new();
583 let mut frame = Frame::new(1, 10, &mut pool);
584 let mut state = ScrollbarState::new(100, 0, 10);
585 StatefulWidget::render(&sb, area, &mut frame, &mut state);
586
587 state.content_length = 0;
588 StatefulWidget::render(&sb, area, &mut frame, &mut state);
589
590 for y in 0..10u16 {
591 assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
592 }
593 assert!(state.track_layout.is_none());
594 }
595
596 #[test]
597 fn scrollbar_vertical_right_renders() {
598 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
599 let area = Rect::new(0, 0, 1, 10);
600 let mut pool = GraphemePool::new();
601 let mut frame = Frame::new(1, 10, &mut pool);
602 let mut state = ScrollbarState::new(100, 0, 10);
603 StatefulWidget::render(&sb, area, &mut frame, &mut state);
604
605 let top_cell = frame.buffer.get(0, 0).unwrap();
607 assert!(top_cell.content.as_char().is_some());
608 }
609
610 #[test]
611 fn scrollbar_vertical_left_renders() {
612 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
613 let area = Rect::new(0, 0, 1, 10);
614 let mut pool = GraphemePool::new();
615 let mut frame = Frame::new(1, 10, &mut pool);
616 let mut state = ScrollbarState::new(100, 0, 10);
617 StatefulWidget::render(&sb, area, &mut frame, &mut state);
618
619 let top_cell = frame.buffer.get(0, 0).unwrap();
620 assert!(top_cell.content.as_char().is_some());
621 }
622
623 #[test]
624 fn scrollbar_horizontal_renders() {
625 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
626 let area = Rect::new(0, 0, 10, 1);
627 let mut pool = GraphemePool::new();
628 let mut frame = Frame::new(10, 1, &mut pool);
629 let mut state = ScrollbarState::new(100, 0, 10);
630 StatefulWidget::render(&sb, area, &mut frame, &mut state);
631
632 let left_cell = frame.buffer.get(0, 0).unwrap();
633 assert!(left_cell.content.as_char().is_some());
634 }
635
636 #[test]
637 fn scrollbar_thumb_moves_with_position() {
638 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
639 let area = Rect::new(0, 0, 1, 10);
640
641 let mut pool1 = GraphemePool::new();
643 let mut frame1 = Frame::new(1, 10, &mut pool1);
644 let mut state1 = ScrollbarState::new(100, 0, 10);
645 StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
646
647 let mut pool2 = GraphemePool::new();
649 let mut frame2 = Frame::new(1, 10, &mut pool2);
650 let mut state2 = ScrollbarState::new(100, 90, 10);
651 StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
652
653 let thumb_char = '█';
655 let thumb_pos_1 = (0..10u16)
656 .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
657 let thumb_pos_2 = (0..10u16)
658 .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
659
660 assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
662 }
663
664 #[test]
665 fn scrollbar_state_constructor() {
666 let state = ScrollbarState::new(200, 50, 20);
667 assert_eq!(state.content_length, 200);
668 assert_eq!(state.position, 50);
669 assert_eq!(state.viewport_length, 20);
670 }
671
672 #[test]
673 fn scrollbar_content_fits_viewport() {
674 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
676 let area = Rect::new(0, 0, 1, 10);
677 let mut pool = GraphemePool::new();
678 let mut frame = Frame::new(1, 10, &mut pool);
679 let mut state = ScrollbarState::new(5, 0, 10);
680 StatefulWidget::render(&sb, area, &mut frame, &mut state);
681
682 let thumb_char = '█';
684 for y in 0..10u16 {
685 assert_eq!(
686 frame.buffer.get(0, y).unwrap().content.as_char(),
687 Some(thumb_char)
688 );
689 }
690 }
691
692 #[test]
693 fn scrollbar_horizontal_top_renders() {
694 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
695 let area = Rect::new(0, 0, 10, 1);
696 let mut pool = GraphemePool::new();
697 let mut frame = Frame::new(10, 1, &mut pool);
698 let mut state = ScrollbarState::new(100, 0, 10);
699 StatefulWidget::render(&sb, area, &mut frame, &mut state);
700
701 let left_cell = frame.buffer.get(0, 0).unwrap();
702 assert!(left_cell.content.as_char().is_some());
703 }
704
705 #[test]
706 fn scrollbar_custom_symbols() {
707 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
708 ".",
709 "#",
710 Some("^"),
711 Some("v"),
712 );
713 let area = Rect::new(0, 0, 1, 5);
714 let mut pool = GraphemePool::new();
715 let mut frame = Frame::new(1, 5, &mut pool);
716 let mut state = ScrollbarState::new(50, 0, 10);
717 StatefulWidget::render(&sb, area, &mut frame, &mut state);
718
719 let mut chars: Vec<Option<char>> = Vec::new();
721 for y in 0..5u16 {
722 chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
723 }
724 assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
726 }
727
728 #[test]
729 fn scrollbar_position_clamped_beyond_max() {
730 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
731 let area = Rect::new(0, 0, 1, 10);
732 let mut pool = GraphemePool::new();
733 let mut frame = Frame::new(1, 10, &mut pool);
734 let mut state = ScrollbarState::new(100, 500, 10);
736 StatefulWidget::render(&sb, area, &mut frame, &mut state);
737
738 let thumb_char = '█';
740 let thumb_pos = (0..10u16)
741 .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
742 assert!(thumb_pos.is_some());
743 }
744
745 #[test]
746 fn scrollbar_state_default() {
747 let state = ScrollbarState::default();
748 assert_eq!(state.content_length, 0);
749 assert_eq!(state.position, 0);
750 assert_eq!(state.viewport_length, 0);
751 }
752
753 #[test]
754 fn scrollbar_widget_trait_renders() {
755 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
756 let area = Rect::new(0, 0, 1, 5);
757 let mut pool = GraphemePool::new();
758 let mut frame = Frame::new(1, 5, &mut pool);
759 Widget::render(&sb, area, &mut frame);
761 }
763
764 #[test]
765 fn scrollbar_orientation_default_is_vertical_right() {
766 assert_eq!(
767 ScrollbarOrientation::default(),
768 ScrollbarOrientation::VerticalRight
769 );
770 }
771
772 #[test]
775 fn degradation_essential_only_skips_entirely() {
776 use ftui_render::budget::DegradationLevel;
777
778 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
779 let area = Rect::new(0, 0, 1, 10);
780 let mut pool = GraphemePool::new();
781 let mut frame = Frame::new(1, 10, &mut pool);
782 frame.buffer.degradation = DegradationLevel::EssentialOnly;
783 let mut state = ScrollbarState::new(100, 0, 10);
784 frame.buffer.degradation = DegradationLevel::Full;
785 StatefulWidget::render(&sb, area, &mut frame, &mut state);
786 frame.buffer.degradation = DegradationLevel::EssentialOnly;
787 StatefulWidget::render(&sb, area, &mut frame, &mut state);
788
789 for y in 0..10u16 {
790 assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
791 }
792 assert!(state.track_layout.is_none());
793 }
794
795 #[test]
796 fn degradation_skeleton_skips_entirely() {
797 use ftui_render::budget::DegradationLevel;
798
799 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
800 let area = Rect::new(0, 0, 1, 10);
801 let mut pool = GraphemePool::new();
802 let mut frame = Frame::new(1, 10, &mut pool);
803 frame.buffer.degradation = DegradationLevel::Skeleton;
804 let mut state = ScrollbarState::new(100, 0, 10);
805 frame.buffer.degradation = DegradationLevel::Full;
806 StatefulWidget::render(&sb, area, &mut frame, &mut state);
807 frame.buffer.degradation = DegradationLevel::Skeleton;
808 StatefulWidget::render(&sb, area, &mut frame, &mut state);
809
810 for y in 0..10u16 {
811 assert_eq!(frame.buffer.get(0, y).unwrap().content.as_char(), Some(' '));
812 }
813 assert!(state.track_layout.is_none());
814 }
815
816 #[test]
817 fn degradation_full_renders_scrollbar() {
818 use ftui_render::budget::DegradationLevel;
819
820 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
821 let area = Rect::new(0, 0, 1, 10);
822 let mut pool = GraphemePool::new();
823 let mut frame = Frame::new(1, 10, &mut pool);
824 frame.buffer.degradation = DegradationLevel::Full;
825 let mut state = ScrollbarState::new(100, 0, 10);
826 StatefulWidget::render(&sb, area, &mut frame, &mut state);
827
828 let top_cell = frame.buffer.get(0, 0).unwrap();
830 assert!(top_cell.content.as_char().is_some());
831 }
832
833 #[test]
834 fn degradation_simple_borders_still_renders() {
835 use ftui_render::budget::DegradationLevel;
836
837 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
838 let area = Rect::new(0, 0, 1, 10);
839 let mut pool = GraphemePool::new();
840 let mut frame = Frame::new(1, 10, &mut pool);
841 frame.buffer.degradation = DegradationLevel::SimpleBorders;
842 let mut state = ScrollbarState::new(100, 0, 10);
843 StatefulWidget::render(&sb, area, &mut frame, &mut state);
844
845 let top_cell = frame.buffer.get(0, 0).unwrap();
847 assert!(top_cell.content.as_char().is_some());
848 }
849
850 #[test]
851 fn scrollbar_wide_symbols_horizontal() {
852 let sb =
853 Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
854 let area = Rect::new(0, 0, 4, 1);
856 let mut pool = GraphemePool::new();
857 let mut frame = Frame::new(4, 1, &mut pool);
858 let mut state = ScrollbarState::new(10, 0, 10);
863
864 StatefulWidget::render(&sb, area, &mut frame, &mut state);
865
866 let c0 = frame.buffer.get(0, 0).unwrap();
868 assert!(!c0.is_empty() && !c0.is_continuation()); let c1 = frame.buffer.get(1, 0).unwrap();
871 assert!(c1.is_continuation());
872
873 let c2 = frame.buffer.get(2, 0).unwrap();
875 assert!(!c2.is_empty() && !c2.is_continuation()); let c3 = frame.buffer.get(3, 0).unwrap();
878 assert!(c3.is_continuation());
879 }
880
881 #[test]
882 fn scrollbar_wide_symbols_vertical() {
883 let sb =
884 Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
885 let area = Rect::new(0, 0, 2, 2);
887 let mut pool = GraphemePool::new();
888 let mut frame = Frame::new(2, 2, &mut pool);
889 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
892
893 let r0_c0 = frame.buffer.get(0, 0).unwrap();
895 assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); let r0_c1 = frame.buffer.get(1, 0).unwrap();
897 assert!(r0_c1.is_continuation()); let r1_c0 = frame.buffer.get(0, 1).unwrap();
901 assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); let r1_c1 = frame.buffer.get(1, 1).unwrap();
903 assert!(r1_c1.is_continuation()); }
905
906 #[test]
907 fn scrollbar_wide_symbol_clips_drawing_and_hits_to_area() {
908 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
910 .symbols("🔴", "👍", None, None)
911 .hit_id(HitId::new(1));
912 let area = Rect::new(0, 0, 3, 1);
913 let mut pool = GraphemePool::new();
914 let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
915 let mut state = ScrollbarState::new(3, 0, 3); StatefulWidget::render(&sb, area, &mut frame, &mut state);
918
919 let outside = frame.buffer.get(3, 0).unwrap();
921 assert!(outside.is_empty(), "cell outside area should remain empty");
922 assert!(frame.hit_test(3, 0).is_none(), "no hit outside area");
923 }
924
925 #[test]
926 fn scrollbar_wide_symbol_vertical_clips_drawing_and_hits_to_area() {
927 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft)
928 .symbols("🔴", "👍", None, None)
929 .hit_id(HitId::new(1));
930 let area = Rect::new(0, 0, 1, 2);
931 let mut pool = GraphemePool::new();
932 let mut frame = Frame::with_hit_grid(2, 2, &mut pool);
933 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
936
937 let outside = frame.buffer.get(1, 0).unwrap();
939 assert!(outside.is_empty(), "cell outside area should remain empty");
940 assert!(frame.hit_test(1, 0).is_none(), "no hit outside area");
941 }
942
943 #[test]
944 fn scrollbar_vertical_right_never_draws_left_of_area_for_wide_symbols() {
945 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
948 .symbols("🔴", "👍", None, None)
949 .hit_id(HitId::new(1));
950 let area = Rect::new(2, 0, 1, 2);
951 let mut pool = GraphemePool::new();
952 let mut frame = Frame::with_hit_grid(4, 2, &mut pool);
953 let mut state = ScrollbarState::new(10, 0, 10);
954
955 StatefulWidget::render(&sb, area, &mut frame, &mut state);
956
957 let outside = frame.buffer.get(1, 0).unwrap();
959 assert!(outside.is_empty(), "cell left of area should remain empty");
960 assert!(frame.hit_test(1, 0).is_none(), "no hit left of area");
961 }
962
963 use crate::mouse::MouseResult;
966 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
967
968 #[test]
969 fn scrollbar_state_begin_button() {
970 let mut state = ScrollbarState::new(100, 10, 20);
971 let data = SCROLLBAR_PART_BEGIN << 56;
972 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
973 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
974 let result = state.handle_mouse(&event, hit, HitId::new(1));
975 assert_eq!(result, MouseResult::Scrolled);
976 assert_eq!(state.position, 9);
977 }
978
979 #[test]
980 fn scrollbar_state_end_button() {
981 let mut state = ScrollbarState::new(100, 10, 20);
982 let data = SCROLLBAR_PART_END << 56;
983 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
984 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
985 let result = state.handle_mouse(&event, hit, HitId::new(1));
986 assert_eq!(result, MouseResult::Scrolled);
987 assert_eq!(state.position, 11);
988 }
989
990 #[test]
991 fn scrollbar_state_track_click() {
992 let mut state = ScrollbarState::new(100, 0, 20);
993 let track_len = 20u64;
994 let track_pos = 10u64;
995 let data = (SCROLLBAR_PART_TRACK << 56)
996 | ((track_len & 0x0FFF_FFFF) << 28)
997 | (track_pos & 0x0FFF_FFFF);
998 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
999 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
1000 let result = state.handle_mouse(&event, hit, HitId::new(1));
1001 assert_eq!(result, MouseResult::Scrolled);
1002 assert_eq!(state.position, 40);
1004 }
1005
1006 #[test]
1007 fn scrollbar_state_track_click_clamps() {
1008 let mut state = ScrollbarState::new(100, 0, 20);
1009 let track_len = 20u64;
1010 let track_pos = 95u64;
1011 let data = (SCROLLBAR_PART_TRACK << 56)
1012 | ((track_len & 0x0FFF_FFFF) << 28)
1013 | (track_pos & 0x0FFF_FFFF);
1014 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
1015 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
1016 let result = state.handle_mouse(&event, hit, HitId::new(1));
1017 assert_eq!(result, MouseResult::Scrolled);
1018 assert_eq!(state.position, 80); }
1020
1021 #[test]
1022 fn scrollbar_state_thumb_drag_updates_position() {
1023 let mut state = ScrollbarState::new(100, 0, 20);
1024 let track_len = 20u64;
1025 let track_pos = 19u64;
1026 let data = (SCROLLBAR_PART_THUMB << 56)
1027 | ((track_len & 0x0FFF_FFFF) << 28)
1028 | (track_pos & 0x0FFF_FFFF);
1029 let event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 0);
1030 let hit = Some((HitId::new(1), HitRegion::Scrollbar, data));
1031 let result = state.handle_mouse(&event, hit, HitId::new(1));
1032 assert_eq!(result, MouseResult::Scrolled);
1033 assert_eq!(state.position, 80);
1034 }
1035
1036 #[test]
1037 fn scrollbar_state_scroll_wheel_up() {
1038 let mut state = ScrollbarState::new(100, 10, 20);
1039 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
1040 let result = state.handle_mouse(&event, None, HitId::new(1));
1041 assert_eq!(result, MouseResult::Scrolled);
1042 assert_eq!(state.position, 7);
1043 }
1044
1045 #[test]
1046 fn scrollbar_state_scroll_wheel_down() {
1047 let mut state = ScrollbarState::new(100, 10, 20);
1048 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
1049 let result = state.handle_mouse(&event, None, HitId::new(1));
1050 assert_eq!(result, MouseResult::Scrolled);
1051 assert_eq!(state.position, 13);
1052 }
1053
1054 #[test]
1055 fn scrollbar_state_scroll_down_clamps() {
1056 let mut state = ScrollbarState::new(100, 78, 20);
1057 state.scroll_down(5);
1058 assert_eq!(state.position, 80); }
1060
1061 #[test]
1062 fn scrollbar_state_scroll_up_clamps() {
1063 let mut state = ScrollbarState::new(100, 2, 20);
1064 state.scroll_up(5);
1065 assert_eq!(state.position, 0);
1066 }
1067
1068 #[test]
1069 fn scrollbar_state_wrong_id_ignored() {
1070 let mut state = ScrollbarState::new(100, 10, 20);
1071 let data = SCROLLBAR_PART_BEGIN << 56;
1072 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
1073 let hit = Some((HitId::new(99), HitRegion::Scrollbar, data));
1074 let result = state.handle_mouse(&event, hit, HitId::new(1));
1075 assert_eq!(result, MouseResult::Ignored);
1076 assert_eq!(state.position, 10);
1077 }
1078
1079 #[test]
1080 fn scrollbar_state_right_click_ignored() {
1081 let mut state = ScrollbarState::new(100, 10, 20);
1082 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
1083 let result = state.handle_mouse(&event, None, HitId::new(1));
1084 assert_eq!(result, MouseResult::Ignored);
1085 }
1086}