1use ratatui::{
35 buffer::Buffer,
36 layout::Rect,
37 style::{Color, Modifier, Style},
38 text::Line,
39 widgets::{Block, Borders, Paragraph, Widget},
40};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ScrollableContentAction {
45 ScrollUp,
47 ScrollDown,
49 ScrollToTop,
51 ScrollToBottom,
53 PageUp,
55 PageDown,
57 ToggleFullscreen,
59}
60
61#[derive(Debug, Clone)]
63pub struct ScrollableContentState {
64 lines: Vec<String>,
66 scroll_offset: usize,
68 focused: bool,
70 fullscreen: bool,
72 title: Option<String>,
74}
75
76impl ScrollableContentState {
77 pub fn new(lines: Vec<String>) -> Self {
79 Self {
80 lines,
81 scroll_offset: 0,
82 focused: false,
83 fullscreen: false,
84 title: None,
85 }
86 }
87
88 pub fn empty() -> Self {
90 Self::new(Vec::new())
91 }
92
93 pub fn set_lines(&mut self, lines: Vec<String>) {
95 self.lines = lines;
96 if !self.lines.is_empty() {
98 self.scroll_offset = self.scroll_offset.min(self.lines.len() - 1);
99 } else {
100 self.scroll_offset = 0;
101 }
102 }
103
104 pub fn lines(&self) -> &[String] {
106 &self.lines
107 }
108
109 pub fn push_line(&mut self, line: impl Into<String>) {
111 self.lines.push(line.into());
112 }
113
114 pub fn clear(&mut self) {
116 self.lines.clear();
117 self.scroll_offset = 0;
118 }
119
120 pub fn line_count(&self) -> usize {
122 self.lines.len()
123 }
124
125 pub fn scroll_offset(&self) -> usize {
127 self.scroll_offset
128 }
129
130 pub fn set_scroll_offset(&mut self, offset: usize) {
132 if !self.lines.is_empty() {
133 self.scroll_offset = offset.min(self.lines.len() - 1);
134 } else {
135 self.scroll_offset = 0;
136 }
137 }
138
139 pub fn is_focused(&self) -> bool {
141 self.focused
142 }
143
144 pub fn set_focused(&mut self, focused: bool) {
146 self.focused = focused;
147 }
148
149 pub fn is_fullscreen(&self) -> bool {
151 self.fullscreen
152 }
153
154 pub fn set_fullscreen(&mut self, fullscreen: bool) {
156 self.fullscreen = fullscreen;
157 }
158
159 pub fn toggle_fullscreen(&mut self) -> bool {
161 self.fullscreen = !self.fullscreen;
162 self.fullscreen
163 }
164
165 pub fn set_title(&mut self, title: impl Into<String>) {
167 self.title = Some(title.into());
168 }
169
170 pub fn title(&self) -> Option<&str> {
172 self.title.as_deref()
173 }
174
175 pub fn scroll_up(&mut self, lines: usize) {
177 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
178 }
179
180 pub fn scroll_down(&mut self, lines: usize, visible_height: usize) {
182 if self.lines.is_empty() {
183 return;
184 }
185 let max_offset = self.lines.len().saturating_sub(visible_height);
186 self.scroll_offset = (self.scroll_offset + lines).min(max_offset);
187 }
188
189 pub fn scroll_to_top(&mut self) {
191 self.scroll_offset = 0;
192 }
193
194 pub fn scroll_to_bottom(&mut self, visible_height: usize) {
196 if self.lines.is_empty() {
197 return;
198 }
199 self.scroll_offset = self.lines.len().saturating_sub(visible_height);
200 }
201
202 pub fn page_up(&mut self, visible_height: usize) {
204 self.scroll_up(visible_height.saturating_sub(1));
205 }
206
207 pub fn page_down(&mut self, visible_height: usize) {
209 self.scroll_down(visible_height.saturating_sub(1), visible_height);
210 }
211
212 pub fn visible_lines(&self, height: usize) -> &[String] {
214 if self.lines.is_empty() {
215 return &[];
216 }
217 let start = self.scroll_offset.min(self.lines.len() - 1);
218 let end = (start + height).min(self.lines.len());
219 &self.lines[start..end]
220 }
221
222 pub fn is_at_top(&self) -> bool {
224 self.scroll_offset == 0
225 }
226
227 pub fn is_at_bottom(&self, visible_height: usize) -> bool {
229 if self.lines.is_empty() {
230 return true;
231 }
232 self.scroll_offset >= self.lines.len().saturating_sub(visible_height)
233 }
234
235 pub fn content_as_string(&self) -> String {
237 self.lines.join("\n")
238 }
239}
240
241impl Default for ScrollableContentState {
242 fn default() -> Self {
243 Self::empty()
244 }
245}
246
247#[derive(Debug, Clone)]
249pub struct ScrollableContentStyle {
250 pub border_style: Style,
252 pub focused_border_style: Style,
254 pub text_style: Style,
256 pub show_borders: bool,
258 pub show_scroll_indicators: bool,
260}
261
262impl Default for ScrollableContentStyle {
263 fn default() -> Self {
264 Self {
265 border_style: Style::default().fg(Color::DarkGray),
266 focused_border_style: Style::default().fg(Color::Cyan),
267 text_style: Style::default().fg(Color::White),
268 show_borders: true,
269 show_scroll_indicators: true,
270 }
271 }
272}
273
274impl ScrollableContentStyle {
275 pub fn borderless() -> Self {
277 Self {
278 show_borders: false,
279 ..Default::default()
280 }
281 }
282
283 pub fn with_focus_color(mut self, color: Color) -> Self {
285 self.focused_border_style = Style::default().fg(color);
286 self
287 }
288
289 pub fn text_style(mut self, style: Style) -> Self {
291 self.text_style = style;
292 self
293 }
294}
295
296pub struct ScrollableContent<'a> {
301 state: &'a ScrollableContentState,
302 style: ScrollableContentStyle,
303 title: Option<&'a str>,
304}
305
306impl<'a> ScrollableContent<'a> {
307 pub fn new(state: &'a ScrollableContentState) -> Self {
309 Self {
310 state,
311 style: ScrollableContentStyle::default(),
312 title: state.title.as_deref(),
313 }
314 }
315
316 pub fn style(mut self, style: ScrollableContentStyle) -> Self {
318 self.style = style;
319 self
320 }
321
322 pub fn title(mut self, title: &'a str) -> Self {
324 self.title = Some(title);
325 self
326 }
327
328 pub fn inner_area(&self, area: Rect) -> Rect {
330 if self.style.show_borders {
331 Rect {
332 x: area.x + 1,
333 y: area.y + 1,
334 width: area.width.saturating_sub(2),
335 height: area.height.saturating_sub(2),
336 }
337 } else {
338 area
339 }
340 }
341}
342
343impl Widget for ScrollableContent<'_> {
344 fn render(self, area: Rect, buf: &mut Buffer) {
345 if area.width == 0 || area.height == 0 {
346 return;
347 }
348
349 let border_style = if self.state.focused {
350 self.style.focused_border_style
351 } else {
352 self.style.border_style
353 };
354
355 let mut block = Block::default().border_style(border_style);
357 if self.style.show_borders {
358 block = block.borders(Borders::ALL);
359 }
360 if let Some(title) = self.title {
361 let title_style = if self.state.focused {
362 border_style.add_modifier(Modifier::BOLD)
363 } else {
364 border_style
365 };
366 block = block.title(format!(" {} ", title)).title_style(title_style);
367 }
368
369 let inner = block.inner(area);
370 block.render(area, buf);
371
372 let visible_height = inner.height as usize;
374 let visible_lines = self.state.visible_lines(visible_height);
375
376 let lines: Vec<Line> = visible_lines
377 .iter()
378 .map(|s| Line::from(s.as_str()).style(self.style.text_style))
379 .collect();
380
381 let paragraph = Paragraph::new(lines);
382 paragraph.render(inner, buf);
383
384 if self.style.show_scroll_indicators && self.style.show_borders {
386 let has_content_above = !self.state.is_at_top();
387 let has_content_below = !self.state.is_at_bottom(visible_height);
388
389 if has_content_above && area.height > 2 {
390 buf.set_string(
391 area.x + area.width - 2,
392 area.y,
393 "▲",
394 Style::default().fg(Color::DarkGray),
395 );
396 }
397 if has_content_below && area.height > 2 {
398 buf.set_string(
399 area.x + area.width - 2,
400 area.y + area.height - 1,
401 "▼",
402 Style::default().fg(Color::DarkGray),
403 );
404 }
405 }
406 }
407}
408
409pub fn handle_scrollable_content_key(
419 state: &mut ScrollableContentState,
420 key: &crossterm::event::KeyEvent,
421 visible_height: usize,
422) -> Option<ScrollableContentAction> {
423 use crossterm::event::KeyCode;
424
425 match key.code {
426 KeyCode::Up | KeyCode::Char('k') => {
427 state.scroll_up(1);
428 Some(ScrollableContentAction::ScrollUp)
429 }
430 KeyCode::Down | KeyCode::Char('j') => {
431 state.scroll_down(1, visible_height);
432 Some(ScrollableContentAction::ScrollDown)
433 }
434 KeyCode::PageUp => {
435 state.page_up(visible_height);
436 Some(ScrollableContentAction::PageUp)
437 }
438 KeyCode::PageDown => {
439 state.page_down(visible_height);
440 Some(ScrollableContentAction::PageDown)
441 }
442 KeyCode::Home => {
443 state.scroll_to_top();
444 Some(ScrollableContentAction::ScrollToTop)
445 }
446 KeyCode::End => {
447 state.scroll_to_bottom(visible_height);
448 Some(ScrollableContentAction::ScrollToBottom)
449 }
450 KeyCode::F(10) | KeyCode::Enter => {
451 state.toggle_fullscreen();
452 Some(ScrollableContentAction::ToggleFullscreen)
453 }
454 _ => None,
455 }
456}
457
458pub fn handle_scrollable_content_mouse(
464 state: &mut ScrollableContentState,
465 mouse: &crossterm::event::MouseEvent,
466 content_area: Rect,
467 visible_height: usize,
468) -> Option<ScrollableContentAction> {
469 use crossterm::event::MouseEventKind;
470
471 if mouse.column < content_area.x
473 || mouse.column >= content_area.x + content_area.width
474 || mouse.row < content_area.y
475 || mouse.row >= content_area.y + content_area.height
476 {
477 return None;
478 }
479
480 match mouse.kind {
481 MouseEventKind::ScrollUp => {
482 state.scroll_up(3);
483 Some(ScrollableContentAction::ScrollUp)
484 }
485 MouseEventKind::ScrollDown => {
486 state.scroll_down(3, visible_height);
487 Some(ScrollableContentAction::ScrollDown)
488 }
489 _ => None,
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 fn sample_lines() -> Vec<String> {
498 (1..=100).map(|i| format!("Line {}", i)).collect()
499 }
500
501 #[test]
502 fn test_state_new() {
503 let lines = vec!["a".to_string(), "b".to_string()];
504 let state = ScrollableContentState::new(lines.clone());
505 assert_eq!(state.lines(), &lines);
506 assert_eq!(state.scroll_offset(), 0);
507 assert!(!state.is_focused());
508 assert!(!state.is_fullscreen());
509 }
510
511 #[test]
512 fn test_state_empty() {
513 let state = ScrollableContentState::empty();
514 assert!(state.lines().is_empty());
515 assert_eq!(state.line_count(), 0);
516 }
517
518 #[test]
519 fn test_scroll_up() {
520 let mut state = ScrollableContentState::new(sample_lines());
521 state.set_scroll_offset(50);
522 assert_eq!(state.scroll_offset(), 50);
523
524 state.scroll_up(10);
525 assert_eq!(state.scroll_offset(), 40);
526
527 state.scroll_up(100); assert_eq!(state.scroll_offset(), 0);
529 }
530
531 #[test]
532 fn test_scroll_down() {
533 let mut state = ScrollableContentState::new(sample_lines());
534 let visible_height = 20;
535
536 state.scroll_down(10, visible_height);
537 assert_eq!(state.scroll_offset(), 10);
538
539 state.scroll_down(1000, visible_height); assert_eq!(state.scroll_offset(), 100 - visible_height);
541 }
542
543 #[test]
544 fn test_scroll_to_top_bottom() {
545 let mut state = ScrollableContentState::new(sample_lines());
546 let visible_height = 20;
547
548 state.scroll_to_bottom(visible_height);
549 assert_eq!(state.scroll_offset(), 80);
550 assert!(state.is_at_bottom(visible_height));
551
552 state.scroll_to_top();
553 assert_eq!(state.scroll_offset(), 0);
554 assert!(state.is_at_top());
555 }
556
557 #[test]
558 fn test_page_up_down() {
559 let mut state = ScrollableContentState::new(sample_lines());
560 let visible_height = 20;
561
562 state.page_down(visible_height);
563 assert_eq!(state.scroll_offset(), 19); state.page_up(visible_height);
566 assert_eq!(state.scroll_offset(), 0);
567 }
568
569 #[test]
570 fn test_visible_lines() {
571 let state = ScrollableContentState::new(sample_lines());
572 let visible = state.visible_lines(5);
573 assert_eq!(visible.len(), 5);
574 assert_eq!(visible[0], "Line 1");
575 assert_eq!(visible[4], "Line 5");
576 }
577
578 #[test]
579 fn test_focus_and_fullscreen() {
580 let mut state = ScrollableContentState::empty();
581
582 assert!(!state.is_focused());
583 state.set_focused(true);
584 assert!(state.is_focused());
585
586 assert!(!state.is_fullscreen());
587 assert!(state.toggle_fullscreen());
588 assert!(state.is_fullscreen());
589 assert!(!state.toggle_fullscreen());
590 assert!(!state.is_fullscreen());
591 }
592
593 #[test]
594 fn test_content_as_string() {
595 let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
596 let state = ScrollableContentState::new(lines);
597 assert_eq!(state.content_as_string(), "a\nb\nc");
598 }
599
600 #[test]
601 fn test_set_lines_clamps_scroll() {
602 let mut state = ScrollableContentState::new(sample_lines());
603 state.set_scroll_offset(50);
604
605 state.set_lines(vec!["a".to_string(), "b".to_string()]);
607 assert_eq!(state.scroll_offset(), 1); }
609
610 #[test]
611 fn test_style_default() {
612 let style = ScrollableContentStyle::default();
613 assert!(style.show_borders);
614 assert!(style.show_scroll_indicators);
615 }
616
617 #[test]
618 fn test_style_borderless() {
619 let style = ScrollableContentStyle::borderless();
620 assert!(!style.show_borders);
621 }
622
623 #[test]
624 fn test_handle_key_scroll() {
625 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
626
627 let mut state = ScrollableContentState::new(sample_lines());
628 let visible_height = 20;
629
630 let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
632 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
633 assert_eq!(action, Some(ScrollableContentAction::ScrollDown));
634 assert_eq!(state.scroll_offset(), 1);
635
636 let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
638 handle_scrollable_content_key(&mut state, &key, visible_height);
639 assert_eq!(state.scroll_offset(), 2);
640
641 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
643 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
644 assert_eq!(action, Some(ScrollableContentAction::ScrollUp));
645 assert_eq!(state.scroll_offset(), 1);
646
647 let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
649 handle_scrollable_content_key(&mut state, &key, visible_height);
650 assert_eq!(state.scroll_offset(), 0);
651
652 state.set_scroll_offset(50);
654 let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
655 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
656 assert_eq!(action, Some(ScrollableContentAction::ScrollToTop));
657 assert_eq!(state.scroll_offset(), 0);
658
659 let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
661 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
662 assert_eq!(action, Some(ScrollableContentAction::ScrollToBottom));
663 assert_eq!(state.scroll_offset(), 80);
664 }
665
666 #[test]
667 fn test_handle_key_fullscreen() {
668 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
669
670 let mut state = ScrollableContentState::new(sample_lines());
671 let visible_height = 20;
672
673 let key = KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE);
675 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
676 assert_eq!(action, Some(ScrollableContentAction::ToggleFullscreen));
677 assert!(state.is_fullscreen());
678
679 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
681 handle_scrollable_content_key(&mut state, &key, visible_height);
682 assert!(!state.is_fullscreen());
683 }
684
685 #[test]
686 fn test_widget_render() {
687 let state = ScrollableContentState::new(vec![
688 "Line 1".to_string(),
689 "Line 2".to_string(),
690 "Line 3".to_string(),
691 ]);
692 let widget = ScrollableContent::new(&state).title("Test");
693 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
694
695 widget.render(Rect::new(0, 0, 20, 10), &mut buf);
696
697 let content: String = buf.content.iter().map(|c| c.symbol()).collect();
699 assert!(content.contains("Line 1"));
700 }
701
702 #[test]
703 fn test_inner_area() {
704 let state = ScrollableContentState::empty();
705 let content = ScrollableContent::new(&state);
706 let area = Rect::new(0, 0, 20, 10);
707
708 let inner = content.inner_area(area);
709 assert_eq!(inner.x, 1);
710 assert_eq!(inner.y, 1);
711 assert_eq!(inner.width, 18);
712 assert_eq!(inner.height, 8);
713 }
714
715 #[test]
716 fn test_title() {
717 let mut state = ScrollableContentState::empty();
718 state.set_title("My Title");
719 assert_eq!(state.title(), Some("My Title"));
720
721 let widget = ScrollableContent::new(&state);
722 assert_eq!(widget.title, Some("My Title"));
723
724 let widget = ScrollableContent::new(&state).title("Override");
726 assert_eq!(widget.title, Some("Override"));
727 }
728}