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