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 From<&crate::theme::Theme> for ScrollableContentStyle {
275 fn from(theme: &crate::theme::Theme) -> Self {
276 let p = &theme.palette;
277 Self {
278 border_style: Style::default().fg(p.border_disabled),
279 focused_border_style: Style::default().fg(p.border_accent),
280 text_style: Style::default().fg(p.text),
281 show_borders: true,
282 show_scroll_indicators: true,
283 }
284 }
285}
286
287impl ScrollableContentStyle {
288 pub fn borderless() -> Self {
290 Self {
291 show_borders: false,
292 ..Default::default()
293 }
294 }
295
296 pub fn with_focus_color(mut self, color: Color) -> Self {
298 self.focused_border_style = Style::default().fg(color);
299 self
300 }
301
302 pub fn text_style(mut self, style: Style) -> Self {
304 self.text_style = style;
305 self
306 }
307}
308
309pub struct ScrollableContent<'a> {
314 state: &'a ScrollableContentState,
315 style: ScrollableContentStyle,
316 title: Option<&'a str>,
317}
318
319impl<'a> ScrollableContent<'a> {
320 pub fn new(state: &'a ScrollableContentState) -> Self {
322 Self {
323 state,
324 style: ScrollableContentStyle::default(),
325 title: state.title.as_deref(),
326 }
327 }
328
329 pub fn style(mut self, style: ScrollableContentStyle) -> Self {
331 self.style = style;
332 self
333 }
334
335 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
337 self.style(ScrollableContentStyle::from(theme))
338 }
339
340 pub fn title(mut self, title: &'a str) -> Self {
342 self.title = Some(title);
343 self
344 }
345
346 pub fn inner_area(&self, area: Rect) -> Rect {
348 if self.style.show_borders {
349 Rect {
350 x: area.x + 1,
351 y: area.y + 1,
352 width: area.width.saturating_sub(2),
353 height: area.height.saturating_sub(2),
354 }
355 } else {
356 area
357 }
358 }
359}
360
361impl Widget for ScrollableContent<'_> {
362 fn render(self, area: Rect, buf: &mut Buffer) {
363 if area.width == 0 || area.height == 0 {
364 return;
365 }
366
367 let border_style = if self.state.focused {
368 self.style.focused_border_style
369 } else {
370 self.style.border_style
371 };
372
373 let mut block = Block::default().border_style(border_style);
375 if self.style.show_borders {
376 block = block.borders(Borders::ALL);
377 }
378 if let Some(title) = self.title {
379 let title_style = if self.state.focused {
380 border_style.add_modifier(Modifier::BOLD)
381 } else {
382 border_style
383 };
384 block = block.title(format!(" {} ", title)).title_style(title_style);
385 }
386
387 let inner = block.inner(area);
388 block.render(area, buf);
389
390 let visible_height = inner.height as usize;
392 let visible_lines = self.state.visible_lines(visible_height);
393
394 let lines: Vec<Line> = visible_lines
395 .iter()
396 .map(|s| Line::from(s.as_str()).style(self.style.text_style))
397 .collect();
398
399 let paragraph = Paragraph::new(lines);
400 paragraph.render(inner, buf);
401
402 if self.style.show_scroll_indicators && self.style.show_borders {
404 let has_content_above = !self.state.is_at_top();
405 let has_content_below = !self.state.is_at_bottom(visible_height);
406
407 if has_content_above && area.height > 2 {
408 buf.set_string(
409 area.x + area.width - 2,
410 area.y,
411 "▲",
412 Style::default().fg(Color::DarkGray),
413 );
414 }
415 if has_content_below && area.height > 2 {
416 buf.set_string(
417 area.x + area.width - 2,
418 area.y + area.height - 1,
419 "▼",
420 Style::default().fg(Color::DarkGray),
421 );
422 }
423 }
424 }
425}
426
427pub fn handle_scrollable_content_key(
437 state: &mut ScrollableContentState,
438 key: &crossterm::event::KeyEvent,
439 visible_height: usize,
440) -> Option<ScrollableContentAction> {
441 use crossterm::event::KeyCode;
442
443 match key.code {
444 KeyCode::Up | KeyCode::Char('k') => {
445 state.scroll_up(1);
446 Some(ScrollableContentAction::ScrollUp)
447 }
448 KeyCode::Down | KeyCode::Char('j') => {
449 state.scroll_down(1, visible_height);
450 Some(ScrollableContentAction::ScrollDown)
451 }
452 KeyCode::PageUp => {
453 state.page_up(visible_height);
454 Some(ScrollableContentAction::PageUp)
455 }
456 KeyCode::PageDown => {
457 state.page_down(visible_height);
458 Some(ScrollableContentAction::PageDown)
459 }
460 KeyCode::Home => {
461 state.scroll_to_top();
462 Some(ScrollableContentAction::ScrollToTop)
463 }
464 KeyCode::End => {
465 state.scroll_to_bottom(visible_height);
466 Some(ScrollableContentAction::ScrollToBottom)
467 }
468 KeyCode::F(10) | KeyCode::Enter => {
469 state.toggle_fullscreen();
470 Some(ScrollableContentAction::ToggleFullscreen)
471 }
472 _ => None,
473 }
474}
475
476pub fn handle_scrollable_content_mouse(
482 state: &mut ScrollableContentState,
483 mouse: &crossterm::event::MouseEvent,
484 content_area: Rect,
485 visible_height: usize,
486) -> Option<ScrollableContentAction> {
487 use crossterm::event::MouseEventKind;
488
489 if mouse.column < content_area.x
491 || mouse.column >= content_area.x + content_area.width
492 || mouse.row < content_area.y
493 || mouse.row >= content_area.y + content_area.height
494 {
495 return None;
496 }
497
498 match mouse.kind {
499 MouseEventKind::ScrollUp => {
500 state.scroll_up(3);
501 Some(ScrollableContentAction::ScrollUp)
502 }
503 MouseEventKind::ScrollDown => {
504 state.scroll_down(3, visible_height);
505 Some(ScrollableContentAction::ScrollDown)
506 }
507 _ => None,
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn sample_lines() -> Vec<String> {
516 (1..=100).map(|i| format!("Line {}", i)).collect()
517 }
518
519 #[test]
520 fn test_state_new() {
521 let lines = vec!["a".to_string(), "b".to_string()];
522 let state = ScrollableContentState::new(lines.clone());
523 assert_eq!(state.lines(), &lines);
524 assert_eq!(state.scroll_offset(), 0);
525 assert!(!state.is_focused());
526 assert!(!state.is_fullscreen());
527 }
528
529 #[test]
530 fn test_state_empty() {
531 let state = ScrollableContentState::empty();
532 assert!(state.lines().is_empty());
533 assert_eq!(state.line_count(), 0);
534 }
535
536 #[test]
537 fn test_scroll_up() {
538 let mut state = ScrollableContentState::new(sample_lines());
539 state.set_scroll_offset(50);
540 assert_eq!(state.scroll_offset(), 50);
541
542 state.scroll_up(10);
543 assert_eq!(state.scroll_offset(), 40);
544
545 state.scroll_up(100); assert_eq!(state.scroll_offset(), 0);
547 }
548
549 #[test]
550 fn test_scroll_down() {
551 let mut state = ScrollableContentState::new(sample_lines());
552 let visible_height = 20;
553
554 state.scroll_down(10, visible_height);
555 assert_eq!(state.scroll_offset(), 10);
556
557 state.scroll_down(1000, visible_height); assert_eq!(state.scroll_offset(), 100 - visible_height);
559 }
560
561 #[test]
562 fn test_scroll_to_top_bottom() {
563 let mut state = ScrollableContentState::new(sample_lines());
564 let visible_height = 20;
565
566 state.scroll_to_bottom(visible_height);
567 assert_eq!(state.scroll_offset(), 80);
568 assert!(state.is_at_bottom(visible_height));
569
570 state.scroll_to_top();
571 assert_eq!(state.scroll_offset(), 0);
572 assert!(state.is_at_top());
573 }
574
575 #[test]
576 fn test_page_up_down() {
577 let mut state = ScrollableContentState::new(sample_lines());
578 let visible_height = 20;
579
580 state.page_down(visible_height);
581 assert_eq!(state.scroll_offset(), 19); state.page_up(visible_height);
584 assert_eq!(state.scroll_offset(), 0);
585 }
586
587 #[test]
588 fn test_visible_lines() {
589 let state = ScrollableContentState::new(sample_lines());
590 let visible = state.visible_lines(5);
591 assert_eq!(visible.len(), 5);
592 assert_eq!(visible[0], "Line 1");
593 assert_eq!(visible[4], "Line 5");
594 }
595
596 #[test]
597 fn test_focus_and_fullscreen() {
598 let mut state = ScrollableContentState::empty();
599
600 assert!(!state.is_focused());
601 state.set_focused(true);
602 assert!(state.is_focused());
603
604 assert!(!state.is_fullscreen());
605 assert!(state.toggle_fullscreen());
606 assert!(state.is_fullscreen());
607 assert!(!state.toggle_fullscreen());
608 assert!(!state.is_fullscreen());
609 }
610
611 #[test]
612 fn test_content_as_string() {
613 let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()];
614 let state = ScrollableContentState::new(lines);
615 assert_eq!(state.content_as_string(), "a\nb\nc");
616 }
617
618 #[test]
619 fn test_set_lines_clamps_scroll() {
620 let mut state = ScrollableContentState::new(sample_lines());
621 state.set_scroll_offset(50);
622
623 state.set_lines(vec!["a".to_string(), "b".to_string()]);
625 assert_eq!(state.scroll_offset(), 1); }
627
628 #[test]
629 fn test_style_default() {
630 let style = ScrollableContentStyle::default();
631 assert!(style.show_borders);
632 assert!(style.show_scroll_indicators);
633 }
634
635 #[test]
636 fn test_style_borderless() {
637 let style = ScrollableContentStyle::borderless();
638 assert!(!style.show_borders);
639 }
640
641 #[test]
642 fn test_handle_key_scroll() {
643 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
644
645 let mut state = ScrollableContentState::new(sample_lines());
646 let visible_height = 20;
647
648 let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
650 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
651 assert_eq!(action, Some(ScrollableContentAction::ScrollDown));
652 assert_eq!(state.scroll_offset(), 1);
653
654 let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
656 handle_scrollable_content_key(&mut state, &key, visible_height);
657 assert_eq!(state.scroll_offset(), 2);
658
659 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
661 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
662 assert_eq!(action, Some(ScrollableContentAction::ScrollUp));
663 assert_eq!(state.scroll_offset(), 1);
664
665 let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
667 handle_scrollable_content_key(&mut state, &key, visible_height);
668 assert_eq!(state.scroll_offset(), 0);
669
670 state.set_scroll_offset(50);
672 let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
673 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
674 assert_eq!(action, Some(ScrollableContentAction::ScrollToTop));
675 assert_eq!(state.scroll_offset(), 0);
676
677 let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
679 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
680 assert_eq!(action, Some(ScrollableContentAction::ScrollToBottom));
681 assert_eq!(state.scroll_offset(), 80);
682 }
683
684 #[test]
685 fn test_handle_key_fullscreen() {
686 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
687
688 let mut state = ScrollableContentState::new(sample_lines());
689 let visible_height = 20;
690
691 let key = KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE);
693 let action = handle_scrollable_content_key(&mut state, &key, visible_height);
694 assert_eq!(action, Some(ScrollableContentAction::ToggleFullscreen));
695 assert!(state.is_fullscreen());
696
697 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
699 handle_scrollable_content_key(&mut state, &key, visible_height);
700 assert!(!state.is_fullscreen());
701 }
702
703 #[test]
704 fn test_widget_render() {
705 let state = ScrollableContentState::new(vec![
706 "Line 1".to_string(),
707 "Line 2".to_string(),
708 "Line 3".to_string(),
709 ]);
710 let widget = ScrollableContent::new(&state).title("Test");
711 let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
712
713 widget.render(Rect::new(0, 0, 20, 10), &mut buf);
714
715 let content: String = buf.content.iter().map(|c| c.symbol()).collect();
717 assert!(content.contains("Line 1"));
718 }
719
720 #[test]
721 fn test_inner_area() {
722 let state = ScrollableContentState::empty();
723 let content = ScrollableContent::new(&state);
724 let area = Rect::new(0, 0, 20, 10);
725
726 let inner = content.inner_area(area);
727 assert_eq!(inner.x, 1);
728 assert_eq!(inner.y, 1);
729 assert_eq!(inner.width, 18);
730 assert_eq!(inner.height, 8);
731 }
732
733 #[test]
734 fn test_title() {
735 let mut state = ScrollableContentState::empty();
736 state.set_title("My Title");
737 assert_eq!(state.title(), Some("My Title"));
738
739 let widget = ScrollableContent::new(&state);
740 assert_eq!(widget.title, Some("My Title"));
741
742 let widget = ScrollableContent::new(&state).title("Override");
744 assert_eq!(widget.title, Some("Override"));
745 }
746}