Skip to main content

tui_widget_list/
state.rs

1use std::collections::HashMap;
2
3use ratatui_core::layout::Rect;
4use ratatui_widgets::scrollbar::ScrollbarState;
5
6use crate::{ListBuildContext, ListBuilder, ScrollAxis, ScrollDirection};
7
8#[allow(clippy::module_name_repetitions)]
9#[derive(Debug, Clone)]
10pub struct ListState {
11    /// The selected item. If `None`, no item is currently selected.
12    pub selected: Option<usize>,
13
14    /// The total number of elements in the list. This is necessary to correctly
15    /// handle item selection.
16    pub(crate) num_elements: usize,
17
18    /// Indicates if infinite scrolling is enabled.
19    /// If true, calling `next` on the last element returns the first,
20    /// and calling `previous` on the first returns the last.
21    ///
22    /// True by default.
23    pub(crate) infinite_scrolling: bool,
24
25    /// Scroll offset within the currently selected item. When an item is larger
26    /// than the viewport, this tracks how far we've scrolled into it.
27    pub(crate) item_scroll: u16,
28
29    /// The state for the viewport. Keeps track which item to show
30    /// first and how much it is truncated.
31    pub(crate) view_state: ViewState,
32
33    /// The scrollbar state. This is only used if the view is
34    /// initialzed with a scrollbar.
35    pub(crate) scrollbar_state: ScrollbarState,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct ViewState {
40    /// The index of the first item displayed on the screen.
41    pub(crate) offset: usize,
42
43    /// The truncation in rows/columns of the first item displayed on the screen.
44    pub(crate) first_truncated: u16,
45
46    /// Cached visible sizes from the last render: map from item index to its visible main-axis size.
47    /// This avoids re-evaluating the builder for hit testing and other post-render queries.
48    pub(crate) visible_main_axis_sizes: HashMap<usize, u16>,
49
50    /// The inner area used during the last render (after applying the optional block).
51    pub(crate) inner_area: Rect,
52
53    /// The scroll axis used during the last render.
54    pub(crate) scroll_axis: ScrollAxis,
55
56    /// The scroll direction used during the last render.
57    pub(crate) scroll_direction: ScrollDirection,
58
59    /// The viewport's main axis size from the last render.
60    pub(crate) last_main_axis_size: u16,
61
62    /// Full (untruncated) main-axis sizes of items from the last render.
63    pub(crate) total_main_axis_sizes: HashMap<usize, u16>,
64}
65
66impl Default for ViewState {
67    fn default() -> Self {
68        Self {
69            offset: 0,
70            first_truncated: 0,
71            visible_main_axis_sizes: HashMap::new(),
72            inner_area: Rect::default(),
73            scroll_axis: ScrollAxis::Vertical,
74            scroll_direction: ScrollDirection::Forward,
75            last_main_axis_size: 0,
76            total_main_axis_sizes: HashMap::new(),
77        }
78    }
79}
80
81impl Default for ListState {
82    fn default() -> Self {
83        Self {
84            selected: None,
85            num_elements: 0,
86            infinite_scrolling: true,
87            item_scroll: 0,
88            view_state: ViewState::default(),
89            scrollbar_state: ScrollbarState::new(0).position(0),
90        }
91    }
92}
93
94impl ListState {
95    /// Creates a new `ListState` with the given initial index.
96    ///
97    /// # Example
98    /// ```rust
99    /// # use tui_widget_list::ListState;
100    ///
101    /// let list_state = ListState::new_with_index(Some(1));
102    ///
103    /// assert_eq!(list_state.selected, Some(1));
104    /// ```
105    pub fn new_with_index(index: Option<usize>) -> Self {
106        let mut state = Self::default();
107        state.select(index);
108        state
109    }
110
111    pub(crate) fn set_infinite_scrolling(&mut self, infinite_scrolling: bool) {
112        self.infinite_scrolling = infinite_scrolling;
113    }
114
115    /// Returns the index of the currently selected item, if any.
116    #[must_use]
117    #[deprecated(since = "0.9.0", note = "Use ListState's selected field instead.")]
118    pub fn selected(&self) -> Option<usize> {
119        self.selected
120    }
121
122    /// Selects an item by its index.
123    pub fn select(&mut self, index: Option<usize>) {
124        self.selected = index;
125        self.item_scroll = 0;
126        if index.is_none() {
127            self.view_state.offset = 0;
128            self.scrollbar_state = self.scrollbar_state.position(0);
129        }
130    }
131
132    /// Selects the next element of the list. If `inifinite_scrolling`
133    /// is true, calling next on the last element selects the first.
134    ///
135    /// # Example
136    ///
137    /// ```rust
138    /// use tui_widget_list::ListState;
139    ///
140    /// let mut list_state = ListState::default();
141    /// list_state.next();
142    /// ```
143    pub fn next(&mut self) {
144        if self.num_elements == 0 {
145            return;
146        }
147        // If the current item overflows the viewport, scroll within it first
148        if let Some(selected) = self.selected {
149            let overflow = self.item_overflow(selected);
150            if overflow > 0 && self.item_scroll < overflow {
151                self.item_scroll += 1;
152                return;
153            }
154        }
155        let i = match self.selected {
156            Some(i) => {
157                if i >= self.num_elements - 1 {
158                    if self.infinite_scrolling {
159                        0
160                    } else {
161                        i
162                    }
163                } else {
164                    i + 1
165                }
166            }
167            None => 0,
168        };
169        self.select(Some(i));
170    }
171
172    /// Selects the previous element of the list. If `infinite_scrolling`
173    /// is true, calling previous on the first element selects the last.
174    ///
175    /// # Example
176    ///
177    /// ```rust
178    /// use tui_widget_list::ListState;
179    ///
180    /// let mut list_state = ListState::default();
181    /// list_state.previous();
182    /// ```
183    pub fn previous(&mut self) {
184        if self.num_elements == 0 {
185            return;
186        }
187        // If the current item overflows the viewport, scroll back within it first
188        if self.item_scroll > 0 {
189            self.item_scroll -= 1;
190            return;
191        }
192        let i = match self.selected {
193            Some(i) => {
194                if i == 0 {
195                    if self.infinite_scrolling {
196                        self.num_elements - 1
197                    } else {
198                        i
199                    }
200                } else {
201                    i - 1
202                }
203            }
204            None => self.num_elements - 1,
205        };
206        // If the previous item overflows the viewport, start at its bottom
207        let overflow = self.item_overflow(i);
208        if overflow > 0 {
209            self.selected = Some(i);
210            self.item_scroll = overflow;
211            return;
212        }
213        self.select(Some(i));
214    }
215
216    /// Returns the index of the first item currently displayed on the screen.
217    #[must_use]
218    pub fn scroll_offset_index(&self) -> usize {
219        self.view_state.offset
220    }
221
222    /// Returns the number of rows/columns of the first visible item that are scrolled off the top/left.
223    ///
224    /// When the first visible item is partially scrolled out of view, this returns how many
225    /// rows (for vertical lists) or columns (for horizontal lists) are hidden above/left of
226    /// the viewport. Returns 0 if the first visible item is fully visible.
227    ///
228    /// # Example
229    ///
230    /// If message #5 is the first visible item but its first 2 rows are scrolled off the top,
231    /// this returns 2. Combined with `scroll_offset_index()`, you can calculate the exact
232    /// scroll position in pixels/rows.
233    #[must_use]
234    pub fn scroll_truncation(&self) -> u16 {
235        self.view_state.first_truncated
236    }
237
238    /// Updates the number of elements that are present in the list.
239    pub(crate) fn set_num_elements(&mut self, num_elements: usize) {
240        self.num_elements = num_elements;
241    }
242
243    /// Updates the current scrollbar content length and position.
244    pub(crate) fn update_scrollbar_state<T>(
245        &mut self,
246        builder: &ListBuilder<T>,
247        item_count: usize,
248        main_axis_size: u16,
249        cross_axis_size: u16,
250        scroll_axis: ScrollAxis,
251    ) {
252        let mut max_scrollbar_position = 0;
253        let mut cumulative_size = 0;
254
255        for index in (0..item_count).rev() {
256            let context = ListBuildContext {
257                index,
258                is_selected: self.selected == Some(index),
259                scroll_axis,
260                cross_axis_size,
261            };
262            let (_, widget_size) = builder.call_closure(&context);
263            cumulative_size += widget_size;
264
265            if cumulative_size > main_axis_size {
266                max_scrollbar_position = index + 1;
267                break;
268            }
269        }
270
271        self.scrollbar_state = self.scrollbar_state.content_length(max_scrollbar_position);
272        self.scrollbar_state = self.scrollbar_state.position(self.view_state.offset);
273    }
274
275    /// Replace the cached visible sizes with a new map computed during render.
276    /// The values should be the actually visible size (after truncation) along the main axis.
277    pub(crate) fn set_visible_main_axis_sizes(&mut self, sizes: HashMap<usize, u16>) {
278        self.view_state.visible_main_axis_sizes = sizes;
279    }
280
281    /// Get a reference to the cached visible sizes map from the last render.
282    #[must_use]
283    pub(crate) fn visible_main_axis_sizes(&self) -> &HashMap<usize, u16> {
284        &self.view_state.visible_main_axis_sizes
285    }
286
287    /// Set the inner area used during the last render.
288    pub(crate) fn set_inner_area(&mut self, inner_area: Rect) {
289        self.view_state.inner_area = inner_area;
290    }
291
292    /// Get the inner area used during the last render.
293    #[must_use]
294    pub(crate) fn inner_area(&self) -> Rect {
295        self.view_state.inner_area
296    }
297
298    /// Set the scroll axis used during the last render.
299    pub(crate) fn set_scroll_axis(&mut self, scroll_axis: ScrollAxis) {
300        self.view_state.scroll_axis = scroll_axis;
301    }
302
303    /// Get the scroll axis used during the last render.
304    #[must_use]
305    pub(crate) fn last_scroll_axis(&self) -> ScrollAxis {
306        self.view_state.scroll_axis
307    }
308
309    /// Set the scroll direction used during the last render.
310    pub(crate) fn set_scroll_direction(&mut self, scroll_direction: ScrollDirection) {
311        self.view_state.scroll_direction = scroll_direction;
312    }
313
314    /// Get the scroll direction used during the last render.
315    #[must_use]
316    pub(crate) fn last_scroll_direction(&self) -> ScrollDirection {
317        self.view_state.scroll_direction
318    }
319
320    /// Returns how many rows/cols of the item extend beyond the viewport,
321    /// or 0 if the item fits or its size is not cached.
322    fn item_overflow(&self, index: usize) -> u16 {
323        self.view_state
324            .total_main_axis_sizes
325            .get(&index)
326            .map(|&total| total.saturating_sub(self.view_state.last_main_axis_size))
327            .unwrap_or(0)
328    }
329
330    /// Set the viewport's main axis size from the last render.
331    pub(crate) fn set_last_main_axis_size(&mut self, size: u16) {
332        self.view_state.last_main_axis_size = size;
333    }
334
335    /// Set the full (untruncated) main-axis sizes of items from the last render.
336    pub(crate) fn set_total_main_axis_sizes(&mut self, sizes: HashMap<usize, u16>) {
337        self.view_state.total_main_axis_sizes = sizes;
338    }
339
340    /// Get the scroll offset within the currently selected item.
341    #[must_use]
342    pub(crate) fn item_scroll(&self) -> u16 {
343        self.item_scroll
344    }
345}