use ratatui::layout::Rect;
use ratatui::Frame;
use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
use crate::view::theme::Theme;
#[derive(Debug, Clone, Copy)]
pub struct FocusRegion {
pub id: usize,
pub y_offset: u16,
pub height: u16,
}
pub trait ScrollItem {
fn height(&self) -> u16;
fn focus_regions(&self) -> Vec<FocusRegion> {
Vec::new()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrollState {
pub offset: u16,
pub viewport: u16,
pub content_height: u16,
}
impl ScrollState {
pub fn new(viewport: u16) -> Self {
Self {
offset: 0,
viewport,
content_height: 0,
}
}
pub fn set_viewport(&mut self, height: u16) {
self.viewport = height;
self.clamp_offset();
}
pub fn set_content_height(&mut self, height: u16) {
self.content_height = height;
self.clamp_offset();
}
pub fn max_offset(&self) -> u16 {
self.content_height.saturating_sub(self.viewport)
}
fn clamp_offset(&mut self) {
self.offset = self.offset.min(self.max_offset());
}
pub fn ensure_visible(&mut self, y: u16, height: u16) {
if y < self.offset {
self.offset = y;
} else if y + height > self.offset + self.viewport {
if height > self.viewport {
self.offset = y;
} else {
self.offset = y + height - self.viewport;
}
}
self.clamp_offset();
}
pub fn scroll_by(&mut self, delta: i16) {
if delta < 0 {
self.offset = self.offset.saturating_sub((-delta) as u16);
} else {
self.offset = self.offset.saturating_add(delta as u16);
}
self.clamp_offset();
}
pub fn scroll_to_ratio(&mut self, ratio: f32) {
let ratio = ratio.clamp(0.0, 1.0);
self.offset = (ratio * self.max_offset() as f32) as u16;
}
pub fn needs_scrollbar(&self) -> bool {
self.content_height > self.viewport
}
pub fn to_scrollbar_state(&self) -> ScrollbarState {
ScrollbarState::new(
self.content_height as usize,
self.viewport as usize,
self.offset as usize,
)
}
}
#[derive(Debug, Clone)]
pub struct ScrollablePanelLayout<L> {
pub content_area: Rect,
pub scrollbar_area: Option<Rect>,
pub item_layouts: Vec<ItemLayoutInfo<L>>,
}
#[derive(Debug, Clone)]
pub struct ItemLayoutInfo<L> {
pub index: usize,
pub content_y: u16,
pub area: Rect,
pub layout: L,
}
#[derive(Debug, Clone, Copy)]
pub struct RenderInfo {
pub area: Rect,
pub skip_top: u16,
pub index: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ScrollablePanel {
pub scroll: ScrollState,
}
impl ScrollablePanel {
pub fn new() -> Self {
Self {
scroll: ScrollState::default(),
}
}
pub fn with_viewport(viewport: u16) -> Self {
Self {
scroll: ScrollState::new(viewport),
}
}
pub fn set_viewport(&mut self, height: u16) {
self.scroll.set_viewport(height);
}
pub fn viewport_height(&self) -> usize {
self.scroll.viewport as usize
}
pub fn update_content_height<I: ScrollItem>(&mut self, items: &[I]) {
let height: u16 = items.iter().map(|i| i.height()).sum();
self.scroll.set_content_height(height);
}
pub fn item_y_offset<I: ScrollItem>(&self, items: &[I], index: usize) -> u16 {
items[..index].iter().map(|i| i.height()).sum()
}
pub fn ensure_focused_visible<I: ScrollItem>(
&mut self,
items: &[I],
focused_index: usize,
sub_focus: Option<usize>,
) {
if focused_index >= items.len() {
return;
}
let item_y = self.item_y_offset(items, focused_index);
let item = &items[focused_index];
let item_h = item.height();
let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
let regions = item.focus_regions();
if let Some(region) = regions.iter().find(|r| r.id == sub_id) {
(item_y + region.y_offset, region.height)
} else {
(item_y, item_h)
}
} else {
(item_y, item_h)
};
self.scroll.ensure_visible(focus_y, focus_h);
}
pub fn render<I, F, L>(
&self,
frame: &mut Frame,
area: Rect,
items: &[I],
render_item: F,
theme: &Theme,
) -> ScrollablePanelLayout<L>
where
I: ScrollItem,
F: Fn(&mut Frame, RenderInfo, &I) -> L,
{
let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
let content_area = Rect::new(
area.x,
area.y,
area.width.saturating_sub(scrollbar_width),
area.height,
);
let mut layouts = Vec::new();
let mut content_y = 0u16; let mut render_y = area.y;
for (idx, item) in items.iter().enumerate() {
let item_h = item.height();
if content_y + item_h <= self.scroll.offset {
content_y += item_h;
continue;
}
if render_y >= area.y + area.height {
break;
}
let skip_top = self.scroll.offset.saturating_sub(content_y);
let available_h = (area.y + area.height).saturating_sub(render_y);
let visible_h = (item_h - skip_top).min(available_h);
if visible_h > 0 {
let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
let info = RenderInfo {
area: item_area,
skip_top,
index: idx,
};
let layout = render_item(frame, info, item);
layouts.push(ItemLayoutInfo {
index: idx,
content_y,
area: item_area,
layout,
});
}
render_y += visible_h;
content_y += item_h;
}
let scrollbar_area = if self.scroll.needs_scrollbar() {
let sb_area = Rect::new(area.x + content_area.width, area.y, 1, area.height);
let scrollbar_state = self.scroll.to_scrollbar_state();
let scrollbar_colors = ScrollbarColors::from_theme(theme);
render_scrollbar(frame, sb_area, &scrollbar_state, &scrollbar_colors);
Some(sb_area)
} else {
None
};
ScrollablePanelLayout {
content_area,
scrollbar_area,
item_layouts: layouts,
}
}
pub fn render_content_only<I, F, L>(
&self,
frame: &mut Frame,
area: Rect,
items: &[I],
render_item: F,
) -> Vec<ItemLayoutInfo<L>>
where
I: ScrollItem,
F: Fn(&mut Frame, RenderInfo, &I) -> L,
{
let mut layouts = Vec::new();
let mut content_y = 0u16;
let mut render_y = area.y;
for (idx, item) in items.iter().enumerate() {
let item_h = item.height();
if content_y + item_h <= self.scroll.offset {
content_y += item_h;
continue;
}
if render_y >= area.y + area.height {
break;
}
let skip_top = self.scroll.offset.saturating_sub(content_y);
let available_h = (area.y + area.height).saturating_sub(render_y);
let visible_h = (item_h - skip_top).min(available_h);
if visible_h > 0 {
let item_area = Rect::new(area.x, render_y, area.width, visible_h);
let info = RenderInfo {
area: item_area,
skip_top,
index: idx,
};
let layout = render_item(frame, info, item);
layouts.push(ItemLayoutInfo {
index: idx,
content_y,
area: item_area,
layout,
});
}
render_y += visible_h;
content_y += item_h;
}
layouts
}
pub fn scroll_up(&mut self, rows: u16) {
self.scroll.scroll_by(-(rows as i16));
}
pub fn scroll_down(&mut self, rows: u16) {
self.scroll.scroll_by(rows as i16);
}
pub fn scroll_to_ratio(&mut self, ratio: f32) {
self.scroll.scroll_to_ratio(ratio);
}
pub fn offset(&self) -> u16 {
self.scroll.offset
}
pub fn needs_scrollbar(&self) -> bool {
self.scroll.needs_scrollbar()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestItem {
height: u16,
}
impl ScrollItem for TestItem {
fn height(&self) -> u16 {
self.height
}
}
#[test]
fn test_scroll_state_basic() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
assert_eq!(state.viewport, 10);
assert_eq!(state.content_height, 100);
assert_eq!(state.max_offset(), 90);
assert!(state.needs_scrollbar());
}
#[test]
fn test_scroll_state_no_scrollbar_needed() {
let mut state = ScrollState::new(100);
state.set_content_height(50);
assert!(!state.needs_scrollbar());
assert_eq!(state.max_offset(), 0);
}
#[test]
fn test_scroll_by() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
state.scroll_by(5);
assert_eq!(state.offset, 5);
state.scroll_by(-3);
assert_eq!(state.offset, 2);
state.scroll_by(-10);
assert_eq!(state.offset, 0);
state.scroll_by(200);
assert_eq!(state.offset, 90);
}
#[test]
fn test_ensure_visible_above_viewport() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
state.offset = 50;
state.ensure_visible(20, 5);
assert_eq!(state.offset, 20);
}
#[test]
fn test_ensure_visible_below_viewport() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
state.offset = 0;
state.ensure_visible(50, 5);
assert_eq!(state.offset, 45); }
#[test]
fn test_ensure_visible_oversized_item() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
state.offset = 0;
state.ensure_visible(50, 20);
assert_eq!(state.offset, 50); }
#[test]
fn test_ensure_visible_already_visible() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
state.offset = 20;
state.ensure_visible(22, 3);
assert_eq!(state.offset, 20); }
#[test]
fn test_scroll_to_ratio() {
let mut state = ScrollState::new(10);
state.set_content_height(100);
state.scroll_to_ratio(0.0);
assert_eq!(state.offset, 0);
state.scroll_to_ratio(1.0);
assert_eq!(state.offset, 90);
state.scroll_to_ratio(0.5);
assert_eq!(state.offset, 45);
}
#[test]
fn test_panel_update_content_height() {
let mut panel = ScrollablePanel::new();
let items = vec![
TestItem { height: 3 },
TestItem { height: 5 },
TestItem { height: 2 },
];
panel.update_content_height(&items);
assert_eq!(panel.scroll.content_height, 10);
}
#[test]
fn test_panel_item_y_offset() {
let panel = ScrollablePanel::new();
let items = vec![
TestItem { height: 3 },
TestItem { height: 5 },
TestItem { height: 2 },
];
assert_eq!(panel.item_y_offset(&items, 0), 0);
assert_eq!(panel.item_y_offset(&items, 1), 3);
assert_eq!(panel.item_y_offset(&items, 2), 8);
}
#[test]
fn test_panel_ensure_focused_visible() {
let mut panel = ScrollablePanel::with_viewport(5);
let items = vec![
TestItem { height: 3 },
TestItem { height: 3 },
TestItem { height: 3 },
TestItem { height: 3 },
];
panel.update_content_height(&items);
panel.ensure_focused_visible(&items, 2, None);
assert_eq!(panel.scroll.offset, 4);
}
struct TestItemWithRegions {
height: u16,
regions: Vec<FocusRegion>,
}
impl ScrollItem for TestItemWithRegions {
fn height(&self) -> u16 {
self.height
}
fn focus_regions(&self) -> Vec<FocusRegion> {
self.regions.clone()
}
}
#[test]
fn test_panel_ensure_focused_visible_with_subfocus() {
let mut panel = ScrollablePanel::with_viewport(5);
let items = vec![TestItemWithRegions {
height: 10,
regions: vec![
FocusRegion {
id: 0,
y_offset: 0,
height: 1,
},
FocusRegion {
id: 1,
y_offset: 3,
height: 1,
},
FocusRegion {
id: 2,
y_offset: 7,
height: 1,
},
],
}];
panel.update_content_height(&items);
panel.ensure_focused_visible(&items, 0, Some(2));
assert_eq!(panel.scroll.offset, 3);
}
}