Skip to main content

hefesto_widgets/scroll_list/
mod.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    style::Style,
5    widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget, Widget},
6};
7
8use crate::{default_scrollbar, default_scrollbar_state, BORDER_GRAY, DEFAULT_HIGHLIGHT, HIGHLIGHT_SYMBOL};
9
10#[derive(Clone)]
11pub struct ScrollListState {
12    pub list_state: ListState,
13    pub follow: bool,
14}
15
16impl Default for ScrollListState {
17    fn default() -> Self {
18        Self {
19            list_state: ListState::default(),
20            follow: true,
21        }
22    }
23}
24
25impl ScrollListState {
26    pub fn select(&mut self, index: Option<usize>) {
27        self.list_state.select(index);
28    }
29
30    pub fn next(&mut self, item_count: usize) {
31        let i = self.list_state.selected().map_or(0, |s| s + 1);
32        let max = item_count.saturating_sub(1);
33        self.list_state.select(Some(i.min(max)));
34    }
35
36    pub fn previous(&mut self) {
37        let i = self.list_state.selected().map_or(0, |s| s.saturating_sub(1));
38        self.list_state.select(Some(i));
39    }
40
41    pub fn last(&mut self, item_count: usize) {
42        self.list_state.select(Some(item_count.saturating_sub(1)));
43    }
44}
45
46/// Flexible scrollable list backed by `Vec<ListItem>`.
47///
48/// Use `new` / `new_styled` for simple string items,
49/// or `new_list` to pass pre-built `ListItem` values
50/// (e.g. with indentation for tree views).
51pub struct ScrollList<'a> {
52    items: Vec<ListItem<'a>>,
53    highlight_style: Style,
54    highlight_symbol: String,
55    borders: Borders,
56    border_style: Style,
57}
58
59impl<'a> ScrollList<'a> {
60    pub fn new(items: Vec<String>) -> Self {
61        Self {
62            items: items
63                .into_iter()
64                .map(|s| ListItem::new(s))
65                .collect(),
66            highlight_style: DEFAULT_HIGHLIGHT,
67            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
68            borders: Borders::NONE,
69            border_style: Style::new().fg(BORDER_GRAY),
70        }
71    }
72
73    pub fn new_styled(items: Vec<(String, Style)>) -> Self {
74        Self {
75            items: items
76                .into_iter()
77                .map(|(s, style)| ListItem::new(s).style(style))
78                .collect(),
79            highlight_style: DEFAULT_HIGHLIGHT,
80            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
81            borders: Borders::NONE,
82            border_style: Style::new().fg(BORDER_GRAY),
83        }
84    }
85
86    /// Build from pre-constructed `ListItem` values.
87    /// Useful when items need custom layout (indentation, icons, etc.).
88    pub fn new_list(items: Vec<ListItem<'a>>) -> Self {
89        Self {
90            items,
91            highlight_style: DEFAULT_HIGHLIGHT,
92            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
93            borders: Borders::NONE,
94            border_style: Style::new().fg(BORDER_GRAY),
95        }
96    }
97
98    pub fn style(mut self, style: Style) -> Self {
99        for item in &mut self.items {
100            *item = item.clone().style(style);
101        }
102        self
103    }
104
105    pub fn highlight_style(mut self, style: Style) -> Self {
106        self.highlight_style = style;
107        self
108    }
109
110    pub fn highlight_symbol(mut self, symbol: &str) -> Self {
111        self.highlight_symbol = symbol.to_string();
112        self
113    }
114
115    pub fn borders(mut self, borders: Borders) -> Self {
116        self.borders = borders;
117        self
118    }
119
120    pub fn border_style(mut self, style: Style) -> Self {
121        self.border_style = style;
122        self
123    }
124}
125
126impl StatefulWidget for ScrollList<'_> {
127    type State = ScrollListState;
128
129    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
130        if state.follow && !self.items.is_empty() {
131            state
132                .list_state
133                .select(Some(self.items.len().saturating_sub(1)));
134        }
135
136        let inner = if self.borders != Borders::NONE {
137            let block = Block::bordered()
138                .borders(self.borders)
139                .border_style(self.border_style);
140            let inner = block.inner(area);
141            block.render(area, buf);
142            inner
143        } else {
144            area
145        };
146
147        let hchunks = Layout::horizontal([
148            Constraint::Min(1),
149            Constraint::Length(1),
150        ])
151        .split(inner);
152
153        let list_area = hchunks[0];
154        let scrollbar_area = hchunks[1];
155        let total = self.items.len();
156        let mut list = List::new(self.items)
157            .highlight_style(self.highlight_style)
158            .scroll_padding(1);
159        if !self.highlight_symbol.is_empty() {
160            list = list.highlight_symbol(self.highlight_symbol);
161        }
162
163        StatefulWidget::render(list, list_area, buf, &mut state.list_state);
164
165        if state.list_state.selected().is_none() && total > 0 {
166            state.list_state.select(Some(0));
167        }
168
169        if total > list_area.height as usize {
170            let visible = list_area.height as usize;
171            let max_offset = total.saturating_sub(visible);
172            let offset = state.list_state.offset();
173            let position = if max_offset > 0 {
174                offset * (total - 1) / max_offset
175            } else {
176                0
177            };
178            let mut scrollbar_state = default_scrollbar_state(total, position);
179            StatefulWidget::render(
180                default_scrollbar(),
181                scrollbar_area,
182                buf,
183                &mut scrollbar_state,
184            );
185        }
186    }
187}
188#[cfg(test)]
189mod tests;