1#![forbid(unsafe_code)]
2
3use crate::{StatefulWidget, Widget, draw_text_span};
8use ftui_core::geometry::Rect;
9use ftui_render::frame::{Frame, HitId, HitRegion};
10use ftui_style::Style;
11use ftui_text::display_width;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ScrollbarOrientation {
16 #[default]
18 VerticalRight,
19 VerticalLeft,
21 HorizontalBottom,
23 HorizontalTop,
25}
26
27pub const SCROLLBAR_PART_TRACK: u64 = 0;
29pub const SCROLLBAR_PART_THUMB: u64 = 1;
31pub const SCROLLBAR_PART_BEGIN: u64 = 2;
33pub const SCROLLBAR_PART_END: u64 = 3;
35
36#[derive(Debug, Clone, Default)]
38pub struct Scrollbar<'a> {
39 orientation: ScrollbarOrientation,
40 thumb_style: Style,
41 track_style: Style,
42 begin_symbol: Option<&'a str>,
43 end_symbol: Option<&'a str>,
44 track_symbol: Option<&'a str>,
45 thumb_symbol: Option<&'a str>,
46 hit_id: Option<HitId>,
47}
48
49impl<'a> Scrollbar<'a> {
50 pub fn new(orientation: ScrollbarOrientation) -> Self {
52 Self {
53 orientation,
54 thumb_style: Style::default(),
55 track_style: Style::default(),
56 begin_symbol: None,
57 end_symbol: None,
58 track_symbol: None,
59 thumb_symbol: None,
60 hit_id: None,
61 }
62 }
63
64 pub fn thumb_style(mut self, style: Style) -> Self {
66 self.thumb_style = style;
67 self
68 }
69
70 pub fn track_style(mut self, style: Style) -> Self {
72 self.track_style = style;
73 self
74 }
75
76 pub fn symbols(
78 mut self,
79 track: &'a str,
80 thumb: &'a str,
81 begin: Option<&'a str>,
82 end: Option<&'a str>,
83 ) -> Self {
84 self.track_symbol = Some(track);
85 self.thumb_symbol = Some(thumb);
86 self.begin_symbol = begin;
87 self.end_symbol = end;
88 self
89 }
90
91 pub fn hit_id(mut self, id: HitId) -> Self {
93 self.hit_id = Some(id);
94 self
95 }
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct ScrollbarState {
101 pub content_length: usize,
103 pub position: usize,
105 pub viewport_length: usize,
107}
108
109impl ScrollbarState {
110 pub fn new(content_length: usize, position: usize, viewport_length: usize) -> Self {
112 Self {
113 content_length,
114 position,
115 viewport_length,
116 }
117 }
118}
119
120impl<'a> StatefulWidget for Scrollbar<'a> {
121 type State = ScrollbarState;
122
123 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
124 #[cfg(feature = "tracing")]
125 let _span = tracing::debug_span!(
126 "widget_render",
127 widget = "Scrollbar",
128 x = area.x,
129 y = area.y,
130 w = area.width,
131 h = area.height
132 )
133 .entered();
134
135 if !frame.buffer.degradation.render_decorative() {
137 return;
138 }
139
140 if area.is_empty() || state.content_length == 0 {
141 return;
142 }
143
144 let is_vertical = match self.orientation {
145 ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => true,
146 ScrollbarOrientation::HorizontalBottom | ScrollbarOrientation::HorizontalTop => false,
147 };
148
149 let length = if is_vertical { area.height } else { area.width } as usize;
150 if length == 0 {
151 return;
152 }
153
154 let track_len = length;
157
158 let viewport_ratio = state.viewport_length as f64 / state.content_length as f64;
160 let thumb_size = (track_len as f64 * viewport_ratio).max(1.0).round() as usize;
161 let thumb_size = thumb_size.min(track_len);
162
163 let max_pos = state.content_length.saturating_sub(state.viewport_length);
164 let pos_ratio = if max_pos == 0 {
165 0.0
166 } else {
167 state.position.min(max_pos) as f64 / max_pos as f64
168 };
169
170 let available_track = track_len.saturating_sub(thumb_size);
171 let thumb_offset = (available_track as f64 * pos_ratio).round() as usize;
172
173 let track_char = self
175 .track_symbol
176 .unwrap_or(if is_vertical { "│" } else { "─" });
177 let thumb_char = self.thumb_symbol.unwrap_or("█");
178 let begin_char = self
179 .begin_symbol
180 .unwrap_or(if is_vertical { "▲" } else { "◄" });
181 let end_char = self
182 .end_symbol
183 .unwrap_or(if is_vertical { "▼" } else { "►" });
184
185 let mut next_draw_index = 0;
187 for i in 0..track_len {
188 if i < next_draw_index {
189 continue;
190 }
191
192 let is_thumb = i >= thumb_offset && i < thumb_offset + thumb_size;
193 let (symbol, part) = if is_thumb {
194 (thumb_char, SCROLLBAR_PART_THUMB)
195 } else if i == 0 && self.begin_symbol.is_some() {
196 (begin_char, SCROLLBAR_PART_BEGIN)
197 } else if i == track_len - 1 && self.end_symbol.is_some() {
198 (end_char, SCROLLBAR_PART_END)
199 } else {
200 (track_char, SCROLLBAR_PART_TRACK)
201 };
202
203 let symbol_width = display_width(symbol);
204 if is_vertical {
205 next_draw_index = i + 1;
206 } else {
207 next_draw_index = i + symbol_width;
208 }
209
210 let style = if !frame.buffer.degradation.apply_styling() {
211 Style::default()
212 } else if is_thumb {
213 self.thumb_style
214 } else {
215 self.track_style
216 };
217
218 let (x, y) = if is_vertical {
219 let x = match self.orientation {
220 ScrollbarOrientation::VerticalRight => {
222 area.right().saturating_sub(symbol_width.max(1) as u16)
223 }
224 ScrollbarOrientation::VerticalLeft => area.left(),
225 _ => unreachable!(),
226 };
227 (x, area.top().saturating_add(i as u16))
228 } else {
229 let y = match self.orientation {
230 ScrollbarOrientation::HorizontalBottom => area.bottom().saturating_sub(1),
231 ScrollbarOrientation::HorizontalTop => area.top(),
232 _ => unreachable!(),
233 };
234 (area.left().saturating_add(i as u16), y)
235 };
236
237 if x < area.right() && y < area.bottom() {
239 draw_text_span(
242 frame,
243 x,
244 y,
245 symbol,
246 style,
247 x.saturating_add(symbol_width as u16),
248 );
249
250 if let Some(id) = self.hit_id {
251 let data = (part << 56) | (i as u64);
252 let hit_w = symbol_width.max(1) as u16;
253 frame.register_hit(Rect::new(x, y, hit_w, 1), id, HitRegion::Scrollbar, data);
254 }
255 }
256 }
257 }
258}
259
260impl<'a> Widget for Scrollbar<'a> {
261 fn render(&self, area: Rect, frame: &mut Frame) {
262 let mut state = ScrollbarState::default();
263 StatefulWidget::render(self, area, frame, &mut state);
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use ftui_render::grapheme_pool::GraphemePool;
271
272 #[test]
273 fn scrollbar_empty_area() {
274 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
275 let area = Rect::new(0, 0, 0, 0);
276 let mut pool = GraphemePool::new();
277 let mut frame = Frame::new(1, 1, &mut pool);
278 let mut state = ScrollbarState::new(100, 0, 10);
279 StatefulWidget::render(&sb, area, &mut frame, &mut state);
280 }
281
282 #[test]
283 fn scrollbar_zero_content() {
284 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
285 let area = Rect::new(0, 0, 1, 10);
286 let mut pool = GraphemePool::new();
287 let mut frame = Frame::new(1, 10, &mut pool);
288 let mut state = ScrollbarState::new(0, 0, 10);
289 StatefulWidget::render(&sb, area, &mut frame, &mut state);
290 }
292
293 #[test]
294 fn scrollbar_vertical_right_renders() {
295 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
296 let area = Rect::new(0, 0, 1, 10);
297 let mut pool = GraphemePool::new();
298 let mut frame = Frame::new(1, 10, &mut pool);
299 let mut state = ScrollbarState::new(100, 0, 10);
300 StatefulWidget::render(&sb, area, &mut frame, &mut state);
301
302 let top_cell = frame.buffer.get(0, 0).unwrap();
304 assert!(top_cell.content.as_char().is_some());
305 }
306
307 #[test]
308 fn scrollbar_vertical_left_renders() {
309 let sb = Scrollbar::new(ScrollbarOrientation::VerticalLeft);
310 let area = Rect::new(0, 0, 1, 10);
311 let mut pool = GraphemePool::new();
312 let mut frame = Frame::new(1, 10, &mut pool);
313 let mut state = ScrollbarState::new(100, 0, 10);
314 StatefulWidget::render(&sb, area, &mut frame, &mut state);
315
316 let top_cell = frame.buffer.get(0, 0).unwrap();
317 assert!(top_cell.content.as_char().is_some());
318 }
319
320 #[test]
321 fn scrollbar_horizontal_renders() {
322 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalBottom);
323 let area = Rect::new(0, 0, 10, 1);
324 let mut pool = GraphemePool::new();
325 let mut frame = Frame::new(10, 1, &mut pool);
326 let mut state = ScrollbarState::new(100, 0, 10);
327 StatefulWidget::render(&sb, area, &mut frame, &mut state);
328
329 let left_cell = frame.buffer.get(0, 0).unwrap();
330 assert!(left_cell.content.as_char().is_some());
331 }
332
333 #[test]
334 fn scrollbar_thumb_moves_with_position() {
335 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
336 let area = Rect::new(0, 0, 1, 10);
337
338 let mut pool1 = GraphemePool::new();
340 let mut frame1 = Frame::new(1, 10, &mut pool1);
341 let mut state1 = ScrollbarState::new(100, 0, 10);
342 StatefulWidget::render(&sb, area, &mut frame1, &mut state1);
343
344 let mut pool2 = GraphemePool::new();
346 let mut frame2 = Frame::new(1, 10, &mut pool2);
347 let mut state2 = ScrollbarState::new(100, 90, 10);
348 StatefulWidget::render(&sb, area, &mut frame2, &mut state2);
349
350 let thumb_char = '█';
352 let thumb_pos_1 = (0..10u16)
353 .find(|&y| frame1.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
354 let thumb_pos_2 = (0..10u16)
355 .find(|&y| frame2.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
356
357 assert!(thumb_pos_1.unwrap_or(0) < thumb_pos_2.unwrap_or(0));
359 }
360
361 #[test]
362 fn scrollbar_state_constructor() {
363 let state = ScrollbarState::new(200, 50, 20);
364 assert_eq!(state.content_length, 200);
365 assert_eq!(state.position, 50);
366 assert_eq!(state.viewport_length, 20);
367 }
368
369 #[test]
370 fn scrollbar_content_fits_viewport() {
371 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
373 let area = Rect::new(0, 0, 1, 10);
374 let mut pool = GraphemePool::new();
375 let mut frame = Frame::new(1, 10, &mut pool);
376 let mut state = ScrollbarState::new(5, 0, 10);
377 StatefulWidget::render(&sb, area, &mut frame, &mut state);
378
379 let thumb_char = '█';
381 for y in 0..10u16 {
382 assert_eq!(
383 frame.buffer.get(0, y).unwrap().content.as_char(),
384 Some(thumb_char)
385 );
386 }
387 }
388
389 #[test]
390 fn scrollbar_horizontal_top_renders() {
391 let sb = Scrollbar::new(ScrollbarOrientation::HorizontalTop);
392 let area = Rect::new(0, 0, 10, 1);
393 let mut pool = GraphemePool::new();
394 let mut frame = Frame::new(10, 1, &mut pool);
395 let mut state = ScrollbarState::new(100, 0, 10);
396 StatefulWidget::render(&sb, area, &mut frame, &mut state);
397
398 let left_cell = frame.buffer.get(0, 0).unwrap();
399 assert!(left_cell.content.as_char().is_some());
400 }
401
402 #[test]
403 fn scrollbar_custom_symbols() {
404 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols(
405 ".",
406 "#",
407 Some("^"),
408 Some("v"),
409 );
410 let area = Rect::new(0, 0, 1, 5);
411 let mut pool = GraphemePool::new();
412 let mut frame = Frame::new(1, 5, &mut pool);
413 let mut state = ScrollbarState::new(50, 0, 10);
414 StatefulWidget::render(&sb, area, &mut frame, &mut state);
415
416 let mut chars: Vec<Option<char>> = Vec::new();
418 for y in 0..5u16 {
419 chars.push(frame.buffer.get(0, y).unwrap().content.as_char());
420 }
421 assert!(chars.contains(&Some('#')) || chars.contains(&Some('.')));
423 }
424
425 #[test]
426 fn scrollbar_position_clamped_beyond_max() {
427 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
428 let area = Rect::new(0, 0, 1, 10);
429 let mut pool = GraphemePool::new();
430 let mut frame = Frame::new(1, 10, &mut pool);
431 let mut state = ScrollbarState::new(100, 500, 10);
433 StatefulWidget::render(&sb, area, &mut frame, &mut state);
434
435 let thumb_char = '█';
437 let thumb_pos = (0..10u16)
438 .find(|&y| frame.buffer.get(0, y).unwrap().content.as_char() == Some(thumb_char));
439 assert!(thumb_pos.is_some());
440 }
441
442 #[test]
443 fn scrollbar_state_default() {
444 let state = ScrollbarState::default();
445 assert_eq!(state.content_length, 0);
446 assert_eq!(state.position, 0);
447 assert_eq!(state.viewport_length, 0);
448 }
449
450 #[test]
451 fn scrollbar_widget_trait_renders() {
452 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
453 let area = Rect::new(0, 0, 1, 5);
454 let mut pool = GraphemePool::new();
455 let mut frame = Frame::new(1, 5, &mut pool);
456 Widget::render(&sb, area, &mut frame);
458 }
460
461 #[test]
462 fn scrollbar_orientation_default_is_vertical_right() {
463 assert_eq!(
464 ScrollbarOrientation::default(),
465 ScrollbarOrientation::VerticalRight
466 );
467 }
468
469 #[test]
472 fn degradation_essential_only_skips_entirely() {
473 use ftui_render::budget::DegradationLevel;
474
475 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
476 let area = Rect::new(0, 0, 1, 10);
477 let mut pool = GraphemePool::new();
478 let mut frame = Frame::new(1, 10, &mut pool);
479 frame.buffer.degradation = DegradationLevel::EssentialOnly;
480 let mut state = ScrollbarState::new(100, 0, 10);
481 StatefulWidget::render(&sb, area, &mut frame, &mut state);
482
483 for y in 0..10u16 {
485 assert!(
486 frame.buffer.get(0, y).unwrap().is_empty(),
487 "cell at y={y} should be empty at EssentialOnly"
488 );
489 }
490 }
491
492 #[test]
493 fn degradation_skeleton_skips_entirely() {
494 use ftui_render::budget::DegradationLevel;
495
496 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
497 let area = Rect::new(0, 0, 1, 10);
498 let mut pool = GraphemePool::new();
499 let mut frame = Frame::new(1, 10, &mut pool);
500 frame.buffer.degradation = DegradationLevel::Skeleton;
501 let mut state = ScrollbarState::new(100, 0, 10);
502 StatefulWidget::render(&sb, area, &mut frame, &mut state);
503
504 for y in 0..10u16 {
505 assert!(
506 frame.buffer.get(0, y).unwrap().is_empty(),
507 "cell at y={y} should be empty at Skeleton"
508 );
509 }
510 }
511
512 #[test]
513 fn degradation_full_renders_scrollbar() {
514 use ftui_render::budget::DegradationLevel;
515
516 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
517 let area = Rect::new(0, 0, 1, 10);
518 let mut pool = GraphemePool::new();
519 let mut frame = Frame::new(1, 10, &mut pool);
520 frame.buffer.degradation = DegradationLevel::Full;
521 let mut state = ScrollbarState::new(100, 0, 10);
522 StatefulWidget::render(&sb, area, &mut frame, &mut state);
523
524 let top_cell = frame.buffer.get(0, 0).unwrap();
526 assert!(top_cell.content.as_char().is_some());
527 }
528
529 #[test]
530 fn degradation_simple_borders_still_renders() {
531 use ftui_render::budget::DegradationLevel;
532
533 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight);
534 let area = Rect::new(0, 0, 1, 10);
535 let mut pool = GraphemePool::new();
536 let mut frame = Frame::new(1, 10, &mut pool);
537 frame.buffer.degradation = DegradationLevel::SimpleBorders;
538 let mut state = ScrollbarState::new(100, 0, 10);
539 StatefulWidget::render(&sb, area, &mut frame, &mut state);
540
541 let top_cell = frame.buffer.get(0, 0).unwrap();
543 assert!(top_cell.content.as_char().is_some());
544 }
545
546 #[test]
547 fn scrollbar_wide_symbols_horizontal() {
548 let sb =
549 Scrollbar::new(ScrollbarOrientation::HorizontalBottom).symbols("🔴", "👍", None, None);
550 let area = Rect::new(0, 0, 4, 1);
552 let mut pool = GraphemePool::new();
553 let mut frame = Frame::new(4, 1, &mut pool);
554 let mut state = ScrollbarState::new(10, 0, 10);
559
560 StatefulWidget::render(&sb, area, &mut frame, &mut state);
561
562 let c0 = frame.buffer.get(0, 0).unwrap();
564 assert!(!c0.is_empty() && !c0.is_continuation()); let c1 = frame.buffer.get(1, 0).unwrap();
567 assert!(c1.is_continuation());
568
569 let c2 = frame.buffer.get(2, 0).unwrap();
571 assert!(!c2.is_empty() && !c2.is_continuation()); let c3 = frame.buffer.get(3, 0).unwrap();
574 assert!(c3.is_continuation());
575 }
576
577 #[test]
578 fn scrollbar_wide_symbols_vertical() {
579 let sb =
580 Scrollbar::new(ScrollbarOrientation::VerticalRight).symbols("🔴", "👍", None, None);
581 let area = Rect::new(0, 0, 2, 2);
583 let mut pool = GraphemePool::new();
584 let mut frame = Frame::new(2, 2, &mut pool);
585 let mut state = ScrollbarState::new(10, 0, 10); StatefulWidget::render(&sb, area, &mut frame, &mut state);
588
589 let r0_c0 = frame.buffer.get(0, 0).unwrap();
591 assert!(!r0_c0.is_empty() && !r0_c0.is_continuation()); let r0_c1 = frame.buffer.get(1, 0).unwrap();
593 assert!(r0_c1.is_continuation()); let r1_c0 = frame.buffer.get(0, 1).unwrap();
597 assert!(!r1_c0.is_empty() && !r1_c0.is_continuation()); let r1_c1 = frame.buffer.get(1, 1).unwrap();
599 assert!(r1_c1.is_continuation()); }
601}