1use crossterm::event::KeyCode;
9use ratatui::{
10 layout::Rect,
11 style::{Color, Style},
12 text::Line,
13 widgets::{Block, Paragraph, ScrollbarOrientation, ScrollbarState},
14 Frame,
15};
16use tui_dispatch_core::{Component, EventKind};
17
18use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle};
19
20#[derive(Debug, Clone, Copy)]
22pub struct VisibleRange {
23 pub start: usize,
25 pub end: usize,
27 pub viewport_height: u16,
29 pub available_width: u16,
31}
32
33#[derive(Debug, Clone)]
35pub struct ScrollViewStyle {
36 pub base: BaseStyle,
38 pub scrollbar: ScrollbarStyle,
40}
41
42impl Default for ScrollViewStyle {
43 fn default() -> Self {
44 Self {
45 base: BaseStyle {
46 fg: Some(Color::Reset),
47 ..Default::default()
48 },
49 scrollbar: ScrollbarStyle::default(),
50 }
51 }
52}
53
54impl ScrollViewStyle {
55 pub fn borderless() -> Self {
57 let mut style = Self::default();
58 style.base.border = None;
59 style
60 }
61
62 pub fn minimal() -> Self {
64 let mut style = Self::default();
65 style.base.border = None;
66 style.base.padding = Padding::default();
67 style
68 }
69}
70
71impl ComponentStyle for ScrollViewStyle {
72 fn base(&self) -> &BaseStyle {
73 &self.base
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct ScrollViewBehavior {
80 pub show_scrollbar: bool,
82 pub scroll_step: usize,
84 pub page_step: usize,
86}
87
88impl Default for ScrollViewBehavior {
89 fn default() -> Self {
90 Self {
91 show_scrollbar: true,
92 scroll_step: 1,
93 page_step: 0,
94 }
95 }
96}
97
98pub struct ScrollViewProps<'a, A> {
100 pub content_height: usize,
102 pub scroll_offset: usize,
104 pub is_focused: bool,
106 pub style: ScrollViewStyle,
108 pub behavior: ScrollViewBehavior,
110 pub on_scroll: fn(usize) -> A,
112 pub render_content: &'a mut dyn FnMut(&mut Frame, Rect, VisibleRange),
117}
118
119#[derive(Default)]
126pub struct ScrollView {
127 viewport_height: usize,
128}
129
130impl ScrollView {
131 pub fn new() -> Self {
133 Self::default()
134 }
135
136 fn viewport_height_value(&self) -> usize {
137 self.viewport_height.max(1)
138 }
139
140 fn max_offset(&self, content_height: usize) -> usize {
141 content_height.saturating_sub(self.viewport_height_value())
142 }
143
144 fn scrollbar_content_length(&self, content_height: usize) -> usize {
145 content_height
146 .saturating_sub(self.viewport_height_value())
147 .saturating_add(1)
148 }
149
150 fn page_size(&self, behavior: &ScrollViewBehavior) -> usize {
151 if behavior.page_step > 0 {
152 behavior.page_step
153 } else {
154 self.viewport_height_value()
155 }
156 }
157
158 fn apply_delta(&self, current: usize, delta: isize, max_offset: usize) -> usize {
159 if delta >= 0 {
160 current.saturating_add(delta as usize).min(max_offset)
161 } else {
162 current.saturating_sub((-delta) as usize)
163 }
164 }
165}
166
167impl<A> Component<A> for ScrollView {
168 type Props<'a> = ScrollViewProps<'a, A>;
169
170 fn handle_event(
171 &mut self,
172 event: &EventKind,
173 props: Self::Props<'_>,
174 ) -> impl IntoIterator<Item = A> {
175 if !props.is_focused || props.content_height == 0 {
176 return None;
177 }
178
179 let max_offset = self.max_offset(props.content_height);
180 let scroll_step = props.behavior.scroll_step.max(1) as isize;
181 let page_size = self.page_size(&props.behavior) as isize;
182
183 let next_offset = match event {
184 EventKind::Key(key) => match key.code {
185 KeyCode::Char('j') | KeyCode::Down => {
186 Some(self.apply_delta(props.scroll_offset, scroll_step, max_offset))
187 }
188 KeyCode::Char('k') | KeyCode::Up => {
189 Some(self.apply_delta(props.scroll_offset, -scroll_step, max_offset))
190 }
191 KeyCode::PageDown => {
192 Some(self.apply_delta(props.scroll_offset, page_size, max_offset))
193 }
194 KeyCode::PageUp => {
195 Some(self.apply_delta(props.scroll_offset, -page_size, max_offset))
196 }
197 KeyCode::Char('g') | KeyCode::Home => Some(0),
198 KeyCode::Char('G') | KeyCode::End => Some(max_offset),
199 _ => None,
200 },
201 EventKind::Scroll { delta, .. } => {
202 if *delta == 0 {
203 None
204 } else {
205 let scaled_delta = delta.saturating_mul(scroll_step);
206 Some(self.apply_delta(props.scroll_offset, scaled_delta, max_offset))
207 }
208 }
209 _ => None,
210 };
211
212 match next_offset {
213 Some(offset) if offset != props.scroll_offset => Some((props.on_scroll)(offset)),
214 _ => None,
215 }
216 }
217
218 fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
219 let style = &props.style;
220
221 if let Some(bg) = style.base.bg {
223 for y in area.y..area.y.saturating_add(area.height) {
224 for x in area.x..area.x.saturating_add(area.width) {
225 frame.buffer_mut()[(x, y)].set_bg(bg);
226 frame.buffer_mut()[(x, y)].set_symbol(" ");
227 }
228 }
229 }
230
231 let content_area = Rect {
233 x: area.x + style.base.padding.left,
234 y: area.y + style.base.padding.top,
235 width: area.width.saturating_sub(style.base.padding.horizontal()),
236 height: area.height.saturating_sub(style.base.padding.vertical()),
237 };
238
239 let mut inner_area = content_area;
241 if let Some(border) = &style.base.border {
242 let block = Block::default()
243 .borders(border.borders)
244 .border_style(border.style_for_focus(props.is_focused));
245 inner_area = block.inner(content_area);
246 frame.render_widget(block, content_area);
247 }
248
249 let viewport_height = inner_area.height as usize;
250 self.viewport_height = viewport_height;
251
252 if inner_area.width == 0 || inner_area.height == 0 {
253 return;
254 }
255
256 let show_scrollbar = props.behavior.show_scrollbar
258 && viewport_height > 0
259 && props.content_height > viewport_height
260 && inner_area.width > 1;
261
262 let (content_area, scrollbar_area) = if show_scrollbar {
263 let scrollbar_area = Rect {
264 x: inner_area.x + inner_area.width.saturating_sub(1),
265 width: 1,
266 ..inner_area
267 };
268 let content_area = Rect {
269 width: inner_area.width.saturating_sub(1),
270 ..inner_area
271 };
272 (content_area, Some(scrollbar_area))
273 } else {
274 (inner_area, None)
275 };
276
277 let max_offset = self.max_offset(props.content_height);
279 let scroll_offset = props.scroll_offset.min(max_offset);
280 let visible_end = (scroll_offset + viewport_height).min(props.content_height);
281
282 let visible_range = VisibleRange {
283 start: scroll_offset,
284 end: visible_end,
285 viewport_height: viewport_height as u16,
286 available_width: content_area.width,
287 };
288
289 (props.render_content)(frame, content_area, visible_range);
291
292 if let Some(scrollbar_area) = scrollbar_area {
294 let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
295 let scrollbar_len = self.scrollbar_content_length(props.content_height);
296 let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
297 .position(scroll_offset)
298 .viewport_content_length(self.viewport_height_value());
299 frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
300 }
301 }
302}
303
304pub struct LinesScroller<'a> {
328 lines: &'a [Line<'a>],
329 style: Style,
330}
331
332impl<'a> LinesScroller<'a> {
333 pub fn new(lines: &'a [Line<'a>]) -> Self {
335 Self {
336 lines,
337 style: Style::default(),
338 }
339 }
340
341 pub fn with_style(mut self, style: Style) -> Self {
343 self.style = style;
344 self
345 }
346
347 pub fn content_height(&self) -> usize {
349 self.lines.len()
350 }
351
352 pub fn renderer(&self) -> impl FnMut(&mut Frame, Rect, VisibleRange) + use<'_, 'a> {
354 move |frame: &mut Frame, area: Rect, range: VisibleRange| {
355 let visible_lines: Vec<Line<'a>> = self
356 .lines
357 .iter()
358 .skip(range.start)
359 .take(range.end.saturating_sub(range.start))
360 .cloned()
361 .collect();
362
363 let paragraph = Paragraph::new(visible_lines).style(self.style);
364 frame.render_widget(paragraph, area);
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
373 use tui_dispatch_core::testing::{key, RenderHarness};
374
375 #[derive(Debug, Clone, PartialEq)]
376 enum TestAction {
377 ScrollTo(usize),
378 }
379
380 fn make_lines(count: usize) -> Vec<Line<'static>> {
381 (0..count)
382 .map(|i| Line::raw(format!("Line {}", i)))
383 .collect()
384 }
385
386 #[test]
387 fn test_scroll_down_action() {
388 let mut view = ScrollView::new();
389 let lines = make_lines(5);
390 let scroller = LinesScroller::new(&lines);
391 let mut harness = RenderHarness::new(20, 3);
392
393 harness.render_to_string_plain(|frame| {
395 view.render(
396 frame,
397 frame.area(),
398 ScrollViewProps {
399 content_height: scroller.content_height(),
400 scroll_offset: 0,
401 is_focused: true,
402 style: ScrollViewStyle::borderless(),
403 behavior: ScrollViewBehavior::default(),
404 on_scroll: TestAction::ScrollTo,
405 render_content: &mut scroller.renderer(),
406 },
407 );
408 });
409
410 let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
411 let actions: Vec<_> = view
412 .handle_event(
413 &EventKind::Key(key("j")),
414 ScrollViewProps {
415 content_height: lines.len(),
416 scroll_offset: 0,
417 is_focused: true,
418 style: ScrollViewStyle::borderless(),
419 behavior: ScrollViewBehavior::default(),
420 on_scroll: TestAction::ScrollTo,
421 render_content: &mut noop_render,
422 },
423 )
424 .into_iter()
425 .collect();
426
427 assert_eq!(actions, vec![TestAction::ScrollTo(1)]);
428 }
429
430 #[test]
431 fn test_page_down_action() {
432 let mut view = ScrollView::new();
433 let lines = make_lines(10);
434 let scroller = LinesScroller::new(&lines);
435 let mut harness = RenderHarness::new(20, 4);
436
437 harness.render_to_string_plain(|frame| {
438 view.render(
439 frame,
440 frame.area(),
441 ScrollViewProps {
442 content_height: scroller.content_height(),
443 scroll_offset: 0,
444 is_focused: true,
445 style: ScrollViewStyle::borderless(),
446 behavior: ScrollViewBehavior::default(),
447 on_scroll: TestAction::ScrollTo,
448 render_content: &mut scroller.renderer(),
449 },
450 );
451 });
452
453 let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
454 let page_down = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE);
455 let actions: Vec<_> = view
456 .handle_event(
457 &EventKind::Key(page_down),
458 ScrollViewProps {
459 content_height: lines.len(),
460 scroll_offset: 0,
461 is_focused: true,
462 style: ScrollViewStyle::borderless(),
463 behavior: ScrollViewBehavior::default(),
464 on_scroll: TestAction::ScrollTo,
465 render_content: &mut noop_render,
466 },
467 )
468 .into_iter()
469 .collect();
470
471 assert_eq!(actions, vec![TestAction::ScrollTo(4)]);
472 }
473
474 #[test]
475 fn test_scroll_wheel_action() {
476 let mut view = ScrollView::new();
477 let lines = make_lines(5);
478 let scroller = LinesScroller::new(&lines);
479 let mut harness = RenderHarness::new(20, 3);
480
481 harness.render_to_string_plain(|frame| {
482 view.render(
483 frame,
484 frame.area(),
485 ScrollViewProps {
486 content_height: scroller.content_height(),
487 scroll_offset: 1,
488 is_focused: true,
489 style: ScrollViewStyle::borderless(),
490 behavior: ScrollViewBehavior::default(),
491 on_scroll: TestAction::ScrollTo,
492 render_content: &mut scroller.renderer(),
493 },
494 );
495 });
496
497 let mut noop_render = |_: &mut Frame, _: Rect, _: VisibleRange| {};
498 let actions: Vec<_> = view
499 .handle_event(
500 &EventKind::Scroll {
501 column: 0,
502 row: 0,
503 delta: -1,
504 },
505 ScrollViewProps {
506 content_height: lines.len(),
507 scroll_offset: 1,
508 is_focused: true,
509 style: ScrollViewStyle::borderless(),
510 behavior: ScrollViewBehavior::default(),
511 on_scroll: TestAction::ScrollTo,
512 render_content: &mut noop_render,
513 },
514 )
515 .into_iter()
516 .collect();
517
518 assert_eq!(actions, vec![TestAction::ScrollTo(0)]);
519 }
520
521 #[test]
522 fn test_render_respects_offset() {
523 let mut view = ScrollView::new();
524 let lines = make_lines(6);
525 let scroller = LinesScroller::new(&lines);
526 let mut harness = RenderHarness::new(20, 3);
527
528 let output = harness.render_to_string_plain(|frame| {
529 view.render(
530 frame,
531 frame.area(),
532 ScrollViewProps {
533 content_height: scroller.content_height(),
534 scroll_offset: 2,
535 is_focused: true,
536 style: ScrollViewStyle::borderless(),
537 behavior: ScrollViewBehavior::default(),
538 on_scroll: TestAction::ScrollTo,
539 render_content: &mut scroller.renderer(),
540 },
541 );
542 });
543
544 assert!(output.contains("Line 2"));
545 assert!(!output.contains("Line 0"));
546 }
547
548 #[test]
549 fn test_lines_scroller_content_height() {
550 let lines = make_lines(10);
551 let scroller = LinesScroller::new(&lines);
552 assert_eq!(scroller.content_height(), 10);
553 }
554}