1use ratatui::layout::Rect;
52use ratatui::Frame;
53
54use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
55use crate::view::theme::Theme;
56
57#[derive(Debug, Clone, Copy)]
59pub struct FocusRegion {
60 pub id: usize,
62 pub y_offset: u16,
64 pub height: u16,
66}
67
68pub trait ScrollItem {
74 fn height(&self, width: u16) -> u16;
76
77 fn focus_regions(&self, _width: u16) -> Vec<FocusRegion> {
82 Vec::new()
83 }
84}
85
86#[derive(Debug, Clone, Copy, Default)]
88pub struct ScrollState {
89 pub offset: u16,
91 pub viewport: u16,
93 pub content_height: u16,
95}
96
97impl ScrollState {
98 pub fn new(viewport: u16) -> Self {
100 Self {
101 offset: 0,
102 viewport,
103 content_height: 0,
104 }
105 }
106
107 pub fn set_viewport(&mut self, height: u16) {
109 self.viewport = height;
110 self.clamp_offset();
111 }
112
113 pub fn set_content_height(&mut self, height: u16) {
115 self.content_height = height;
116 self.clamp_offset();
117 }
118
119 pub fn max_offset(&self) -> u16 {
121 self.content_height.saturating_sub(self.viewport)
122 }
123
124 fn clamp_offset(&mut self) {
126 self.offset = self.offset.min(self.max_offset());
127 }
128
129 pub fn ensure_visible(&mut self, y: u16, height: u16) {
132 if y < self.offset {
133 self.offset = y;
135 } else if y + height > self.offset + self.viewport {
136 if height > self.viewport {
138 self.offset = y;
140 } else {
141 self.offset = y + height - self.viewport;
142 }
143 }
144 self.clamp_offset();
145 }
146
147 pub fn scroll_by(&mut self, delta: i16) {
149 if delta < 0 {
150 self.offset = self.offset.saturating_sub((-delta) as u16);
151 } else {
152 self.offset = self.offset.saturating_add(delta as u16);
153 }
154 self.clamp_offset();
155 }
156
157 pub fn scroll_to_ratio(&mut self, ratio: f32) {
159 let ratio = ratio.clamp(0.0, 1.0);
160 self.offset = (ratio * self.max_offset() as f32) as u16;
161 }
162
163 pub fn needs_scrollbar(&self) -> bool {
165 self.content_height > self.viewport
166 }
167
168 pub fn to_scrollbar_state(&self) -> ScrollbarState {
170 ScrollbarState::new(
171 self.content_height as usize,
172 self.viewport as usize,
173 self.offset as usize,
174 )
175 }
176}
177
178#[derive(Debug, Clone)]
180pub struct ScrollablePanelLayout<L> {
181 pub content_area: Rect,
183 pub scrollbar_area: Option<Rect>,
185 pub item_layouts: Vec<ItemLayoutInfo<L>>,
187}
188
189#[derive(Debug, Clone)]
191pub struct ItemLayoutInfo<L> {
192 pub index: usize,
194 pub content_y: u16,
196 pub area: Rect,
198 pub layout: L,
200}
201
202#[derive(Debug, Clone, Copy)]
204pub struct RenderInfo {
205 pub area: Rect,
207 pub skip_top: u16,
209 pub index: usize,
211}
212
213#[derive(Debug, Clone, Default)]
215pub struct ScrollablePanel {
216 pub scroll: ScrollState,
218}
219
220impl ScrollablePanel {
221 pub fn new() -> Self {
223 Self {
224 scroll: ScrollState::default(),
225 }
226 }
227
228 pub fn with_viewport(viewport: u16) -> Self {
230 Self {
231 scroll: ScrollState::new(viewport),
232 }
233 }
234
235 pub fn set_viewport(&mut self, height: u16) {
237 self.scroll.set_viewport(height);
238 }
239
240 pub fn viewport_height(&self) -> usize {
242 self.scroll.viewport as usize
243 }
244
245 pub fn update_content_height<I: ScrollItem>(&mut self, items: &[I], area_width: u16) {
248 let height1: u16 = items.iter().map(|i| i.height(area_width)).sum();
250 self.scroll.set_content_height(height1);
251
252 if self.scroll.needs_scrollbar() && area_width > 0 {
254 let height2: u16 = items.iter().map(|i| i.height(area_width - 1)).sum();
255 self.scroll.set_content_height(height2);
256 }
257 }
258
259 pub fn content_width(&self, area_width: u16) -> u16 {
261 if self.scroll.needs_scrollbar() {
262 area_width.saturating_sub(1)
263 } else {
264 area_width
265 }
266 }
267
268 pub fn item_y_offset<I: ScrollItem>(
270 &self,
271 items: &[I],
272 index: usize,
273 content_width: u16,
274 ) -> u16 {
275 items[..index].iter().map(|i| i.height(content_width)).sum()
276 }
277
278 pub fn ensure_focused_visible<I: ScrollItem>(
280 &mut self,
281 items: &[I],
282 focused_index: usize,
283 sub_focus: Option<usize>,
284 area_width: u16,
285 ) {
286 if focused_index >= items.len() {
287 return;
288 }
289
290 self.update_content_height(items, area_width);
292
293 let content_width = self.content_width(area_width);
294
295 let item_y = self.item_y_offset(items, focused_index, content_width);
297 let item = &items[focused_index];
298 let item_h = item.height(content_width);
299
300 let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
302 let regions = item.focus_regions(content_width);
303 if let Some(region) = regions.iter().find(|r| r.id == sub_id) {
304 (item_y + region.y_offset, region.height)
305 } else {
306 (item_y, item_h)
307 }
308 } else {
309 (item_y, item_h)
310 };
311 self.scroll.ensure_visible(focus_y, focus_h);
312 }
313
314 pub fn render<I, F, L>(
327 &self,
328 frame: &mut Frame,
329 area: Rect,
330 items: &[I],
331 render_item: F,
332 theme: &Theme,
333 ) -> ScrollablePanelLayout<L>
334 where
335 I: ScrollItem,
336 F: Fn(&mut Frame, RenderInfo, &I) -> L,
337 {
338 let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
339 let content_area = Rect::new(
340 area.x,
341 area.y,
342 area.width.saturating_sub(scrollbar_width),
343 area.height,
344 );
345 let item_width = content_area.width;
346
347 let mut layouts = Vec::new();
348 let mut content_y = 0u16; let mut render_y = area.y; for (idx, item) in items.iter().enumerate() {
352 let item_h = item.height(item_width);
353
354 if content_y + item_h <= self.scroll.offset {
356 content_y += item_h;
357 continue;
358 }
359
360 if render_y >= area.y + area.height {
362 break;
363 }
364
365 let skip_top = self.scroll.offset.saturating_sub(content_y);
367 let available_h = (area.y + area.height).saturating_sub(render_y);
368 let visible_h = (item_h - skip_top).min(available_h);
369
370 if visible_h > 0 {
371 let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
372 let info = RenderInfo {
373 area: item_area,
374 skip_top,
375 index: idx,
376 };
377 let layout = render_item(frame, info, item);
378 layouts.push(ItemLayoutInfo {
379 index: idx,
380 content_y,
381 area: item_area,
382 layout,
383 });
384 }
385
386 render_y += visible_h;
387 content_y += item_h;
388 }
389
390 let scrollbar_area = if self.scroll.needs_scrollbar() {
392 let sb_area = Rect::new(area.x + content_area.width, area.y, 1, area.height);
393 let scrollbar_state = self.scroll.to_scrollbar_state();
394 let scrollbar_colors = ScrollbarColors::from_theme(theme);
395 render_scrollbar(frame, sb_area, &scrollbar_state, &scrollbar_colors);
396 Some(sb_area)
397 } else {
398 None
399 };
400
401 ScrollablePanelLayout {
402 content_area,
403 scrollbar_area,
404 item_layouts: layouts,
405 }
406 }
407
408 pub fn render_content_only<I, F, L>(
410 &self,
411 frame: &mut Frame,
412 area: Rect,
413 items: &[I],
414 render_item: F,
415 ) -> Vec<ItemLayoutInfo<L>>
416 where
417 I: ScrollItem,
418 F: Fn(&mut Frame, RenderInfo, &I) -> L,
419 {
420 let mut layouts = Vec::new();
421 let mut content_y = 0u16;
422 let mut render_y = area.y;
423 let item_width = area.width;
424
425 for (idx, item) in items.iter().enumerate() {
426 let item_h = item.height(item_width);
427
428 if content_y + item_h <= self.scroll.offset {
429 content_y += item_h;
430 continue;
431 }
432
433 if render_y >= area.y + area.height {
434 break;
435 }
436
437 let skip_top = self.scroll.offset.saturating_sub(content_y);
438 let available_h = (area.y + area.height).saturating_sub(render_y);
439 let visible_h = (item_h - skip_top).min(available_h);
440
441 if visible_h > 0 {
442 let item_area = Rect::new(area.x, render_y, area.width, visible_h);
443 let info = RenderInfo {
444 area: item_area,
445 skip_top,
446 index: idx,
447 };
448 let layout = render_item(frame, info, item);
449 layouts.push(ItemLayoutInfo {
450 index: idx,
451 content_y,
452 area: item_area,
453 layout,
454 });
455 }
456
457 render_y += visible_h;
458 content_y += item_h;
459 }
460
461 layouts
462 }
463
464 pub fn scroll_up(&mut self, rows: u16) {
466 self.scroll.scroll_by(-(rows as i16));
467 }
468
469 pub fn scroll_down(&mut self, rows: u16) {
470 self.scroll.scroll_by(rows as i16);
471 }
472
473 pub fn scroll_to_ratio(&mut self, ratio: f32) {
474 self.scroll.scroll_to_ratio(ratio);
475 }
476
477 pub fn offset(&self) -> u16 {
479 self.scroll.offset
480 }
481
482 pub fn needs_scrollbar(&self) -> bool {
484 self.scroll.needs_scrollbar()
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 struct TestItem {
493 height: u16,
494 }
495
496 impl ScrollItem for TestItem {
497 fn height(&self, _width: u16) -> u16 {
498 self.height
499 }
500 }
501
502 impl ScrollItem for fn(u16) -> u16 {
503 fn height(&self, width: u16) -> u16 {
504 self(width)
505 }
506 }
507
508 #[test]
509 fn test_scroll_state_basic() {
510 let mut state = ScrollState::new(10);
511 state.set_content_height(100);
512
513 assert_eq!(state.viewport, 10);
514 assert_eq!(state.content_height, 100);
515 assert_eq!(state.max_offset(), 90);
516 assert!(state.needs_scrollbar());
517 }
518
519 #[test]
520 fn test_scroll_state_no_scrollbar_needed() {
521 let mut state = ScrollState::new(100);
522 state.set_content_height(50);
523
524 assert!(!state.needs_scrollbar());
525 assert_eq!(state.max_offset(), 0);
526 }
527
528 #[test]
529 fn test_scroll_by() {
530 let mut state = ScrollState::new(10);
531 state.set_content_height(100);
532
533 state.scroll_by(5);
534 assert_eq!(state.offset, 5);
535
536 state.scroll_by(-3);
537 assert_eq!(state.offset, 2);
538
539 state.scroll_by(-10);
541 assert_eq!(state.offset, 0);
542
543 state.scroll_by(200);
545 assert_eq!(state.offset, 90);
546 }
547
548 #[test]
549 fn test_ensure_visible_above_viewport() {
550 let mut state = ScrollState::new(10);
551 state.set_content_height(100);
552 state.offset = 50;
553
554 state.ensure_visible(20, 5);
556 assert_eq!(state.offset, 20);
557 }
558
559 #[test]
560 fn test_ensure_visible_below_viewport() {
561 let mut state = ScrollState::new(10);
562 state.set_content_height(100);
563 state.offset = 0;
564
565 state.ensure_visible(50, 5);
567 assert_eq!(state.offset, 45); }
569
570 #[test]
571 fn test_ensure_visible_oversized_item() {
572 let mut state = ScrollState::new(10);
573 state.set_content_height(100);
574 state.offset = 0;
575
576 state.ensure_visible(50, 20);
578 assert_eq!(state.offset, 50); }
580
581 #[test]
582 fn test_ensure_visible_already_visible() {
583 let mut state = ScrollState::new(10);
584 state.set_content_height(100);
585 state.offset = 20;
586
587 state.ensure_visible(22, 3);
589 assert_eq!(state.offset, 20); }
591
592 #[test]
593 fn test_scroll_to_ratio() {
594 let mut state = ScrollState::new(10);
595 state.set_content_height(100);
596
597 state.scroll_to_ratio(0.0);
598 assert_eq!(state.offset, 0);
599
600 state.scroll_to_ratio(1.0);
601 assert_eq!(state.offset, 90);
602
603 state.scroll_to_ratio(0.5);
604 assert_eq!(state.offset, 45);
605 }
606
607 const TEST_WIDTH: u16 = 80;
609
610 #[test]
611 fn test_panel_update_content_height() {
612 let mut panel = ScrollablePanel::new();
613 let items = vec![
614 TestItem { height: 3 },
615 TestItem { height: 5 },
616 TestItem { height: 2 },
617 ];
618
619 panel.update_content_height(&items, TEST_WIDTH);
620 assert_eq!(panel.scroll.content_height, 10);
621 }
622
623 #[test]
624 fn test_panel_item_y_offset() {
625 let panel = ScrollablePanel::new();
626 let items = vec![
627 TestItem { height: 3 },
628 TestItem { height: 5 },
629 TestItem { height: 2 },
630 ];
631
632 assert_eq!(panel.item_y_offset(&items, 0, TEST_WIDTH), 0);
633 assert_eq!(panel.item_y_offset(&items, 1, TEST_WIDTH), 3);
634 assert_eq!(panel.item_y_offset(&items, 2, TEST_WIDTH), 8);
635 }
636
637 #[test]
638 fn test_panel_ensure_focused_visible() {
639 let mut panel = ScrollablePanel::with_viewport(5);
640 let items = vec![
641 TestItem { height: 3 },
642 TestItem { height: 3 },
643 TestItem { height: 3 },
644 TestItem { height: 3 },
645 ];
646 panel.update_content_height(&items, TEST_WIDTH);
647
648 panel.ensure_focused_visible(&items, 2, None, TEST_WIDTH);
650 assert_eq!(panel.scroll.offset, 4);
652 }
653
654 struct TestItemWithRegions {
655 height: u16,
656 regions: Vec<FocusRegion>,
657 }
658
659 impl ScrollItem for TestItemWithRegions {
660 fn height(&self, _width: u16) -> u16 {
661 self.height
662 }
663
664 fn focus_regions(&self, _width: u16) -> Vec<FocusRegion> {
665 self.regions.clone()
666 }
667 }
668
669 #[test]
670 fn test_panel_ensure_focused_visible_with_subfocus() {
671 let mut panel = ScrollablePanel::with_viewport(5);
672 let items = vec![TestItemWithRegions {
673 height: 10,
674 regions: vec![
675 FocusRegion {
676 id: 0,
677 y_offset: 0,
678 height: 1,
679 },
680 FocusRegion {
681 id: 1,
682 y_offset: 3,
683 height: 1,
684 },
685 FocusRegion {
686 id: 2,
687 y_offset: 7,
688 height: 1,
689 },
690 ],
691 }];
692 panel.update_content_height(&items, TEST_WIDTH);
693
694 panel.ensure_focused_visible(&items, 0, Some(2), TEST_WIDTH);
696 assert_eq!(panel.scroll.offset, 3);
698 }
699
700 #[test]
730 fn test_ensure_focused_visible_uses_render_width_when_scrollbar_present() {
731 let area_width = 10u16;
732 let viewport = 5u16;
733
734 let mut items: Vec<fn(u16) -> u16> = vec![|w| if w >= 10 { 2 } else { 3 }; 4];
737 items.push(|_| 2);
738
739 let mut panel = ScrollablePanel::new();
740 panel.set_viewport(viewport);
741 panel.update_content_height(&items, area_width);
742
743 assert!(
745 panel.needs_scrollbar(),
746 "content height ({}) should exceed viewport ({}) to trigger the scrollbar",
747 panel.scroll.content_height,
748 viewport
749 );
750
751 panel.ensure_focused_visible(&items, 4, None, area_width);
753
754 let render_width = area_width - 1;
756 let item4_y: u16 = items[..4].iter().map(|i| i.height(render_width)).sum();
757 let item4_h = items[4].height(render_width);
758 let offset = panel.offset();
759
760 assert!(
762 offset <= item4_y && item4_y + item4_h <= offset + viewport,
763 "Item 4 at render-y={item4_y}..{} must be visible in viewport \
764 [{offset}..{}), but offset was computed as {offset}",
765 item4_y + item4_h,
766 offset + viewport,
767 );
768 }
769}