1use std::rc::Rc;
9
10use crossterm::event::KeyCode;
11use ratatui::{
12 layout::Rect,
13 style::{Color, Style},
14 text::Line,
15 widgets::{Block, Paragraph, ScrollbarOrientation, ScrollbarState},
16 Frame,
17};
18use tui_dispatch_core::{Component, EventKind, HandlerResponse};
19
20use crate::commands;
21use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle};
22use crate::{ComponentDebugEntry, ComponentDebugState, ComponentInput, InteractiveComponent};
23
24#[derive(Debug, Clone, Copy)]
26pub struct VisibleRange {
27 pub start: usize,
29 pub end: usize,
31 pub viewport_height: u16,
33 pub available_width: u16,
35}
36
37#[derive(Debug, Clone)]
39pub struct ScrollViewStyle {
40 pub base: BaseStyle,
42 pub scrollbar: ScrollbarStyle,
44}
45
46impl Default for ScrollViewStyle {
47 fn default() -> Self {
48 Self {
49 base: BaseStyle {
50 fg: Some(Color::Reset),
51 ..Default::default()
52 },
53 scrollbar: ScrollbarStyle::default(),
54 }
55 }
56}
57
58impl ScrollViewStyle {
59 pub fn borderless() -> Self {
61 let mut style = Self::default();
62 style.base.border = None;
63 style
64 }
65
66 pub fn minimal() -> Self {
68 let mut style = Self::default();
69 style.base.border = None;
70 style.base.padding = Padding::default();
71 style
72 }
73}
74
75impl ComponentStyle for ScrollViewStyle {
76 fn base(&self) -> &BaseStyle {
77 &self.base
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct ScrollViewBehavior {
84 pub show_scrollbar: bool,
86 pub scroll_step: usize,
88 pub page_step: usize,
90}
91
92impl Default for ScrollViewBehavior {
93 fn default() -> Self {
94 Self {
95 show_scrollbar: true,
96 scroll_step: 1,
97 page_step: 0,
98 }
99 }
100}
101
102pub type ScrollViewCallback<A> = Rc<dyn Fn(usize) -> A>;
104
105pub struct ScrollViewProps<'a, A> {
107 pub content_height: usize,
109 pub scroll_offset: usize,
111 pub is_focused: bool,
113 pub style: ScrollViewStyle,
115 pub behavior: ScrollViewBehavior,
117 pub on_scroll: ScrollViewCallback<A>,
119 pub render_content: &'a mut dyn FnMut(&mut Frame, Rect, VisibleRange),
124}
125
126pub struct ScrollViewRenderProps<'a> {
128 pub content_height: usize,
130 pub scroll_offset: usize,
132 pub is_focused: bool,
134 pub style: ScrollViewStyle,
136 pub behavior: ScrollViewBehavior,
138 pub render_content: &'a mut dyn FnMut(&mut Frame, Rect, VisibleRange),
140}
141
142#[derive(Default)]
149pub struct ScrollView {
150 viewport_height: usize,
151}
152
153impl ScrollView {
154 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn render_widget(
161 &mut self,
162 frame: &mut Frame,
163 area: Rect,
164 props: ScrollViewRenderProps<'_>,
165 ) {
166 self.render_with(frame, area, props);
167 }
168
169 fn viewport_height_value(&self) -> usize {
170 self.viewport_height.max(1)
171 }
172
173 fn max_offset(&self, content_height: usize) -> usize {
174 content_height.saturating_sub(self.viewport_height_value())
175 }
176
177 fn scrollbar_content_length(&self, content_height: usize) -> usize {
178 content_height
179 .saturating_sub(self.viewport_height_value())
180 .saturating_add(1)
181 }
182
183 fn page_size(&self, behavior: &ScrollViewBehavior) -> usize {
184 if behavior.page_step > 0 {
185 behavior.page_step
186 } else {
187 self.viewport_height_value()
188 }
189 }
190
191 fn apply_delta(&self, current: usize, delta: isize, max_offset: usize) -> usize {
192 if delta >= 0 {
193 current.saturating_add(delta as usize).min(max_offset)
194 } else {
195 current.saturating_sub((-delta) as usize)
196 }
197 }
198
199 fn render_with(&mut self, frame: &mut Frame, area: Rect, props: ScrollViewRenderProps<'_>) {
200 let style = &props.style;
201
202 if let Some(bg) = style.base.bg {
204 for y in area.y..area.y.saturating_add(area.height) {
205 for x in area.x..area.x.saturating_add(area.width) {
206 frame.buffer_mut()[(x, y)].set_bg(bg);
207 frame.buffer_mut()[(x, y)].set_symbol(" ");
208 }
209 }
210 }
211
212 let content_area = Rect {
214 x: area.x + style.base.padding.left,
215 y: area.y + style.base.padding.top,
216 width: area.width.saturating_sub(style.base.padding.horizontal()),
217 height: area.height.saturating_sub(style.base.padding.vertical()),
218 };
219
220 let mut inner_area = content_area;
222 if let Some(border) = &style.base.border {
223 let block = Block::default()
224 .borders(border.borders)
225 .border_style(border.style_for_focus(props.is_focused));
226 inner_area = block.inner(content_area);
227 frame.render_widget(block, content_area);
228 }
229
230 let viewport_height = inner_area.height as usize;
231 self.viewport_height = viewport_height;
232
233 if inner_area.width == 0 || inner_area.height == 0 {
234 return;
235 }
236
237 let show_scrollbar = props.behavior.show_scrollbar
239 && viewport_height > 0
240 && props.content_height > viewport_height
241 && inner_area.width > 1;
242
243 let (content_area, scrollbar_area) = if show_scrollbar {
244 let scrollbar_area = Rect {
245 x: inner_area.x + inner_area.width.saturating_sub(1),
246 width: 1,
247 ..inner_area
248 };
249 let content_area = Rect {
250 width: inner_area.width.saturating_sub(1),
251 ..inner_area
252 };
253 (content_area, Some(scrollbar_area))
254 } else {
255 (inner_area, None)
256 };
257
258 let max_offset = self.max_offset(props.content_height);
260 let scroll_offset = props.scroll_offset.min(max_offset);
261 let visible_end = (scroll_offset + viewport_height).min(props.content_height);
262
263 let visible_range = VisibleRange {
264 start: scroll_offset,
265 end: visible_end,
266 viewport_height: viewport_height as u16,
267 available_width: content_area.width,
268 };
269
270 (props.render_content)(frame, content_area, visible_range);
272
273 if let Some(scrollbar_area) = scrollbar_area {
275 let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
276 let scrollbar_len = self.scrollbar_content_length(props.content_height);
277 let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
278 .position(scroll_offset)
279 .viewport_content_length(self.viewport_height_value());
280 frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
281 }
282 }
283}
284
285impl<A> Component<A> for ScrollView {
286 type Props<'a> = ScrollViewProps<'a, A>;
287
288 fn handle_event(
289 &mut self,
290 event: &EventKind,
291 props: Self::Props<'_>,
292 ) -> impl IntoIterator<Item = A> {
293 if !props.is_focused || props.content_height == 0 {
294 return None;
295 }
296
297 let max_offset = self.max_offset(props.content_height);
298 let scroll_step = props.behavior.scroll_step.max(1) as isize;
299 let page_size = self.page_size(&props.behavior) as isize;
300
301 let next_offset = match event {
302 EventKind::Key(key) => match key.code {
303 KeyCode::Char('j') | KeyCode::Down => {
304 Some(self.apply_delta(props.scroll_offset, scroll_step, max_offset))
305 }
306 KeyCode::Char('k') | KeyCode::Up => {
307 Some(self.apply_delta(props.scroll_offset, -scroll_step, max_offset))
308 }
309 KeyCode::PageDown => {
310 Some(self.apply_delta(props.scroll_offset, page_size, max_offset))
311 }
312 KeyCode::PageUp => {
313 Some(self.apply_delta(props.scroll_offset, -page_size, max_offset))
314 }
315 KeyCode::Char('g') | KeyCode::Home => Some(0),
316 KeyCode::Char('G') | KeyCode::End => Some(max_offset),
317 _ => None,
318 },
319 EventKind::Scroll { delta, .. } => {
320 if *delta == 0 {
321 None
322 } else {
323 let scaled_delta = delta.saturating_mul(scroll_step);
324 Some(self.apply_delta(props.scroll_offset, scaled_delta, max_offset))
325 }
326 }
327 _ => None,
328 };
329
330 match next_offset {
331 Some(offset) if offset != props.scroll_offset => {
332 Some((props.on_scroll.as_ref())(offset))
333 }
334 _ => None,
335 }
336 }
337
338 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
339 self.render_with(
340 frame,
341 area,
342 ScrollViewRenderProps {
343 content_height: props.content_height,
344 scroll_offset: props.scroll_offset,
345 is_focused: props.is_focused,
346 style: props.style,
347 behavior: props.behavior,
348 render_content: props.render_content,
349 },
350 );
351 }
352}
353
354impl ComponentDebugState for ScrollView {
355 fn debug_state(&self) -> Vec<ComponentDebugEntry> {
356 vec![ComponentDebugEntry::new(
357 "viewport_height",
358 self.viewport_height.to_string(),
359 )]
360 }
361}
362
363impl<A, Ctx> InteractiveComponent<A, Ctx> for ScrollView {
364 type Props<'a> = ScrollViewProps<'a, A>;
365
366 fn update(
367 &mut self,
368 input: ComponentInput<'_, Ctx>,
369 props: Self::Props<'_>,
370 ) -> HandlerResponse<A> {
371 let action = match input {
372 ComponentInput::Command { name, .. } => {
373 if !props.is_focused || props.content_height == 0 {
374 None
375 } else {
376 let max_offset = self.max_offset(props.content_height);
377 let scroll_step = props.behavior.scroll_step.max(1) as isize;
378 let page_size = self.page_size(&props.behavior) as isize;
379 let next_offset = match name {
380 commands::NEXT | commands::DOWN => {
381 Some(self.apply_delta(props.scroll_offset, scroll_step, max_offset))
382 }
383 commands::PREV | commands::UP => {
384 Some(self.apply_delta(props.scroll_offset, -scroll_step, max_offset))
385 }
386 commands::PAGE_DOWN => {
387 Some(self.apply_delta(props.scroll_offset, page_size, max_offset))
388 }
389 commands::PAGE_UP => {
390 Some(self.apply_delta(props.scroll_offset, -page_size, max_offset))
391 }
392 commands::FIRST | commands::HOME => Some(0),
393 commands::LAST | commands::END => Some(max_offset),
394 _ => None,
395 };
396
397 match next_offset {
398 Some(offset) if offset != props.scroll_offset => {
399 Some((props.on_scroll.as_ref())(offset))
400 }
401 _ => None,
402 }
403 }
404 }
405 ComponentInput::Key(key) => {
406 <Self as Component<A>>::handle_event(self, &EventKind::Key(key), props)
407 .into_iter()
408 .next()
409 }
410 ComponentInput::Scroll {
411 column,
412 row,
413 delta,
414 modifiers,
415 } => <Self as Component<A>>::handle_event(
416 self,
417 &EventKind::Scroll {
418 column,
419 row,
420 delta,
421 modifiers,
422 },
423 props,
424 )
425 .into_iter()
426 .next(),
427 _ => None,
428 };
429
430 match action {
431 Some(action) => HandlerResponse::action(action),
432 None => HandlerResponse::ignored(),
433 }
434 }
435
436 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
437 <Self as Component<A>>::render(self, frame, area, props);
438 }
439}
440
441pub struct LinesScroller<'a> {
467 lines: &'a [Line<'a>],
468 style: Style,
469}
470
471impl<'a> LinesScroller<'a> {
472 pub fn new(lines: &'a [Line<'a>]) -> Self {
474 Self {
475 lines,
476 style: Style::default(),
477 }
478 }
479
480 pub fn with_style(mut self, style: Style) -> Self {
482 self.style = style;
483 self
484 }
485
486 pub fn content_height(&self) -> usize {
488 self.lines.len()
489 }
490
491 pub fn renderer(&self) -> impl FnMut(&mut Frame, Rect, VisibleRange) + use<'_, 'a> {
493 move |frame: &mut Frame, area: Rect, range: VisibleRange| {
494 let visible_lines: Vec<Line<'a>> = self
495 .lines
496 .iter()
497 .skip(range.start)
498 .take(range.end.saturating_sub(range.start))
499 .cloned()
500 .collect();
501
502 let paragraph = Paragraph::new(visible_lines).style(self.style);
503 frame.render_widget(paragraph, area);
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
512 use tui_dispatch_core::testing::{key, RenderHarness};
513
514 #[derive(Debug, Clone, PartialEq)]
515 enum TestAction {
516 ScrollTo(usize),
517 }
518
519 fn make_lines(count: usize) -> Vec<Line<'static>> {
520 (0..count)
521 .map(|i| Line::raw(format!("Line {}", i)))
522 .collect()
523 }
524
525 #[test]
526 fn test_scroll_down_action() {
527 let mut view = ScrollView::new();
528 let lines = make_lines(5);
529 let scroller = LinesScroller::new(&lines);
530 let mut harness = RenderHarness::new(20, 3);
531
532 harness.render_to_string_plain(|frame| {
534 <ScrollView as Component<TestAction>>::render(
535 &mut view,
536 frame,
537 frame.area(),
538 ScrollViewProps {
539 content_height: scroller.content_height(),
540 scroll_offset: 0,
541 is_focused: true,
542 style: ScrollViewStyle::borderless(),
543 behavior: ScrollViewBehavior::default(),
544 on_scroll: Rc::new(TestAction::ScrollTo),
545 render_content: &mut scroller.renderer(),
546 },
547 );
548 });
549
550 let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
551 let actions: Vec<_> = view
552 .handle_event(
553 &EventKind::Key(key("j")),
554 ScrollViewProps {
555 content_height: lines.len(),
556 scroll_offset: 0,
557 is_focused: true,
558 style: ScrollViewStyle::borderless(),
559 behavior: ScrollViewBehavior::default(),
560 on_scroll: Rc::new(TestAction::ScrollTo),
561 render_content: &mut noop_render,
562 },
563 )
564 .into_iter()
565 .collect();
566
567 assert_eq!(actions, vec![TestAction::ScrollTo(1)]);
568 }
569
570 #[test]
571 fn test_page_down_action() {
572 let mut view = ScrollView::new();
573 let lines = make_lines(10);
574 let scroller = LinesScroller::new(&lines);
575 let mut harness = RenderHarness::new(20, 4);
576
577 harness.render_to_string_plain(|frame| {
578 <ScrollView as Component<TestAction>>::render(
579 &mut view,
580 frame,
581 frame.area(),
582 ScrollViewProps {
583 content_height: scroller.content_height(),
584 scroll_offset: 0,
585 is_focused: true,
586 style: ScrollViewStyle::borderless(),
587 behavior: ScrollViewBehavior::default(),
588 on_scroll: Rc::new(TestAction::ScrollTo),
589 render_content: &mut scroller.renderer(),
590 },
591 );
592 });
593
594 let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
595 let page_down = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE);
596 let actions: Vec<_> = view
597 .handle_event(
598 &EventKind::Key(page_down),
599 ScrollViewProps {
600 content_height: lines.len(),
601 scroll_offset: 0,
602 is_focused: true,
603 style: ScrollViewStyle::borderless(),
604 behavior: ScrollViewBehavior::default(),
605 on_scroll: Rc::new(TestAction::ScrollTo),
606 render_content: &mut noop_render,
607 },
608 )
609 .into_iter()
610 .collect();
611
612 assert_eq!(actions, vec![TestAction::ScrollTo(4)]);
613 }
614
615 #[test]
616 fn test_scroll_wheel_action() {
617 let mut view = ScrollView::new();
618 let lines = make_lines(5);
619 let scroller = LinesScroller::new(&lines);
620 let mut harness = RenderHarness::new(20, 3);
621
622 harness.render_to_string_plain(|frame| {
623 <ScrollView as Component<TestAction>>::render(
624 &mut view,
625 frame,
626 frame.area(),
627 ScrollViewProps {
628 content_height: scroller.content_height(),
629 scroll_offset: 1,
630 is_focused: true,
631 style: ScrollViewStyle::borderless(),
632 behavior: ScrollViewBehavior::default(),
633 on_scroll: Rc::new(TestAction::ScrollTo),
634 render_content: &mut scroller.renderer(),
635 },
636 );
637 });
638
639 let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
640 let actions: Vec<_> = view
641 .handle_event(
642 &EventKind::Scroll {
643 column: 0,
644 row: 0,
645 delta: -1,
646 modifiers: KeyModifiers::NONE,
647 },
648 ScrollViewProps {
649 content_height: lines.len(),
650 scroll_offset: 1,
651 is_focused: true,
652 style: ScrollViewStyle::borderless(),
653 behavior: ScrollViewBehavior::default(),
654 on_scroll: Rc::new(TestAction::ScrollTo),
655 render_content: &mut noop_render,
656 },
657 )
658 .into_iter()
659 .collect();
660
661 assert_eq!(actions, vec![TestAction::ScrollTo(0)]);
662 }
663
664 #[test]
665 fn test_render_respects_offset() {
666 let mut view = ScrollView::new();
667 let lines = make_lines(6);
668 let scroller = LinesScroller::new(&lines);
669 let mut harness = RenderHarness::new(20, 3);
670
671 let output = harness.render_to_string_plain(|frame| {
672 <ScrollView as Component<TestAction>>::render(
673 &mut view,
674 frame,
675 frame.area(),
676 ScrollViewProps {
677 content_height: scroller.content_height(),
678 scroll_offset: 2,
679 is_focused: true,
680 style: ScrollViewStyle::borderless(),
681 behavior: ScrollViewBehavior::default(),
682 on_scroll: Rc::new(TestAction::ScrollTo),
683 render_content: &mut scroller.renderer(),
684 },
685 );
686 });
687
688 assert!(output.contains("Line 2"));
689 assert!(!output.contains("Line 0"));
690 }
691
692 #[test]
693 fn test_lines_scroller_content_height() {
694 let lines = make_lines(10);
695 let scroller = LinesScroller::new(&lines);
696 assert_eq!(scroller.content_height(), 10);
697 }
698}