1use ratatui::layout::Rect;
50use ratatui::Frame;
51
52use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
53use crate::view::theme::Theme;
54
55#[derive(Debug, Clone, Copy)]
57pub struct FocusRegion {
58 pub id: usize,
60 pub y_offset: u16,
62 pub height: u16,
64}
65
66pub trait ScrollItem {
68 fn height(&self) -> u16;
70
71 fn focus_regions(&self) -> Vec<FocusRegion> {
74 Vec::new()
75 }
76}
77
78#[derive(Debug, Clone, Copy, Default)]
80pub struct ScrollState {
81 pub offset: u16,
83 pub viewport: u16,
85 pub content_height: u16,
87}
88
89impl ScrollState {
90 pub fn new(viewport: u16) -> Self {
92 Self {
93 offset: 0,
94 viewport,
95 content_height: 0,
96 }
97 }
98
99 pub fn set_viewport(&mut self, height: u16) {
101 self.viewport = height;
102 self.clamp_offset();
103 }
104
105 pub fn set_content_height(&mut self, height: u16) {
107 self.content_height = height;
108 self.clamp_offset();
109 }
110
111 pub fn max_offset(&self) -> u16 {
113 self.content_height.saturating_sub(self.viewport)
114 }
115
116 fn clamp_offset(&mut self) {
118 self.offset = self.offset.min(self.max_offset());
119 }
120
121 pub fn ensure_visible(&mut self, y: u16, height: u16) {
124 if y < self.offset {
125 self.offset = y;
127 } else if y + height > self.offset + self.viewport {
128 if height > self.viewport {
130 self.offset = y;
132 } else {
133 self.offset = y + height - self.viewport;
134 }
135 }
136 self.clamp_offset();
137 }
138
139 pub fn scroll_by(&mut self, delta: i16) {
141 if delta < 0 {
142 self.offset = self.offset.saturating_sub((-delta) as u16);
143 } else {
144 self.offset = self.offset.saturating_add(delta as u16);
145 }
146 self.clamp_offset();
147 }
148
149 pub fn scroll_to_ratio(&mut self, ratio: f32) {
151 let ratio = ratio.clamp(0.0, 1.0);
152 self.offset = (ratio * self.max_offset() as f32) as u16;
153 }
154
155 pub fn needs_scrollbar(&self) -> bool {
157 self.content_height > self.viewport
158 }
159
160 pub fn to_scrollbar_state(&self) -> ScrollbarState {
162 ScrollbarState::new(
163 self.content_height as usize,
164 self.viewport as usize,
165 self.offset as usize,
166 )
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct ScrollablePanelLayout<L> {
173 pub content_area: Rect,
175 pub scrollbar_area: Option<Rect>,
177 pub item_layouts: Vec<ItemLayoutInfo<L>>,
179}
180
181#[derive(Debug, Clone)]
183pub struct ItemLayoutInfo<L> {
184 pub index: usize,
186 pub content_y: u16,
188 pub area: Rect,
190 pub layout: L,
192}
193
194#[derive(Debug, Clone, Copy)]
196pub struct RenderInfo {
197 pub area: Rect,
199 pub skip_top: u16,
201 pub index: usize,
203}
204
205#[derive(Debug, Clone, Default)]
207pub struct ScrollablePanel {
208 pub scroll: ScrollState,
210}
211
212impl ScrollablePanel {
213 pub fn new() -> Self {
215 Self {
216 scroll: ScrollState::default(),
217 }
218 }
219
220 pub fn with_viewport(viewport: u16) -> Self {
222 Self {
223 scroll: ScrollState::new(viewport),
224 }
225 }
226
227 pub fn set_viewport(&mut self, height: u16) {
229 self.scroll.set_viewport(height);
230 }
231
232 pub fn viewport_height(&self) -> usize {
234 self.scroll.viewport as usize
235 }
236
237 pub fn update_content_height<I: ScrollItem>(&mut self, items: &[I]) {
239 let height: u16 = items.iter().map(|i| i.height()).sum();
240 self.scroll.set_content_height(height);
241 }
242
243 pub fn item_y_offset<I: ScrollItem>(&self, items: &[I], index: usize) -> u16 {
245 items[..index].iter().map(|i| i.height()).sum()
246 }
247
248 pub fn ensure_focused_visible<I: ScrollItem>(
250 &mut self,
251 items: &[I],
252 focused_index: usize,
253 sub_focus: Option<usize>,
254 ) {
255 if focused_index >= items.len() {
256 return;
257 }
258
259 let item_y = self.item_y_offset(items, focused_index);
261 let item = &items[focused_index];
262 let item_h = item.height();
263
264 let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
266 let regions = item.focus_regions();
267 if let Some(region) = regions.iter().find(|r| r.id == sub_id) {
268 (item_y + region.y_offset, region.height)
269 } else {
270 (item_y, item_h)
271 }
272 } else {
273 (item_y, item_h)
274 };
275
276 self.scroll.ensure_visible(focus_y, focus_h);
277 }
278
279 pub fn render<I, F, L>(
292 &self,
293 frame: &mut Frame,
294 area: Rect,
295 items: &[I],
296 render_item: F,
297 theme: &Theme,
298 ) -> ScrollablePanelLayout<L>
299 where
300 I: ScrollItem,
301 F: Fn(&mut Frame, RenderInfo, &I) -> L,
302 {
303 let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
304 let content_area = Rect::new(
305 area.x,
306 area.y,
307 area.width.saturating_sub(scrollbar_width),
308 area.height,
309 );
310
311 let mut layouts = Vec::new();
312 let mut content_y = 0u16; let mut render_y = area.y; for (idx, item) in items.iter().enumerate() {
316 let item_h = item.height();
317
318 if content_y + item_h <= self.scroll.offset {
320 content_y += item_h;
321 continue;
322 }
323
324 if render_y >= area.y + area.height {
326 break;
327 }
328
329 let skip_top = self.scroll.offset.saturating_sub(content_y);
331 let available_h = (area.y + area.height).saturating_sub(render_y);
332 let visible_h = (item_h - skip_top).min(available_h);
333
334 if visible_h > 0 {
335 let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
336 let info = RenderInfo {
337 area: item_area,
338 skip_top,
339 index: idx,
340 };
341 let layout = render_item(frame, info, item);
342 layouts.push(ItemLayoutInfo {
343 index: idx,
344 content_y,
345 area: item_area,
346 layout,
347 });
348 }
349
350 render_y += visible_h;
351 content_y += item_h;
352 }
353
354 let scrollbar_area = if self.scroll.needs_scrollbar() {
356 let sb_area = Rect::new(area.x + content_area.width, area.y, 1, area.height);
357 let scrollbar_state = self.scroll.to_scrollbar_state();
358 let scrollbar_colors = ScrollbarColors::from_theme(theme);
359 render_scrollbar(frame, sb_area, &scrollbar_state, &scrollbar_colors);
360 Some(sb_area)
361 } else {
362 None
363 };
364
365 ScrollablePanelLayout {
366 content_area,
367 scrollbar_area,
368 item_layouts: layouts,
369 }
370 }
371
372 pub fn render_content_only<I, F, L>(
374 &self,
375 frame: &mut Frame,
376 area: Rect,
377 items: &[I],
378 render_item: F,
379 ) -> Vec<ItemLayoutInfo<L>>
380 where
381 I: ScrollItem,
382 F: Fn(&mut Frame, RenderInfo, &I) -> L,
383 {
384 let mut layouts = Vec::new();
385 let mut content_y = 0u16;
386 let mut render_y = area.y;
387
388 for (idx, item) in items.iter().enumerate() {
389 let item_h = item.height();
390
391 if content_y + item_h <= self.scroll.offset {
392 content_y += item_h;
393 continue;
394 }
395
396 if render_y >= area.y + area.height {
397 break;
398 }
399
400 let skip_top = self.scroll.offset.saturating_sub(content_y);
401 let available_h = (area.y + area.height).saturating_sub(render_y);
402 let visible_h = (item_h - skip_top).min(available_h);
403
404 if visible_h > 0 {
405 let item_area = Rect::new(area.x, render_y, area.width, visible_h);
406 let info = RenderInfo {
407 area: item_area,
408 skip_top,
409 index: idx,
410 };
411 let layout = render_item(frame, info, item);
412 layouts.push(ItemLayoutInfo {
413 index: idx,
414 content_y,
415 area: item_area,
416 layout,
417 });
418 }
419
420 render_y += visible_h;
421 content_y += item_h;
422 }
423
424 layouts
425 }
426
427 pub fn scroll_up(&mut self, rows: u16) {
429 self.scroll.scroll_by(-(rows as i16));
430 }
431
432 pub fn scroll_down(&mut self, rows: u16) {
433 self.scroll.scroll_by(rows as i16);
434 }
435
436 pub fn scroll_to_ratio(&mut self, ratio: f32) {
437 self.scroll.scroll_to_ratio(ratio);
438 }
439
440 pub fn offset(&self) -> u16 {
442 self.scroll.offset
443 }
444
445 pub fn needs_scrollbar(&self) -> bool {
447 self.scroll.needs_scrollbar()
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 struct TestItem {
456 height: u16,
457 }
458
459 impl ScrollItem for TestItem {
460 fn height(&self) -> u16 {
461 self.height
462 }
463 }
464
465 #[test]
466 fn test_scroll_state_basic() {
467 let mut state = ScrollState::new(10);
468 state.set_content_height(100);
469
470 assert_eq!(state.viewport, 10);
471 assert_eq!(state.content_height, 100);
472 assert_eq!(state.max_offset(), 90);
473 assert!(state.needs_scrollbar());
474 }
475
476 #[test]
477 fn test_scroll_state_no_scrollbar_needed() {
478 let mut state = ScrollState::new(100);
479 state.set_content_height(50);
480
481 assert!(!state.needs_scrollbar());
482 assert_eq!(state.max_offset(), 0);
483 }
484
485 #[test]
486 fn test_scroll_by() {
487 let mut state = ScrollState::new(10);
488 state.set_content_height(100);
489
490 state.scroll_by(5);
491 assert_eq!(state.offset, 5);
492
493 state.scroll_by(-3);
494 assert_eq!(state.offset, 2);
495
496 state.scroll_by(-10);
498 assert_eq!(state.offset, 0);
499
500 state.scroll_by(200);
502 assert_eq!(state.offset, 90);
503 }
504
505 #[test]
506 fn test_ensure_visible_above_viewport() {
507 let mut state = ScrollState::new(10);
508 state.set_content_height(100);
509 state.offset = 50;
510
511 state.ensure_visible(20, 5);
513 assert_eq!(state.offset, 20);
514 }
515
516 #[test]
517 fn test_ensure_visible_below_viewport() {
518 let mut state = ScrollState::new(10);
519 state.set_content_height(100);
520 state.offset = 0;
521
522 state.ensure_visible(50, 5);
524 assert_eq!(state.offset, 45); }
526
527 #[test]
528 fn test_ensure_visible_oversized_item() {
529 let mut state = ScrollState::new(10);
530 state.set_content_height(100);
531 state.offset = 0;
532
533 state.ensure_visible(50, 20);
535 assert_eq!(state.offset, 50); }
537
538 #[test]
539 fn test_ensure_visible_already_visible() {
540 let mut state = ScrollState::new(10);
541 state.set_content_height(100);
542 state.offset = 20;
543
544 state.ensure_visible(22, 3);
546 assert_eq!(state.offset, 20); }
548
549 #[test]
550 fn test_scroll_to_ratio() {
551 let mut state = ScrollState::new(10);
552 state.set_content_height(100);
553
554 state.scroll_to_ratio(0.0);
555 assert_eq!(state.offset, 0);
556
557 state.scroll_to_ratio(1.0);
558 assert_eq!(state.offset, 90);
559
560 state.scroll_to_ratio(0.5);
561 assert_eq!(state.offset, 45);
562 }
563
564 #[test]
565 fn test_panel_update_content_height() {
566 let mut panel = ScrollablePanel::new();
567 let items = vec![
568 TestItem { height: 3 },
569 TestItem { height: 5 },
570 TestItem { height: 2 },
571 ];
572
573 panel.update_content_height(&items);
574 assert_eq!(panel.scroll.content_height, 10);
575 }
576
577 #[test]
578 fn test_panel_item_y_offset() {
579 let panel = ScrollablePanel::new();
580 let items = vec![
581 TestItem { height: 3 },
582 TestItem { height: 5 },
583 TestItem { height: 2 },
584 ];
585
586 assert_eq!(panel.item_y_offset(&items, 0), 0);
587 assert_eq!(panel.item_y_offset(&items, 1), 3);
588 assert_eq!(panel.item_y_offset(&items, 2), 8);
589 }
590
591 #[test]
592 fn test_panel_ensure_focused_visible() {
593 let mut panel = ScrollablePanel::with_viewport(5);
594 let items = vec![
595 TestItem { height: 3 },
596 TestItem { height: 3 },
597 TestItem { height: 3 },
598 TestItem { height: 3 },
599 ];
600 panel.update_content_height(&items);
601
602 panel.ensure_focused_visible(&items, 2, None);
604 assert_eq!(panel.scroll.offset, 4);
606 }
607
608 struct TestItemWithRegions {
609 height: u16,
610 regions: Vec<FocusRegion>,
611 }
612
613 impl ScrollItem for TestItemWithRegions {
614 fn height(&self) -> u16 {
615 self.height
616 }
617
618 fn focus_regions(&self) -> Vec<FocusRegion> {
619 self.regions.clone()
620 }
621 }
622
623 #[test]
624 fn test_panel_ensure_focused_visible_with_subfocus() {
625 let mut panel = ScrollablePanel::with_viewport(5);
626 let items = vec![TestItemWithRegions {
627 height: 10,
628 regions: vec![
629 FocusRegion {
630 id: 0,
631 y_offset: 0,
632 height: 1,
633 },
634 FocusRegion {
635 id: 1,
636 y_offset: 3,
637 height: 1,
638 },
639 FocusRegion {
640 id: 2,
641 y_offset: 7,
642 height: 1,
643 },
644 ],
645 }];
646 panel.update_content_height(&items);
647
648 panel.ensure_focused_visible(&items, 0, Some(2));
650 assert_eq!(panel.scroll.offset, 3);
652 }
653}