Skip to main content

slt/widgets/
collections.rs

1/// State for a selectable list widget.
2///
3/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
4/// keys (and `k`/`j`) move the selection when the widget is focused.
5#[derive(Debug, Clone, Default)]
6pub struct ListState {
7    /// The list items as display strings.
8    pub items: Vec<String>,
9    /// Index of the currently selected item.
10    pub selected: usize,
11    /// Case-insensitive substring filter applied to list items.
12    pub filter: String,
13    view_indices: Vec<usize>,
14}
15
16impl ListState {
17    /// Create a list with the given items. The first item is selected initially.
18    pub fn new(items: Vec<impl Into<String>>) -> Self {
19        let len = items.len();
20        Self {
21            items: items.into_iter().map(Into::into).collect(),
22            selected: 0,
23            filter: String::new(),
24            view_indices: (0..len).collect(),
25        }
26    }
27
28    /// Replace the list items and rebuild the view index.
29    ///
30    /// Use this instead of assigning `items` directly to ensure the internal
31    /// filter/view state stays consistent.
32    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
33        self.items = items.into_iter().map(Into::into).collect();
34        self.selected = self.selected.min(self.items.len().saturating_sub(1));
35        self.rebuild_view();
36    }
37
38    /// Set the filter string. Multiple space-separated tokens are AND'd
39    /// together — all tokens must match across any cell in the same row.
40    /// Empty string disables filtering.
41    pub fn set_filter(&mut self, filter: impl Into<String>) {
42        self.filter = filter.into();
43        self.rebuild_view();
44    }
45
46    /// Returns indices of items visible after filtering.
47    pub fn visible_indices(&self) -> &[usize] {
48        &self.view_indices
49    }
50
51    /// Get the currently selected item text, or `None` if the list is empty.
52    pub fn selected_item(&self) -> Option<&str> {
53        let data_idx = *self.view_indices.get(self.selected)?;
54        self.items.get(data_idx).map(String::as_str)
55    }
56
57    fn rebuild_view(&mut self) {
58        let tokens: Vec<String> = self
59            .filter
60            .split_whitespace()
61            .map(|t| t.to_lowercase())
62            .collect();
63        self.view_indices = if tokens.is_empty() {
64            (0..self.items.len()).collect()
65        } else {
66            (0..self.items.len())
67                .filter(|&i| {
68                    tokens
69                        .iter()
70                        .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
71                })
72                .collect()
73        };
74        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
75            self.selected = self.view_indices.len() - 1;
76        }
77    }
78}
79
80/// State for a file picker widget.
81///
82/// Tracks the current directory listing, filtering options, and selected file.
83#[derive(Debug, Clone)]
84pub struct FilePickerState {
85    /// Current directory being browsed.
86    pub current_dir: PathBuf,
87    /// Visible entries in the current directory.
88    pub entries: Vec<FileEntry>,
89    /// Selected entry index in `entries`.
90    pub selected: usize,
91    /// Currently selected file path, if any.
92    pub selected_file: Option<PathBuf>,
93    /// Whether dotfiles are included in the listing.
94    pub show_hidden: bool,
95    /// Allowed file extensions (lowercase, no leading dot).
96    pub extensions: Vec<String>,
97    /// Whether the directory listing needs refresh.
98    pub dirty: bool,
99}
100
101/// A directory entry shown by [`FilePickerState`].
102#[derive(Debug, Clone, Default)]
103pub struct FileEntry {
104    /// File or directory name.
105    pub name: String,
106    /// Full path to the entry.
107    pub path: PathBuf,
108    /// Whether this entry is a directory.
109    pub is_dir: bool,
110    /// File size in bytes (0 for directories).
111    pub size: u64,
112}
113
114impl FilePickerState {
115    /// Create a file picker rooted at `dir`.
116    pub fn new(dir: impl Into<PathBuf>) -> Self {
117        Self {
118            current_dir: dir.into(),
119            entries: Vec::new(),
120            selected: 0,
121            selected_file: None,
122            show_hidden: false,
123            extensions: Vec::new(),
124            dirty: true,
125        }
126    }
127
128    /// Configure whether hidden files should be shown.
129    pub fn show_hidden(mut self, show: bool) -> Self {
130        self.show_hidden = show;
131        self.dirty = true;
132        self
133    }
134
135    /// Restrict visible files to the provided extensions.
136    pub fn extensions(mut self, exts: &[&str]) -> Self {
137        self.extensions = exts
138            .iter()
139            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
140            .filter(|ext| !ext.is_empty())
141            .collect();
142        self.dirty = true;
143        self
144    }
145
146    /// Return the currently selected file path.
147    pub fn selected(&self) -> Option<&PathBuf> {
148        self.selected_file.as_ref()
149    }
150
151    /// Re-scan the current directory and rebuild entries.
152    pub fn refresh(&mut self) {
153        let mut entries = Vec::new();
154
155        if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
156            for dir_entry in read_dir.flatten() {
157                let name = dir_entry.file_name().to_string_lossy().to_string();
158                if !self.show_hidden && name.starts_with('.') {
159                    continue;
160                }
161
162                let Ok(file_type) = dir_entry.file_type() else {
163                    continue;
164                };
165                if file_type.is_symlink() {
166                    continue;
167                }
168
169                let path = dir_entry.path();
170                let is_dir = file_type.is_dir();
171
172                if !is_dir && !self.extensions.is_empty() {
173                    let ext = path
174                        .extension()
175                        .and_then(|e| e.to_str())
176                        .map(|e| e.to_ascii_lowercase());
177                    let Some(ext) = ext else {
178                        continue;
179                    };
180                    if !self.extensions.iter().any(|allowed| allowed == &ext) {
181                        continue;
182                    }
183                }
184
185                let size = if is_dir {
186                    0
187                } else {
188                    fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
189                };
190
191                entries.push(FileEntry {
192                    name,
193                    path,
194                    is_dir,
195                    size,
196                });
197            }
198        }
199
200        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
201            (true, false) => std::cmp::Ordering::Less,
202            (false, true) => std::cmp::Ordering::Greater,
203            _ => a
204                .name
205                .to_ascii_lowercase()
206                .cmp(&b.name.to_ascii_lowercase())
207                .then_with(|| a.name.cmp(&b.name)),
208        });
209
210        self.entries = entries;
211        if self.entries.is_empty() {
212            self.selected = 0;
213        } else {
214            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
215        }
216        self.dirty = false;
217    }
218}
219
220impl Default for FilePickerState {
221    fn default() -> Self {
222        Self::new(".")
223    }
224}
225
226/// State for a tab navigation widget.
227///
228/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
229/// keys cycle through tabs when the widget is focused.
230#[derive(Debug, Clone, Default)]
231pub struct TabsState {
232    /// The tab labels displayed in the bar.
233    pub labels: Vec<String>,
234    /// Index of the currently active tab.
235    pub selected: usize,
236}
237
238impl TabsState {
239    /// Create tabs with the given labels. The first tab is active initially.
240    pub fn new(labels: Vec<impl Into<String>>) -> Self {
241        Self {
242            labels: labels.into_iter().map(Into::into).collect(),
243            selected: 0,
244        }
245    }
246
247    /// Get the currently selected tab label, or `None` if there are no tabs.
248    pub fn selected_label(&self) -> Option<&str> {
249        self.labels.get(self.selected).map(String::as_str)
250    }
251}
252
253/// State for a data table widget.
254///
255/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
256/// keys move the row selection when the widget is focused. Column widths are
257/// computed automatically from header and cell content.
258#[derive(Debug, Clone)]
259pub struct TableState {
260    /// Column header labels.
261    pub headers: Vec<String>,
262    /// Table rows, each a `Vec` of cell strings.
263    pub rows: Vec<Vec<String>>,
264    /// Index of the currently selected row.
265    pub selected: usize,
266    column_widths: Vec<u32>,
267    widths_dirty: bool,
268    /// Sorted column index (`None` means no sorting).
269    pub sort_column: Option<usize>,
270    /// Sort direction (`true` for ascending).
271    pub sort_ascending: bool,
272    /// Case-insensitive substring filter applied across all cells.
273    pub filter: String,
274    /// Current page (0-based) when pagination is enabled.
275    pub page: usize,
276    /// Rows per page (`0` disables pagination).
277    pub page_size: usize,
278    /// Whether alternating row backgrounds are enabled.
279    pub zebra: bool,
280    view_indices: Vec<usize>,
281    row_search_cache: Vec<String>,
282    filter_tokens: Vec<String>,
283}
284
285impl Default for TableState {
286    fn default() -> Self {
287        Self {
288            headers: Vec::new(),
289            rows: Vec::new(),
290            selected: 0,
291            column_widths: Vec::new(),
292            widths_dirty: true,
293            sort_column: None,
294            sort_ascending: true,
295            filter: String::new(),
296            page: 0,
297            page_size: 0,
298            zebra: false,
299            view_indices: Vec::new(),
300            row_search_cache: Vec::new(),
301            filter_tokens: Vec::new(),
302        }
303    }
304}
305
306impl TableState {
307    /// Create a table with headers and rows. Column widths are computed immediately.
308    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
309        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
310        let rows: Vec<Vec<String>> = rows
311            .into_iter()
312            .map(|r| r.into_iter().map(Into::into).collect())
313            .collect();
314        let mut state = Self {
315            headers,
316            rows,
317            selected: 0,
318            column_widths: Vec::new(),
319            widths_dirty: true,
320            sort_column: None,
321            sort_ascending: true,
322            filter: String::new(),
323            page: 0,
324            page_size: 0,
325            zebra: false,
326            view_indices: Vec::new(),
327            row_search_cache: Vec::new(),
328            filter_tokens: Vec::new(),
329        };
330        state.rebuild_row_search_cache();
331        state.rebuild_view();
332        state.recompute_widths();
333        state
334    }
335
336    /// Replace all rows, preserving the selection index if possible.
337    ///
338    /// If the current selection is beyond the new row count, it is clamped to
339    /// the last row.
340    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
341        self.rows = rows
342            .into_iter()
343            .map(|r| r.into_iter().map(Into::into).collect())
344            .collect();
345        self.rebuild_row_search_cache();
346        self.rebuild_view();
347    }
348
349    /// Sort by a specific column index. If already sorted by this column, toggles direction.
350    pub fn toggle_sort(&mut self, column: usize) {
351        if self.sort_column == Some(column) {
352            self.sort_ascending = !self.sort_ascending;
353        } else {
354            self.sort_column = Some(column);
355            self.sort_ascending = true;
356        }
357        self.rebuild_view();
358    }
359
360    /// Sort by column without toggling (always sets to ascending first).
361    pub fn sort_by(&mut self, column: usize) {
362        if self.sort_column == Some(column) && self.sort_ascending {
363            return;
364        }
365        self.sort_column = Some(column);
366        self.sort_ascending = true;
367        self.rebuild_view();
368    }
369
370    /// Set the filter string. Multiple space-separated tokens are AND'd
371    /// together — all tokens must match across any cell in the same row.
372    /// Empty string disables filtering.
373    pub fn set_filter(&mut self, filter: impl Into<String>) {
374        let filter = filter.into();
375        if self.filter == filter {
376            return;
377        }
378        self.filter = filter;
379        self.filter_tokens = Self::tokenize_filter(&self.filter);
380        self.page = 0;
381        self.rebuild_view();
382    }
383
384    /// Clear sorting.
385    pub fn clear_sort(&mut self) {
386        if self.sort_column.is_none() && self.sort_ascending {
387            return;
388        }
389        self.sort_column = None;
390        self.sort_ascending = true;
391        self.rebuild_view();
392    }
393
394    /// Move to the next page. Does nothing if already on the last page.
395    pub fn next_page(&mut self) {
396        if self.page_size == 0 {
397            return;
398        }
399        let last_page = self.total_pages().saturating_sub(1);
400        self.page = (self.page + 1).min(last_page);
401    }
402
403    /// Move to the previous page. Does nothing if already on page 0.
404    pub fn prev_page(&mut self) {
405        self.page = self.page.saturating_sub(1);
406    }
407
408    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
409    pub fn total_pages(&self) -> usize {
410        if self.page_size == 0 {
411            return 1;
412        }
413
414        let len = self.view_indices.len();
415        if len == 0 {
416            1
417        } else {
418            len.div_ceil(self.page_size)
419        }
420    }
421
422    /// Get the visible row indices after filtering and sorting (used internally by table()).
423    pub fn visible_indices(&self) -> &[usize] {
424        &self.view_indices
425    }
426
427    /// Get the currently selected row data, or `None` if the table is empty.
428    pub fn selected_row(&self) -> Option<&[String]> {
429        if self.view_indices.is_empty() {
430            return None;
431        }
432        let data_idx = self.view_indices.get(self.selected)?;
433        self.rows.get(*data_idx).map(|r| r.as_slice())
434    }
435
436    /// Recompute view_indices based on current sort + filter settings.
437    fn rebuild_view(&mut self) {
438        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
439
440        if !self.filter_tokens.is_empty() {
441            indices.retain(|&idx| {
442                let searchable = match self.row_search_cache.get(idx) {
443                    Some(row) => row,
444                    None => return false,
445                };
446                self.filter_tokens
447                    .iter()
448                    .all(|token| searchable.contains(token.as_str()))
449            });
450        }
451
452        if let Some(column) = self.sort_column {
453            indices.sort_by(|a, b| {
454                let left = self
455                    .rows
456                    .get(*a)
457                    .and_then(|row| row.get(column))
458                    .map(String::as_str)
459                    .unwrap_or("");
460                let right = self
461                    .rows
462                    .get(*b)
463                    .and_then(|row| row.get(column))
464                    .map(String::as_str)
465                    .unwrap_or("");
466
467                match (left.parse::<f64>(), right.parse::<f64>()) {
468                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
469                    _ => left
470                        .chars()
471                        .flat_map(char::to_lowercase)
472                        .cmp(right.chars().flat_map(char::to_lowercase)),
473                }
474            });
475
476            if !self.sort_ascending {
477                indices.reverse();
478            }
479        }
480
481        self.view_indices = indices;
482
483        if self.page_size > 0 {
484            self.page = self.page.min(self.total_pages().saturating_sub(1));
485        } else {
486            self.page = 0;
487        }
488
489        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
490        self.widths_dirty = true;
491    }
492
493    fn rebuild_row_search_cache(&mut self) {
494        self.row_search_cache = self
495            .rows
496            .iter()
497            .map(|row| {
498                let mut searchable = String::new();
499                for (idx, cell) in row.iter().enumerate() {
500                    if idx > 0 {
501                        searchable.push('\n');
502                    }
503                    searchable.extend(cell.chars().flat_map(char::to_lowercase));
504                }
505                searchable
506            })
507            .collect();
508        self.filter_tokens = Self::tokenize_filter(&self.filter);
509        self.widths_dirty = true;
510    }
511
512    fn tokenize_filter(filter: &str) -> Vec<String> {
513        filter
514            .split_whitespace()
515            .map(|t| t.to_lowercase())
516            .collect()
517    }
518
519    pub(crate) fn recompute_widths(&mut self) {
520        let col_count = self.headers.len();
521        self.column_widths = vec![0u32; col_count];
522        for (i, header) in self.headers.iter().enumerate() {
523            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
524            if self.sort_column == Some(i) {
525                width += 2;
526            }
527            self.column_widths[i] = width;
528        }
529        for row in &self.rows {
530            for (i, cell) in row.iter().enumerate() {
531                if i < col_count {
532                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
533                    self.column_widths[i] = self.column_widths[i].max(w);
534                }
535            }
536        }
537        self.widths_dirty = false;
538    }
539
540    pub(crate) fn column_widths(&self) -> &[u32] {
541        &self.column_widths
542    }
543
544    pub(crate) fn is_dirty(&self) -> bool {
545        self.widths_dirty
546    }
547}
548
549/// State for a scrollable container.
550///
551/// Pass a mutable reference to `Context::scrollable` each frame. The context
552/// updates `offset` and the internal bounds automatically based on mouse wheel
553/// and drag events.
554#[derive(Debug, Clone)]
555pub struct ScrollState {
556    /// Current vertical scroll offset in rows.
557    pub offset: usize,
558    content_height: u32,
559    viewport_height: u32,
560}
561
562impl ScrollState {
563    /// Create scroll state starting at offset 0.
564    pub fn new() -> Self {
565        Self {
566            offset: 0,
567            content_height: 0,
568            viewport_height: 0,
569        }
570    }
571
572    /// Check if scrolling upward is possible (offset is greater than 0).
573    pub fn can_scroll_up(&self) -> bool {
574        self.offset > 0
575    }
576
577    /// Check if scrolling downward is possible (content extends below the viewport).
578    pub fn can_scroll_down(&self) -> bool {
579        (self.offset as u32) + self.viewport_height < self.content_height
580    }
581
582    /// Get the total content height in rows.
583    pub fn content_height(&self) -> u32 {
584        self.content_height
585    }
586
587    /// Get the viewport height in rows.
588    pub fn viewport_height(&self) -> u32 {
589        self.viewport_height
590    }
591
592    /// Get the scroll progress as a ratio in [0.0, 1.0].
593    pub fn progress(&self) -> f32 {
594        let max = self.content_height.saturating_sub(self.viewport_height);
595        if max == 0 {
596            0.0
597        } else {
598            self.offset as f32 / max as f32
599        }
600    }
601
602    /// Scroll up by the given number of rows, clamped to 0.
603    pub fn scroll_up(&mut self, amount: usize) {
604        self.offset = self.offset.saturating_sub(amount);
605    }
606
607    /// Scroll down by the given number of rows, clamped to the maximum offset.
608    pub fn scroll_down(&mut self, amount: usize) {
609        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
610        self.offset = (self.offset + amount).min(max_offset);
611    }
612
613    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
614        self.content_height = content_height;
615        self.viewport_height = viewport_height;
616    }
617}
618
619impl Default for ScrollState {
620    fn default() -> Self {
621        Self::new()
622    }
623}