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], width: u16) {
247 let height: u16 = items.iter().map(|i| i.height(width)).sum();
248 self.scroll.set_content_height(height);
249 }
250
251 pub fn item_y_offset<I: ScrollItem>(&self, items: &[I], index: usize, width: u16) -> u16 {
253 items[..index].iter().map(|i| i.height(width)).sum()
254 }
255
256 pub fn ensure_focused_visible<I: ScrollItem>(
258 &mut self,
259 items: &[I],
260 focused_index: usize,
261 sub_focus: Option<usize>,
262 width: u16,
263 ) {
264 if focused_index >= items.len() {
265 return;
266 }
267
268 let item_y = self.item_y_offset(items, focused_index, width);
270 let item = &items[focused_index];
271 let item_h = item.height(width);
272
273 let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
275 let regions = item.focus_regions(width);
276 if let Some(region) = regions.iter().find(|r| r.id == sub_id) {
277 (item_y + region.y_offset, region.height)
278 } else {
279 (item_y, item_h)
280 }
281 } else {
282 (item_y, item_h)
283 };
284
285 self.scroll.ensure_visible(focus_y, focus_h);
286 }
287
288 pub fn render<I, F, L>(
301 &self,
302 frame: &mut Frame,
303 area: Rect,
304 items: &[I],
305 render_item: F,
306 theme: &Theme,
307 ) -> ScrollablePanelLayout<L>
308 where
309 I: ScrollItem,
310 F: Fn(&mut Frame, RenderInfo, &I) -> L,
311 {
312 let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
313 let content_area = Rect::new(
314 area.x,
315 area.y,
316 area.width.saturating_sub(scrollbar_width),
317 area.height,
318 );
319 let item_width = content_area.width;
320
321 let mut layouts = Vec::new();
322 let mut content_y = 0u16; let mut render_y = area.y; for (idx, item) in items.iter().enumerate() {
326 let item_h = item.height(item_width);
327
328 if content_y + item_h <= self.scroll.offset {
330 content_y += item_h;
331 continue;
332 }
333
334 if render_y >= area.y + area.height {
336 break;
337 }
338
339 let skip_top = self.scroll.offset.saturating_sub(content_y);
341 let available_h = (area.y + area.height).saturating_sub(render_y);
342 let visible_h = (item_h - skip_top).min(available_h);
343
344 if visible_h > 0 {
345 let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
346 let info = RenderInfo {
347 area: item_area,
348 skip_top,
349 index: idx,
350 };
351 let layout = render_item(frame, info, item);
352 layouts.push(ItemLayoutInfo {
353 index: idx,
354 content_y,
355 area: item_area,
356 layout,
357 });
358 }
359
360 render_y += visible_h;
361 content_y += item_h;
362 }
363
364 let scrollbar_area = if self.scroll.needs_scrollbar() {
366 let sb_area = Rect::new(area.x + content_area.width, area.y, 1, area.height);
367 let scrollbar_state = self.scroll.to_scrollbar_state();
368 let scrollbar_colors = ScrollbarColors::from_theme(theme);
369 render_scrollbar(frame, sb_area, &scrollbar_state, &scrollbar_colors);
370 Some(sb_area)
371 } else {
372 None
373 };
374
375 ScrollablePanelLayout {
376 content_area,
377 scrollbar_area,
378 item_layouts: layouts,
379 }
380 }
381
382 pub fn render_content_only<I, F, L>(
384 &self,
385 frame: &mut Frame,
386 area: Rect,
387 items: &[I],
388 render_item: F,
389 ) -> Vec<ItemLayoutInfo<L>>
390 where
391 I: ScrollItem,
392 F: Fn(&mut Frame, RenderInfo, &I) -> L,
393 {
394 let mut layouts = Vec::new();
395 let mut content_y = 0u16;
396 let mut render_y = area.y;
397 let item_width = area.width;
398
399 for (idx, item) in items.iter().enumerate() {
400 let item_h = item.height(item_width);
401
402 if content_y + item_h <= self.scroll.offset {
403 content_y += item_h;
404 continue;
405 }
406
407 if render_y >= area.y + area.height {
408 break;
409 }
410
411 let skip_top = self.scroll.offset.saturating_sub(content_y);
412 let available_h = (area.y + area.height).saturating_sub(render_y);
413 let visible_h = (item_h - skip_top).min(available_h);
414
415 if visible_h > 0 {
416 let item_area = Rect::new(area.x, render_y, area.width, visible_h);
417 let info = RenderInfo {
418 area: item_area,
419 skip_top,
420 index: idx,
421 };
422 let layout = render_item(frame, info, item);
423 layouts.push(ItemLayoutInfo {
424 index: idx,
425 content_y,
426 area: item_area,
427 layout,
428 });
429 }
430
431 render_y += visible_h;
432 content_y += item_h;
433 }
434
435 layouts
436 }
437
438 pub fn scroll_up(&mut self, rows: u16) {
440 self.scroll.scroll_by(-(rows as i16));
441 }
442
443 pub fn scroll_down(&mut self, rows: u16) {
444 self.scroll.scroll_by(rows as i16);
445 }
446
447 pub fn scroll_to_ratio(&mut self, ratio: f32) {
448 self.scroll.scroll_to_ratio(ratio);
449 }
450
451 pub fn offset(&self) -> u16 {
453 self.scroll.offset
454 }
455
456 pub fn needs_scrollbar(&self) -> bool {
458 self.scroll.needs_scrollbar()
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 struct TestItem {
467 height: u16,
468 }
469
470 impl ScrollItem for TestItem {
471 fn height(&self, _width: u16) -> u16 {
472 self.height
473 }
474 }
475
476 #[test]
477 fn test_scroll_state_basic() {
478 let mut state = ScrollState::new(10);
479 state.set_content_height(100);
480
481 assert_eq!(state.viewport, 10);
482 assert_eq!(state.content_height, 100);
483 assert_eq!(state.max_offset(), 90);
484 assert!(state.needs_scrollbar());
485 }
486
487 #[test]
488 fn test_scroll_state_no_scrollbar_needed() {
489 let mut state = ScrollState::new(100);
490 state.set_content_height(50);
491
492 assert!(!state.needs_scrollbar());
493 assert_eq!(state.max_offset(), 0);
494 }
495
496 #[test]
497 fn test_scroll_by() {
498 let mut state = ScrollState::new(10);
499 state.set_content_height(100);
500
501 state.scroll_by(5);
502 assert_eq!(state.offset, 5);
503
504 state.scroll_by(-3);
505 assert_eq!(state.offset, 2);
506
507 state.scroll_by(-10);
509 assert_eq!(state.offset, 0);
510
511 state.scroll_by(200);
513 assert_eq!(state.offset, 90);
514 }
515
516 #[test]
517 fn test_ensure_visible_above_viewport() {
518 let mut state = ScrollState::new(10);
519 state.set_content_height(100);
520 state.offset = 50;
521
522 state.ensure_visible(20, 5);
524 assert_eq!(state.offset, 20);
525 }
526
527 #[test]
528 fn test_ensure_visible_below_viewport() {
529 let mut state = ScrollState::new(10);
530 state.set_content_height(100);
531 state.offset = 0;
532
533 state.ensure_visible(50, 5);
535 assert_eq!(state.offset, 45); }
537
538 #[test]
539 fn test_ensure_visible_oversized_item() {
540 let mut state = ScrollState::new(10);
541 state.set_content_height(100);
542 state.offset = 0;
543
544 state.ensure_visible(50, 20);
546 assert_eq!(state.offset, 50); }
548
549 #[test]
550 fn test_ensure_visible_already_visible() {
551 let mut state = ScrollState::new(10);
552 state.set_content_height(100);
553 state.offset = 20;
554
555 state.ensure_visible(22, 3);
557 assert_eq!(state.offset, 20); }
559
560 #[test]
561 fn test_scroll_to_ratio() {
562 let mut state = ScrollState::new(10);
563 state.set_content_height(100);
564
565 state.scroll_to_ratio(0.0);
566 assert_eq!(state.offset, 0);
567
568 state.scroll_to_ratio(1.0);
569 assert_eq!(state.offset, 90);
570
571 state.scroll_to_ratio(0.5);
572 assert_eq!(state.offset, 45);
573 }
574
575 const TEST_WIDTH: u16 = 80;
577
578 #[test]
579 fn test_panel_update_content_height() {
580 let mut panel = ScrollablePanel::new();
581 let items = vec![
582 TestItem { height: 3 },
583 TestItem { height: 5 },
584 TestItem { height: 2 },
585 ];
586
587 panel.update_content_height(&items, TEST_WIDTH);
588 assert_eq!(panel.scroll.content_height, 10);
589 }
590
591 #[test]
592 fn test_panel_item_y_offset() {
593 let panel = ScrollablePanel::new();
594 let items = vec![
595 TestItem { height: 3 },
596 TestItem { height: 5 },
597 TestItem { height: 2 },
598 ];
599
600 assert_eq!(panel.item_y_offset(&items, 0, TEST_WIDTH), 0);
601 assert_eq!(panel.item_y_offset(&items, 1, TEST_WIDTH), 3);
602 assert_eq!(panel.item_y_offset(&items, 2, TEST_WIDTH), 8);
603 }
604
605 #[test]
606 fn test_panel_ensure_focused_visible() {
607 let mut panel = ScrollablePanel::with_viewport(5);
608 let items = vec![
609 TestItem { height: 3 },
610 TestItem { height: 3 },
611 TestItem { height: 3 },
612 TestItem { height: 3 },
613 ];
614 panel.update_content_height(&items, TEST_WIDTH);
615
616 panel.ensure_focused_visible(&items, 2, None, TEST_WIDTH);
618 assert_eq!(panel.scroll.offset, 4);
620 }
621
622 struct TestItemWithRegions {
623 height: u16,
624 regions: Vec<FocusRegion>,
625 }
626
627 impl ScrollItem for TestItemWithRegions {
628 fn height(&self, _width: u16) -> u16 {
629 self.height
630 }
631
632 fn focus_regions(&self, _width: u16) -> Vec<FocusRegion> {
633 self.regions.clone()
634 }
635 }
636
637 #[test]
638 fn test_panel_ensure_focused_visible_with_subfocus() {
639 let mut panel = ScrollablePanel::with_viewport(5);
640 let items = vec![TestItemWithRegions {
641 height: 10,
642 regions: vec![
643 FocusRegion {
644 id: 0,
645 y_offset: 0,
646 height: 1,
647 },
648 FocusRegion {
649 id: 1,
650 y_offset: 3,
651 height: 1,
652 },
653 FocusRegion {
654 id: 2,
655 y_offset: 7,
656 height: 1,
657 },
658 ],
659 }];
660 panel.update_content_height(&items, TEST_WIDTH);
661
662 panel.ensure_focused_visible(&items, 0, Some(2), TEST_WIDTH);
664 assert_eq!(panel.scroll.offset, 3);
666 }
667}