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    /// Top *item* index of the visible viewport for `virtual_list`. Defaults to
14    /// `0` and is clamped each frame so `selected` stays inside the viewport
15    /// without forcing the cursor to the bottom row. For the uniform
16    /// fixed-height path this equals the top row; with per-item heights set
17    /// (see [`set_item_heights`](ListState::set_item_heights)) the cumulative
18    /// row offset is tracked separately in `viewport_row_offset`.
19    pub(crate) viewport_offset: usize,
20    /// Cumulative top-row offset of the visible viewport for
21    /// `virtual_list_variable`. Tracks the total row height of the items above
22    /// `viewport_offset` so row-accurate scrolling and edge clipping work when
23    /// per-item heights are present. Equals `viewport_offset` only when every
24    /// item is one row tall.
25    pub(crate) viewport_row_offset: usize,
26    /// Optional per-item row heights (each clamped to `>= 1`). When present,
27    /// [`Context::virtual_list_variable`](crate::Context::virtual_list_variable)
28    /// uses them to compute a row-accurate visible range; when `None` the
29    /// uniform one-row-per-item model is used.
30    item_heights: Option<Vec<u32>>,
31    /// Cached prefix sum of `item_heights`, rebuilt lazily when `heights_dirty`.
32    /// `row_prefix[i]` is the total number of rows occupied by items `0..i`, so
33    /// `row_prefix.len() == items.len() + 1` after `ensure_row_prefix`.
34    row_prefix: Vec<u32>,
35    /// Dirty flag gating `row_prefix` rebuilds; set whenever items or heights
36    /// change so a stale prefix sum is never consumed.
37    heights_dirty: bool,
38    view_indices: Vec<usize>,
39    /// Lowercase cache parallel to `items`, rebuilt only on `set_items` / `new`.
40    /// Mirrors the `row_search_cache` pattern in `TableState`.
41    item_search_cache: Vec<String>,
42}
43
44impl ListState {
45    /// Create a list with the given items. The first item is selected initially.
46    pub fn new(items: Vec<impl Into<String>>) -> Self {
47        let items: Vec<String> = items.into_iter().map(Into::into).collect();
48        let item_search_cache: Vec<String> =
49            items.iter().map(|s| s.to_lowercase()).collect();
50        let len = items.len();
51        Self {
52            items,
53            selected: 0,
54            filter: String::new(),
55            viewport_offset: 0,
56            viewport_row_offset: 0,
57            item_heights: None,
58            row_prefix: Vec::new(),
59            heights_dirty: true,
60            view_indices: (0..len).collect(),
61            item_search_cache,
62        }
63    }
64
65    /// Replace the list items and rebuild the view index.
66    ///
67    /// Use this instead of assigning `items` directly to ensure the internal
68    /// filter/view state stays consistent.
69    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
70        self.items = items.into_iter().map(Into::into).collect();
71        self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
72        self.selected = self.selected.min(self.items.len().saturating_sub(1));
73        // Item count changed, so any cached prefix sum is stale.
74        self.heights_dirty = true;
75        self.rebuild_view();
76    }
77
78    /// Provide a per-item row height (each clamped to `>= 1`) and return `self`.
79    ///
80    /// Enables variable-height virtualization via
81    /// [`Context::virtual_list_variable`](crate::Context::virtual_list_variable),
82    /// the chat/feed bubble use case where each item occupies a different
83    /// number of rows. Each entry corresponds to the item at the same index;
84    /// missing entries fall back to a height of `1`.
85    ///
86    /// # Example
87    ///
88    /// ```no_run
89    /// use slt::widgets::ListState;
90    ///
91    /// let state = ListState::new(vec!["short", "a\nthree\nline bubble", "ok"])
92    ///     .with_item_heights(vec![1, 3, 1]);
93    /// # let _ = state;
94    /// ```
95    ///
96    /// Available since `0.21.0`.
97    pub fn with_item_heights(mut self, heights: Vec<u32>) -> Self {
98        self.set_item_heights(heights);
99        self
100    }
101
102    /// Set per-item row heights (each clamped to `>= 1`).
103    ///
104    /// Marks the cached prefix sum dirty so it is rebuilt on the next render.
105    /// Length should match [`items`](ListState::items); missing entries fall
106    /// back to a height of `1` and extra entries are ignored.
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// use slt::widgets::ListState;
112    ///
113    /// let mut state = ListState::new(vec!["a", "b", "c"]);
114    /// state.set_item_heights(vec![2, 1, 4]);
115    /// # let _ = state;
116    /// ```
117    ///
118    /// Available since `0.21.0`.
119    pub fn set_item_heights(&mut self, heights: Vec<u32>) {
120        self.item_heights = Some(heights.into_iter().map(|h| h.max(1)).collect());
121        self.heights_dirty = true;
122    }
123
124    /// Clear per-item heights, reverting to the uniform one-row-per-item model.
125    ///
126    /// After this call [`Context::virtual_list_variable`](crate::Context::virtual_list_variable)
127    /// behaves identically to [`Context::virtual_list`](crate::Context::virtual_list).
128    ///
129    /// # Example
130    ///
131    /// ```no_run
132    /// use slt::widgets::ListState;
133    ///
134    /// let mut state = ListState::new(vec!["a", "b"]).with_item_heights(vec![3, 2]);
135    /// state.clear_item_heights();
136    /// # let _ = state;
137    /// ```
138    ///
139    /// Available since `0.21.0`.
140    pub fn clear_item_heights(&mut self) {
141        self.item_heights = None;
142        self.heights_dirty = true;
143    }
144
145    /// Whether per-item heights are currently set.
146    pub(crate) fn has_item_heights(&self) -> bool {
147        self.item_heights.is_some()
148    }
149
150    /// Height of item `idx` in rows (`1` when no per-item heights are set or the
151    /// index has no explicit height).
152    pub(crate) fn item_height(&self, idx: usize) -> u32 {
153        self.item_heights
154            .as_ref()
155            .and_then(|h| h.get(idx).copied())
156            .unwrap_or(1)
157    }
158
159    /// Rebuild `row_prefix` if dirty. After this call `row_prefix[i]` is the
160    /// total number of rows occupied by items `0..i`, and
161    /// `row_prefix.len() == items.len() + 1`. Rebuild is `O(n)` and skipped
162    /// entirely when `heights_dirty` is `false`.
163    pub(crate) fn ensure_row_prefix(&mut self) {
164        if !self.heights_dirty && self.row_prefix.len() == self.items.len() + 1 {
165            return;
166        }
167        let n = self.items.len();
168        self.row_prefix.clear();
169        self.row_prefix.reserve(n + 1);
170        let mut acc = 0u32;
171        self.row_prefix.push(0);
172        for i in 0..n {
173            acc = acc.saturating_add(self.item_height(i));
174            self.row_prefix.push(acc);
175        }
176        self.heights_dirty = false;
177    }
178
179    /// Read-only access to the cached prefix sum (test/helper use).
180    pub(crate) fn row_prefix(&self) -> &[u32] {
181        &self.row_prefix
182    }
183
184    /// Set the filter string. Multiple space-separated tokens are AND'd
185    /// together — all tokens must match across any cell in the same row.
186    /// Empty string disables filtering.
187    pub fn set_filter(&mut self, filter: impl Into<String>) {
188        self.filter = filter.into();
189        self.rebuild_view();
190    }
191
192    /// Returns indices of items visible after filtering.
193    pub fn visible_indices(&self) -> &[usize] {
194        &self.view_indices
195    }
196
197    /// Get the currently selected item text, or `None` if the list is empty.
198    pub fn selected_item(&self) -> Option<&str> {
199        let data_idx = *self.view_indices.get(self.selected)?;
200        self.items.get(data_idx).map(String::as_str)
201    }
202
203    fn rebuild_view(&mut self) {
204        let tokens: Vec<String> = self
205            .filter
206            .split_whitespace()
207            .map(|t| t.to_lowercase())
208            .collect();
209        self.view_indices = if tokens.is_empty() {
210            (0..self.items.len()).collect()
211        } else {
212            (0..self.items.len())
213                .filter(|&i| {
214                    let cached = match self.item_search_cache.get(i) {
215                        Some(s) => s.as_str(),
216                        None => return false,
217                    };
218                    tokens.iter().all(|token| cached.contains(token.as_str()))
219                })
220                .collect()
221        };
222        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
223            self.selected = self.view_indices.len() - 1;
224        }
225    }
226}
227
228/// State for a file picker widget.
229///
230/// Tracks the current directory listing, filtering options, and selected file.
231#[derive(Debug, Clone)]
232pub struct FilePickerState {
233    /// Current directory being browsed.
234    pub current_dir: PathBuf,
235    /// Visible entries in the current directory.
236    pub entries: Vec<FileEntry>,
237    /// Selected entry index in `entries`.
238    pub selected: usize,
239    /// Currently selected file path, if any.
240    pub selected_file: Option<PathBuf>,
241    /// Whether dotfiles are included in the listing.
242    pub show_hidden: bool,
243    /// Allowed file extensions (lowercase, no leading dot).
244    pub extensions: Vec<String>,
245    /// Whether the directory listing needs refresh.
246    pub dirty: bool,
247}
248
249/// A directory entry shown by [`FilePickerState`].
250#[derive(Debug, Clone, Default)]
251pub struct FileEntry {
252    /// File or directory name.
253    pub name: String,
254    /// Full path to the entry.
255    pub path: PathBuf,
256    /// Whether this entry is a directory.
257    pub is_dir: bool,
258    /// File size in bytes (0 for directories).
259    pub size: u64,
260}
261
262impl FilePickerState {
263    /// Create a file picker rooted at `dir`.
264    pub fn new(dir: impl Into<PathBuf>) -> Self {
265        Self {
266            current_dir: dir.into(),
267            entries: Vec::new(),
268            selected: 0,
269            selected_file: None,
270            show_hidden: false,
271            extensions: Vec::new(),
272            dirty: true,
273        }
274    }
275
276    /// Configure whether hidden files should be shown.
277    pub fn show_hidden(mut self, show: bool) -> Self {
278        self.show_hidden = show;
279        self.dirty = true;
280        self
281    }
282
283    /// Restrict visible files to the provided extensions.
284    pub fn extensions(mut self, exts: &[&str]) -> Self {
285        self.extensions = exts
286            .iter()
287            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
288            .filter(|ext| !ext.is_empty())
289            .collect();
290        self.dirty = true;
291        self
292    }
293
294    /// Return the currently selected file path, if any.
295    ///
296    /// Disambiguates from the [`selected: usize`](Self::selected) field, which
297    /// is the entry index into [`entries`](Self::entries). This method returns
298    /// the resolved file path that the user picked via Enter — `None` until a
299    /// file (not a directory) is selected.
300    ///
301    /// # Example
302    ///
303    /// ```no_run
304    /// # use slt::widgets::FilePickerState;
305    /// # slt::run(|ui: &mut slt::Context| {
306    /// let mut state = FilePickerState::new(".");
307    /// if ui.file_picker(&mut state).changed {
308    ///     if let Some(path) = state.selected_file() {
309    ///         println!("picked: {}", path.display());
310    ///     }
311    /// }
312    /// # });
313    /// ```
314    pub fn selected_file(&self) -> Option<&PathBuf> {
315        self.selected_file.as_ref()
316    }
317
318    /// Return the currently selected file path.
319    ///
320    /// Deprecated alias for [`selected_file`](Self::selected_file). The
321    /// shorter name conflicts visually with the [`selected: usize`](Self::selected)
322    /// field — a getter returning a path alongside a public field returning
323    /// an index made call sites ambiguous. Migrate to `selected_file()` for
324    /// new code; this stub stays callable until v1.0.
325    #[deprecated(since = "0.20.0", note = "use selected_file() — disambiguates from the `selected: usize` field index")]
326    pub fn selected(&self) -> Option<&PathBuf> {
327        self.selected_file()
328    }
329
330    /// Re-scan the current directory and rebuild entries.
331    pub fn refresh(&mut self) {
332        let mut entries = Vec::new();
333
334        if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
335            for dir_entry in read_dir.flatten() {
336                let name = dir_entry.file_name().to_string_lossy().to_string();
337                if !self.show_hidden && name.starts_with('.') {
338                    continue;
339                }
340
341                let Ok(file_type) = dir_entry.file_type() else {
342                    continue;
343                };
344                if file_type.is_symlink() {
345                    continue;
346                }
347
348                let path = dir_entry.path();
349                let is_dir = file_type.is_dir();
350
351                if !is_dir && !self.extensions.is_empty() {
352                    let ext = path
353                        .extension()
354                        .and_then(|e| e.to_str())
355                        .map(|e| e.to_ascii_lowercase());
356                    let Some(ext) = ext else {
357                        continue;
358                    };
359                    if !self.extensions.iter().any(|allowed| allowed == &ext) {
360                        continue;
361                    }
362                }
363
364                let size = if is_dir {
365                    0
366                } else {
367                    fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
368                };
369
370                entries.push(FileEntry {
371                    name,
372                    path,
373                    is_dir,
374                    size,
375                });
376            }
377        }
378
379        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
380            (true, false) => std::cmp::Ordering::Less,
381            (false, true) => std::cmp::Ordering::Greater,
382            _ => a
383                .name
384                .to_ascii_lowercase()
385                .cmp(&b.name.to_ascii_lowercase())
386                .then_with(|| a.name.cmp(&b.name)),
387        });
388
389        self.entries = entries;
390        if self.entries.is_empty() {
391            self.selected = 0;
392        } else {
393            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
394        }
395        self.dirty = false;
396    }
397}
398
399impl Default for FilePickerState {
400    fn default() -> Self {
401        Self::new(".")
402    }
403}
404
405/// State for a tab navigation widget.
406///
407/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
408/// keys cycle through tabs when the widget is focused.
409#[derive(Debug, Clone, Default)]
410pub struct TabsState {
411    /// The tab labels displayed in the bar.
412    pub labels: Vec<String>,
413    /// Index of the currently active tab.
414    pub selected: usize,
415}
416
417impl TabsState {
418    /// Create tabs with the given labels. The first tab is active initially.
419    pub fn new(labels: Vec<impl Into<String>>) -> Self {
420        Self {
421            labels: labels.into_iter().map(Into::into).collect(),
422            selected: 0,
423        }
424    }
425
426    /// Get the currently selected tab label, or `None` if there are no tabs.
427    pub fn selected_label(&self) -> Option<&str> {
428        self.labels.get(self.selected).map(String::as_str)
429    }
430}
431
432/// Per-column width policy for a [`TableState`].
433///
434/// Mirrors the semantics of [`GridColumn`] and
435/// [`WidthSpec`](crate::WidthSpec) for the string-grid table model. Apply a
436/// slice of these via [`TableState::column_widths_spec`]; columns without an
437/// entry (or set to [`TableColumn::Auto`]) keep the default content-derived
438/// sizing.
439///
440/// Available since v0.21.0.
441///
442/// # Example
443///
444/// ```no_run
445/// use slt::{TableColumn, widgets::TableState};
446/// # slt::run(|ui: &mut slt::Context| {
447/// let mut table = TableState::new(
448///     vec!["Name", "Status"],
449///     vec![vec!["build", "ok"]],
450/// );
451/// // Pin the status column to 6 cells, leave the name column automatic.
452/// table.column_widths_spec(&[TableColumn::Auto, TableColumn::Fixed(6)]);
453/// ui.table(&mut table);
454/// # });
455/// ```
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
457pub enum TableColumn {
458    /// Size the column to its content (header + widest cell). Default.
459    Auto,
460    /// Exact cell width in character cells. Content is padded or truncated to fit.
461    Fixed(u32),
462    /// Content width, floored at `n` cells (never narrower than `n`).
463    Min(u32),
464    /// Content width, capped at `n` cells (truncated with an ellipsis if longer).
465    Max(u32),
466    /// Width as a percentage (`1..=100`) of the available table content width.
467    Percent(u8),
468}
469
470/// State for a data table widget.
471///
472/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
473/// keys move the row selection when the widget is focused. Column widths are
474/// computed automatically from header and cell content, or constrained per
475/// column via [`column_widths_spec`](TableState::column_widths_spec).
476///
477/// Multi-row selection (Space / Shift+Up/Down / Ctrl+Space and modifier
478/// clicks) is tracked in [`multi_selected`](TableState::multi_selected); the
479/// `selected` field always remains the focused/cursor row.
480#[derive(Debug, Clone)]
481pub struct TableState {
482    /// Column header labels.
483    pub headers: Vec<String>,
484    /// Table rows, each a `Vec` of cell strings.
485    pub rows: Vec<Vec<String>>,
486    /// Focused/cursor row (view index). Unchanged single-select semantics.
487    pub selected: usize,
488    /// Multi-row selection as view indices. Empty means no multi-selection.
489    ///
490    /// Available since v0.21.0.
491    pub multi_selected: HashSet<usize>,
492    /// Range-selection anchor (view index) for Shift extension.
493    pub(crate) selection_anchor: Option<usize>,
494    /// Per-column width policy. Empty means every column is [`TableColumn::Auto`].
495    column_specs: Vec<TableColumn>,
496    column_widths: Vec<u32>,
497    /// Content-derived widths before per-column specs are resolved.
498    content_widths: Vec<u32>,
499    widths_dirty: bool,
500    /// Available content width used to resolve [`TableColumn::Percent`].
501    resolved_width: u32,
502    /// Sorted column index (`None` means no sorting).
503    pub sort_column: Option<usize>,
504    /// Sort direction (`true` for ascending).
505    pub sort_ascending: bool,
506    /// Case-insensitive substring filter applied across all cells.
507    pub filter: String,
508    /// Current page (0-based) when pagination is enabled.
509    pub page: usize,
510    /// Rows per page (`0` disables pagination).
511    pub page_size: usize,
512    /// Whether alternating row backgrounds are enabled.
513    pub zebra: bool,
514    view_indices: Vec<usize>,
515    row_search_cache: Vec<String>,
516    filter_tokens: Vec<String>,
517}
518
519impl Default for TableState {
520    fn default() -> Self {
521        Self {
522            headers: Vec::new(),
523            rows: Vec::new(),
524            selected: 0,
525            multi_selected: HashSet::new(),
526            selection_anchor: None,
527            column_specs: Vec::new(),
528            column_widths: Vec::new(),
529            content_widths: Vec::new(),
530            widths_dirty: true,
531            resolved_width: 0,
532            sort_column: None,
533            sort_ascending: true,
534            filter: String::new(),
535            page: 0,
536            page_size: 0,
537            zebra: false,
538            view_indices: Vec::new(),
539            row_search_cache: Vec::new(),
540            filter_tokens: Vec::new(),
541        }
542    }
543}
544
545impl TableState {
546    /// Create a table with headers and rows. Column widths are computed immediately.
547    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
548        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
549        let rows: Vec<Vec<String>> = rows
550            .into_iter()
551            .map(|r| r.into_iter().map(Into::into).collect())
552            .collect();
553        let mut state = Self {
554            headers,
555            rows,
556            selected: 0,
557            multi_selected: HashSet::new(),
558            selection_anchor: None,
559            column_specs: Vec::new(),
560            column_widths: Vec::new(),
561            content_widths: Vec::new(),
562            widths_dirty: true,
563            resolved_width: 0,
564            sort_column: None,
565            sort_ascending: true,
566            filter: String::new(),
567            page: 0,
568            page_size: 0,
569            zebra: false,
570            view_indices: Vec::new(),
571            row_search_cache: Vec::new(),
572            filter_tokens: Vec::new(),
573        };
574        state.rebuild_row_search_cache();
575        state.rebuild_view();
576        state.recompute_widths();
577        state
578    }
579
580    /// Replace all rows, preserving the selection index if possible.
581    ///
582    /// If the current selection is beyond the new row count, it is clamped to
583    /// the last row.
584    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
585        self.rows = rows
586            .into_iter()
587            .map(|r| r.into_iter().map(Into::into).collect())
588            .collect();
589        self.rebuild_row_search_cache();
590        self.rebuild_view();
591    }
592
593    /// Sort by a specific column index. If already sorted by this column, toggles direction.
594    pub fn toggle_sort(&mut self, column: usize) {
595        if self.sort_column == Some(column) {
596            self.sort_ascending = !self.sort_ascending;
597        } else {
598            self.sort_column = Some(column);
599            self.sort_ascending = true;
600        }
601        self.rebuild_view();
602    }
603
604    /// Sort by column without toggling (always sets to ascending first).
605    pub fn sort_by(&mut self, column: usize) {
606        if self.sort_column == Some(column) && self.sort_ascending {
607            return;
608        }
609        self.sort_column = Some(column);
610        self.sort_ascending = true;
611        self.rebuild_view();
612    }
613
614    /// Set the filter string. Multiple space-separated tokens are AND'd
615    /// together — all tokens must match across any cell in the same row.
616    /// Empty string disables filtering.
617    pub fn set_filter(&mut self, filter: impl Into<String>) {
618        let filter = filter.into();
619        if self.filter == filter {
620            return;
621        }
622        self.filter = filter;
623        self.filter_tokens = Self::tokenize_filter(&self.filter);
624        self.page = 0;
625        self.rebuild_view();
626    }
627
628    /// Clear sorting.
629    pub fn clear_sort(&mut self) {
630        if self.sort_column.is_none() && self.sort_ascending {
631            return;
632        }
633        self.sort_column = None;
634        self.sort_ascending = true;
635        self.rebuild_view();
636    }
637
638    /// Move to the next page. Does nothing if already on the last page.
639    pub fn next_page(&mut self) {
640        if self.page_size == 0 {
641            return;
642        }
643        let last_page = self.total_pages().saturating_sub(1);
644        self.page = (self.page + 1).min(last_page);
645    }
646
647    /// Move to the previous page. Does nothing if already on page 0.
648    pub fn prev_page(&mut self) {
649        self.page = self.page.saturating_sub(1);
650    }
651
652    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
653    pub fn total_pages(&self) -> usize {
654        if self.page_size == 0 {
655            return 1;
656        }
657
658        let len = self.view_indices.len();
659        if len == 0 {
660            1
661        } else {
662            len.div_ceil(self.page_size)
663        }
664    }
665
666    /// Get the visible row indices after filtering and sorting (used internally by table()).
667    pub fn visible_indices(&self) -> &[usize] {
668        &self.view_indices
669    }
670
671    /// Get the currently selected row data, or `None` if the table is empty.
672    pub fn selected_row(&self) -> Option<&[String]> {
673        if self.view_indices.is_empty() {
674            return None;
675        }
676        let data_idx = self.view_indices.get(self.selected)?;
677        self.rows.get(*data_idx).map(|r| r.as_slice())
678    }
679
680    /// Set the per-column width policy.
681    ///
682    /// The slice is index-aligned with [`headers`](TableState::headers); a
683    /// shorter slice leaves trailing columns at [`TableColumn::Auto`]. Passing
684    /// an empty slice resets every column to automatic sizing.
685    ///
686    /// Available since v0.21.0.
687    ///
688    /// # Example
689    ///
690    /// ```no_run
691    /// use slt::{TableColumn, widgets::TableState};
692    /// # slt::run(|ui: &mut slt::Context| {
693    /// let mut table = TableState::new(
694    ///     vec!["Name", "Note"],
695    ///     vec![vec!["a", "a very long note that should be capped"]],
696    /// );
697    /// table.column_widths_spec(&[TableColumn::Fixed(6), TableColumn::Max(10)]);
698    /// ui.table(&mut table);
699    /// # });
700    /// ```
701    pub fn column_widths_spec(&mut self, specs: &[TableColumn]) {
702        self.column_specs = specs.to_vec();
703        self.widths_dirty = true;
704    }
705
706    /// Return the multi-selected rows in ascending view order.
707    ///
708    /// View indices are resolved against the current sort/filter view, so the
709    /// returned slices reflect what the user sees. Stale indices (beyond the
710    /// current view) are skipped.
711    ///
712    /// Available since v0.21.0.
713    ///
714    /// # Example
715    ///
716    /// ```no_run
717    /// use slt::widgets::TableState;
718    /// # slt::run(|ui: &mut slt::Context| {
719    /// let mut table = TableState::new(
720    ///     vec!["Name"],
721    ///     vec![vec!["a"], vec!["b"]],
722    /// );
723    /// ui.table(&mut table);
724    /// for row in table.selected_rows() {
725    ///     let _ = row;
726    /// }
727    /// # });
728    /// ```
729    pub fn selected_rows(&self) -> Vec<&[String]> {
730        let mut indices: Vec<usize> = self.multi_selected.iter().copied().collect();
731        indices.sort_unstable();
732        indices
733            .iter()
734            .filter_map(|&view_idx| self.view_indices.get(view_idx))
735            .filter_map(|&data_idx| self.rows.get(data_idx).map(|r| r.as_slice()))
736            .collect()
737    }
738
739    /// Returns `true` if the row at `view_idx` is in the multi-selection set.
740    ///
741    /// Available since v0.21.0.
742    ///
743    /// # Example
744    ///
745    /// ```no_run
746    /// use slt::widgets::TableState;
747    /// # slt::run(|ui: &mut slt::Context| {
748    /// let mut table = TableState::new(vec!["Name"], vec![vec!["a"]]);
749    /// ui.table(&mut table);
750    /// let _ = table.is_row_selected(0);
751    /// # });
752    /// ```
753    pub fn is_row_selected(&self, view_idx: usize) -> bool {
754        self.multi_selected.contains(&view_idx)
755    }
756
757    /// Clear the multi-selection set and the range anchor.
758    ///
759    /// The focused [`selected`](TableState::selected) cursor row is unaffected.
760    ///
761    /// Available since v0.21.0.
762    ///
763    /// # Example
764    ///
765    /// ```no_run
766    /// use slt::widgets::TableState;
767    /// # slt::run(|ui: &mut slt::Context| {
768    /// let mut table = TableState::new(vec!["Name"], vec![vec!["a"]]);
769    /// ui.table(&mut table);
770    /// table.clear_selection();
771    /// # });
772    /// ```
773    pub fn clear_selection(&mut self) {
774        self.multi_selected.clear();
775        self.selection_anchor = None;
776    }
777
778    /// Toggle the multi-selection state for the row at `view_idx`, and set the
779    /// range anchor to it. Mirrors [`MultiSelectState::toggle`].
780    pub(crate) fn toggle_row(&mut self, view_idx: usize) {
781        if self.multi_selected.contains(&view_idx) {
782            self.multi_selected.remove(&view_idx);
783        } else {
784            self.multi_selected.insert(view_idx);
785        }
786        self.selection_anchor = Some(view_idx);
787    }
788
789    /// Replace the multi-selection with the single row at `view_idx` and reset
790    /// the anchor to it.
791    pub(crate) fn select_single(&mut self, view_idx: usize) {
792        self.multi_selected.clear();
793        self.multi_selected.insert(view_idx);
794        self.selection_anchor = Some(view_idx);
795    }
796
797    /// Select the inclusive contiguous range `[min(from,to)..=max(from,to)]`,
798    /// replacing the current multi-selection. The anchor is left at `from`.
799    pub(crate) fn select_range(&mut self, from: usize, to: usize) {
800        let (lo, hi) = if from <= to { (from, to) } else { (to, from) };
801        self.multi_selected.clear();
802        for idx in lo..=hi {
803            self.multi_selected.insert(idx);
804        }
805        self.selection_anchor = Some(from);
806    }
807
808    /// Remove any multi-selection indices that are no longer valid view
809    /// indices, and clamp the anchor. Called after the view is rebuilt.
810    fn prune_selection(&mut self) {
811        let view_len = self.view_indices.len();
812        self.multi_selected.retain(|&idx| idx < view_len);
813        if let Some(anchor) = self.selection_anchor {
814            if anchor >= view_len {
815                self.selection_anchor = None;
816            }
817        }
818    }
819
820    /// Recompute view_indices based on current sort + filter settings.
821    fn rebuild_view(&mut self) {
822        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
823
824        if !self.filter_tokens.is_empty() {
825            indices.retain(|&idx| {
826                let searchable = match self.row_search_cache.get(idx) {
827                    Some(row) => row,
828                    None => return false,
829                };
830                self.filter_tokens
831                    .iter()
832                    .all(|token| searchable.contains(token.as_str()))
833            });
834        }
835
836        if let Some(column) = self.sort_column {
837            indices.sort_by(|a, b| {
838                let left = self
839                    .rows
840                    .get(*a)
841                    .and_then(|row| row.get(column))
842                    .map(String::as_str)
843                    .unwrap_or("");
844                let right = self
845                    .rows
846                    .get(*b)
847                    .and_then(|row| row.get(column))
848                    .map(String::as_str)
849                    .unwrap_or("");
850
851                match (left.parse::<f64>(), right.parse::<f64>()) {
852                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
853                    _ => left
854                        .chars()
855                        .flat_map(char::to_lowercase)
856                        .cmp(right.chars().flat_map(char::to_lowercase)),
857                }
858            });
859
860            if !self.sort_ascending {
861                indices.reverse();
862            }
863        }
864
865        self.view_indices = indices;
866
867        if self.page_size > 0 {
868            self.page = self.page.min(self.total_pages().saturating_sub(1));
869        } else {
870            self.page = 0;
871        }
872
873        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
874        self.prune_selection();
875        self.widths_dirty = true;
876    }
877
878    fn rebuild_row_search_cache(&mut self) {
879        self.row_search_cache = self
880            .rows
881            .iter()
882            .map(|row| {
883                let mut searchable = String::new();
884                for (idx, cell) in row.iter().enumerate() {
885                    if idx > 0 {
886                        searchable.push('\n');
887                    }
888                    searchable.extend(cell.chars().flat_map(char::to_lowercase));
889                }
890                searchable
891            })
892            .collect();
893        self.filter_tokens = Self::tokenize_filter(&self.filter);
894        self.widths_dirty = true;
895    }
896
897    fn tokenize_filter(filter: &str) -> Vec<String> {
898        filter
899            .split_whitespace()
900            .map(|t| t.to_lowercase())
901            .collect()
902    }
903
904    pub(crate) fn recompute_widths(&mut self) {
905        // Skip when no mutation since the last computation. `widths_dirty` is
906        // set by `rebuild_view` (covers `set_rows`, `set_filter`, sort),
907        // `column_widths_spec`, and at construction. Frames without data
908        // mutation become a no-op.
909        if !self.widths_dirty {
910            return;
911        }
912        let col_count = self.headers.len();
913        self.content_widths = vec![0u32; col_count];
914        for (i, header) in self.headers.iter().enumerate() {
915            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
916            if self.sort_column == Some(i) {
917                width += 2;
918            }
919            self.content_widths[i] = width;
920        }
921        for row in &self.rows {
922            for (i, cell) in row.iter().enumerate() {
923                if i < col_count {
924                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
925                    self.content_widths[i] = self.content_widths[i].max(w);
926                }
927            }
928        }
929        // Default resolved widths to the content widths; `resolve_column_widths`
930        // overlays the per-column specs each frame once the available width is
931        // known. When no spec is set this is the pre-v0.21 behavior verbatim.
932        self.column_widths = self.content_widths.clone();
933        self.widths_dirty = false;
934    }
935
936    /// Resolve per-column width specs against the content widths, using
937    /// `available` as the total table content width for `Percent`. A no-op
938    /// when no spec is set, so all-`Auto` tables render byte-identically.
939    pub(crate) fn resolve_column_widths(&mut self, available: u32) {
940        if self.column_specs.is_empty() {
941            return;
942        }
943        // Re-derive base content widths if the available width changed since
944        // the last resolution (the previous frame may have shrunk a column).
945        if self.resolved_width != available {
946            self.column_widths = self.content_widths.clone();
947            self.resolved_width = available;
948        }
949        let col_count = self.column_widths.len();
950        for i in 0..col_count {
951            let content = self.content_widths.get(i).copied().unwrap_or(0);
952            let spec = self.column_specs.get(i).copied().unwrap_or(TableColumn::Auto);
953            let resolved = match spec {
954                TableColumn::Auto => content,
955                TableColumn::Fixed(n) => n,
956                TableColumn::Min(n) => content.max(n),
957                TableColumn::Max(n) => content.min(n),
958                TableColumn::Percent(pct) => {
959                    let pct = pct.clamp(1, 100) as u32;
960                    (available.saturating_mul(pct)) / 100
961                }
962            };
963            self.column_widths[i] = resolved;
964        }
965    }
966
967    pub(crate) fn column_widths(&self) -> &[u32] {
968        &self.column_widths
969    }
970
971    pub(crate) fn is_dirty(&self) -> bool {
972        self.widths_dirty
973    }
974}
975
976/// Visual style for [`Context::paginator`](crate::Context::paginator).
977///
978/// `Dots` renders one `●`/`○` glyph per page and is the default; it falls back
979/// to `Arabic` automatically once there are more than 12 pages so the indicator
980/// never overflows. `Arabic` renders a compact `{page}/{total}` counter.
981#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
982pub enum PaginatorStyle {
983    /// One `●`/`○` glyph per page. Auto-falls back to [`Self::Arabic`] past 12 pages.
984    #[default]
985    Dots,
986    /// Compact `{page}/{total}` counter.
987    Arabic,
988}
989
990/// Standalone pagination state, decoupled from any list or table.
991///
992/// Owns a page index over an arbitrary item count, so you can paginate a
993/// wizard, slide deck, onboarding flow, carousel, or any non-table data. Pass a
994/// mutable reference to [`Context::paginator`](crate::Context::paginator) each
995/// frame; Left/`h`/PageUp move to the previous page and Right/`l`/PageDown move
996/// to the next page when the widget is focused.
997///
998/// # Example
999///
1000/// ```no_run
1001/// use slt::{PaginatorState, PaginatorStyle};
1002///
1003/// let mut state = PaginatorState::new(42, 10); // 42 items, 10 per page
1004/// state.style = PaginatorStyle::Arabic;
1005/// assert_eq!(state.total_pages(), 5);
1006/// let (start, end) = state.page_bounds(); // slice your own data with these
1007/// assert_eq!((start, end), (0, 10));
1008/// ```
1009#[derive(Debug, Clone)]
1010pub struct PaginatorState {
1011    /// Total number of items being paged over.
1012    pub total_items: usize,
1013    /// Items per page (clamped to `>= 1` internally).
1014    pub per_page: usize,
1015    /// Current page (0-based).
1016    pub page: usize,
1017    /// Rendering style.
1018    pub style: PaginatorStyle,
1019}
1020
1021impl PaginatorState {
1022    /// Create a paginator over `total_items` with `per_page` items per page.
1023    ///
1024    /// `per_page` is clamped to at least `1` internally (so a `0` argument is
1025    /// treated as `1`, avoiding division by zero). The current page starts at
1026    /// `0` and the style defaults to [`PaginatorStyle::Dots`].
1027    ///
1028    /// # Example
1029    ///
1030    /// ```no_run
1031    /// use slt::PaginatorState;
1032    ///
1033    /// let state = PaginatorState::new(30, 0); // 0 per_page -> clamped to 1
1034    /// assert_eq!(state.per_page, 1);
1035    /// assert_eq!(state.total_pages(), 30);
1036    /// ```
1037    pub fn new(total_items: usize, per_page: usize) -> Self {
1038        Self {
1039            total_items,
1040            per_page: per_page.max(1),
1041            page: 0,
1042            style: PaginatorStyle::default(),
1043        }
1044    }
1045
1046    /// Total number of pages; always `>= 1` (returns `1` when there are no items).
1047    ///
1048    /// # Example
1049    ///
1050    /// ```no_run
1051    /// use slt::PaginatorState;
1052    ///
1053    /// assert_eq!(PaginatorState::new(0, 5).total_pages(), 1);
1054    /// assert_eq!(PaginatorState::new(10, 3).total_pages(), 4);
1055    /// assert_eq!(PaginatorState::new(9, 3).total_pages(), 3);
1056    /// ```
1057    pub fn total_pages(&self) -> usize {
1058        self.total_items.div_ceil(self.per_page.max(1)).max(1)
1059    }
1060
1061    /// Inclusive-start / exclusive-end item indices for the current page.
1062    ///
1063    /// `end` is clamped to `total_items`, so callers can slice their own data
1064    /// with `&items[start..end]` without bounds-checking the tail page.
1065    ///
1066    /// # Example
1067    ///
1068    /// ```no_run
1069    /// use slt::PaginatorState;
1070    ///
1071    /// let mut state = PaginatorState::new(10, 3);
1072    /// assert_eq!(state.page_bounds(), (0, 3));
1073    /// state.set_page(3); // last (partial) page
1074    /// assert_eq!(state.page_bounds(), (9, 10));
1075    /// ```
1076    pub fn page_bounds(&self) -> (usize, usize) {
1077        let start = self
1078            .page
1079            .saturating_mul(self.per_page)
1080            .min(self.total_items);
1081        let end = start.saturating_add(self.per_page).min(self.total_items);
1082        (start, end)
1083    }
1084
1085    /// Advance one page, clamped to the last page (no wrap).
1086    ///
1087    /// # Example
1088    ///
1089    /// ```no_run
1090    /// use slt::PaginatorState;
1091    ///
1092    /// let mut state = PaginatorState::new(6, 3); // 2 pages
1093    /// state.next_page();
1094    /// assert_eq!(state.page, 1);
1095    /// state.next_page(); // already last page -> clamped
1096    /// assert_eq!(state.page, 1);
1097    /// ```
1098    pub fn next_page(&mut self) {
1099        self.page = (self.page + 1).min(self.total_pages().saturating_sub(1));
1100    }
1101
1102    /// Go back one page, clamped to `0` (no wrap).
1103    ///
1104    /// # Example
1105    ///
1106    /// ```no_run
1107    /// use slt::PaginatorState;
1108    ///
1109    /// let mut state = PaginatorState::new(6, 3);
1110    /// state.prev_page(); // already page 0 -> clamped
1111    /// assert_eq!(state.page, 0);
1112    /// ```
1113    pub fn prev_page(&mut self) {
1114        self.page = self.page.saturating_sub(1);
1115    }
1116
1117    /// Jump to a specific page, clamped into `[0, total_pages() - 1]`.
1118    ///
1119    /// # Example
1120    ///
1121    /// ```no_run
1122    /// use slt::PaginatorState;
1123    ///
1124    /// let mut state = PaginatorState::new(10, 3); // 4 pages
1125    /// state.set_page(99);
1126    /// assert_eq!(state.page, 3);
1127    /// ```
1128    pub fn set_page(&mut self, page: usize) {
1129        self.page = page.min(self.total_pages().saturating_sub(1));
1130    }
1131
1132    /// Update the item count and re-clamp the current page into range.
1133    ///
1134    /// # Example
1135    ///
1136    /// ```no_run
1137    /// use slt::PaginatorState;
1138    ///
1139    /// let mut state = PaginatorState::new(10, 3);
1140    /// state.set_page(3); // last page
1141    /// state.set_total_items(3); // now only 1 page
1142    /// assert_eq!(state.page, 0);
1143    /// ```
1144    pub fn set_total_items(&mut self, total: usize) {
1145        self.total_items = total;
1146        self.page = self.page.min(self.total_pages().saturating_sub(1));
1147    }
1148
1149    /// Update items-per-page (clamped to `>= 1`) and re-clamp the current page.
1150    ///
1151    /// # Example
1152    ///
1153    /// ```no_run
1154    /// use slt::PaginatorState;
1155    ///
1156    /// let mut state = PaginatorState::new(10, 3); // 4 pages
1157    /// state.set_page(3);
1158    /// state.set_per_page(10); // now only 1 page
1159    /// assert_eq!(state.per_page, 10);
1160    /// assert_eq!(state.page, 0);
1161    /// ```
1162    pub fn set_per_page(&mut self, per_page: usize) {
1163        self.per_page = per_page.max(1);
1164        self.page = self.page.min(self.total_pages().saturating_sub(1));
1165    }
1166}
1167
1168/// A highlighted line range within a scrollable region.
1169///
1170/// Used with [`ScrollState::set_highlights`] to mark search results, error
1171/// lines, or any per-line emphasis. The `scrollable_with_gutter` widget reads
1172/// the active highlights and renders a background band on matching lines.
1173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1174pub struct HighlightRange {
1175    /// First line (0-based, relative to content top).
1176    pub start_line: usize,
1177    /// Number of lines in the range (1 = single line).
1178    pub line_count: usize,
1179}
1180
1181impl HighlightRange {
1182    /// Create a single-line highlight at `line`.
1183    ///
1184    /// Field-name pairing: `start_line` + `line_count` → constructor named
1185    /// `line`. Use [`Self::span`] for multi-line ranges.
1186    pub fn line(line: usize) -> Self {
1187        Self {
1188            start_line: line,
1189            line_count: 1,
1190        }
1191    }
1192
1193    /// Create a multi-line highlight starting at `start_line` covering `line_count` rows.
1194    pub fn span(start_line: usize, line_count: usize) -> Self {
1195        Self {
1196            start_line,
1197            line_count: line_count.max(1),
1198        }
1199    }
1200
1201    /// Check whether the given absolute line index falls within this range.
1202    pub fn contains(&self, line: usize) -> bool {
1203        line >= self.start_line && line < self.start_line + self.line_count
1204    }
1205}
1206
1207/// State for a scrollable container.
1208///
1209/// Pass a mutable reference to `Context::scrollable` each frame. The context
1210/// updates `offset` and the internal bounds automatically based on mouse wheel
1211/// and drag events.
1212///
1213/// Both axes are tracked (#247): the vertical axis (`offset`, [`scroll_up`] /
1214/// [`scroll_down`]) drives [`Context::scroll_col`], and the horizontal axis
1215/// (`offset_x`, [`scroll_left`] / [`scroll_right`]) drives
1216/// [`Context::scroll_row`]. A single [`ScrollState`] scrolls one axis per
1217/// container — nest a `scroll_row` inside a `scroll_col` for both. The vertical
1218/// API is unchanged from earlier versions.
1219///
1220/// [`scroll_up`]: ScrollState::scroll_up
1221/// [`scroll_down`]: ScrollState::scroll_down
1222/// [`scroll_left`]: ScrollState::scroll_left
1223/// [`scroll_right`]: ScrollState::scroll_right
1224/// [`Context::scroll_col`]: crate::Context::scroll_col
1225/// [`Context::scroll_row`]: crate::Context::scroll_row
1226#[derive(Debug, Clone)]
1227pub struct ScrollState {
1228    /// Current vertical scroll offset in rows.
1229    pub offset: usize,
1230    /// Current horizontal scroll offset in columns (#247).
1231    pub offset_x: usize,
1232    /// Whether the scrollbar thumb is currently being dragged.
1233    ///
1234    /// Set to `true` by [`Context::scrollbar`] on a mouse-down inside the
1235    /// thumb and back to `false` on mouse-up, mirroring
1236    /// [`SplitPaneState::dragging`](crate::widgets::SplitPaneState). Persists
1237    /// across frames so cursor motion outside the thumb (or even outside the
1238    /// track on the x-axis) keeps scrolling while the button is held.
1239    ///
1240    /// [`Context::scrollbar`]: crate::Context::scrollbar
1241    pub dragging: bool,
1242    content_height: u32,
1243    viewport_height: u32,
1244    content_width: u32,
1245    viewport_width: u32,
1246    highlights: Vec<HighlightRange>,
1247    current_highlight: Option<usize>,
1248}
1249
1250impl ScrollState {
1251    /// Create scroll state starting at offset 0.
1252    pub fn new() -> Self {
1253        Self {
1254            offset: 0,
1255            offset_x: 0,
1256            dragging: false,
1257            content_height: 0,
1258            viewport_height: 0,
1259            content_width: 0,
1260            viewport_width: 0,
1261            highlights: Vec::new(),
1262            current_highlight: None,
1263        }
1264    }
1265
1266    /// Check if scrolling upward is possible (offset is greater than 0).
1267    pub fn can_scroll_up(&self) -> bool {
1268        self.offset > 0
1269    }
1270
1271    /// Check if scrolling downward is possible (content extends below the viewport).
1272    pub fn can_scroll_down(&self) -> bool {
1273        (self.offset as u32) + self.viewport_height < self.content_height
1274    }
1275
1276    /// Get the total content height in rows.
1277    pub fn content_height(&self) -> u32 {
1278        self.content_height
1279    }
1280
1281    /// Get the viewport height in rows.
1282    pub fn viewport_height(&self) -> u32 {
1283        self.viewport_height
1284    }
1285
1286    /// Get the scroll progress as a ratio in `[0.0, 1.0]`.
1287    ///
1288    /// Returns `f64` to match the rest of the ratio surface unified in v0.20
1289    /// (`Gauge::ratio`, `SplitPaneState::ratio`, `progress(ratio)`,
1290    /// `progress_bar(ratio)`). Feed the value straight into [`Context::gauge`]
1291    /// or [`Context::progress_bar`] without a cast.
1292    ///
1293    /// [`Context::gauge`]: crate::Context::gauge
1294    /// [`Context::progress_bar`]: crate::Context::progress_bar
1295    ///
1296    /// ```no_run
1297    /// # use slt::ScrollState;
1298    /// let scroll = ScrollState::new();
1299    /// // Bounds are populated by the `scrollable` widget each frame; a fresh
1300    /// // state with no content reports 0.0.
1301    /// let ratio: f64 = scroll.progress_ratio();
1302    /// assert!((0.0..=1.0).contains(&ratio));
1303    /// ```
1304    pub fn progress_ratio(&self) -> f64 {
1305        let max = self.content_height.saturating_sub(self.viewport_height);
1306        if max == 0 {
1307            0.0
1308        } else {
1309            self.offset as f64 / max as f64
1310        }
1311    }
1312
1313    /// Deprecated `f32` alias for [`progress_ratio`](Self::progress_ratio).
1314    ///
1315    /// `ScrollState::progress` was the only `f32` ratio left after the v0.20
1316    /// `f32 → f64` ratio unification. Migrate to [`progress_ratio`](Self::progress_ratio):
1317    /// call sites that wrapped the result in `as f64` can drop the cast, while
1318    /// call sites passing the value to `gauge` / `progress_bar` (which already
1319    /// take `f64`) need no cast at all.
1320    #[deprecated(
1321        since = "0.21.0",
1322        note = "use progress_ratio() — f64 matches the rest of the v0.20+ ratio surface (gauge/progress_bar take f64; drop any `as f64` cast)"
1323    )]
1324    pub fn progress(&self) -> f32 {
1325        self.progress_ratio() as f32
1326    }
1327
1328    /// Scroll up by the given number of rows, clamped to 0.
1329    pub fn scroll_up(&mut self, amount: usize) {
1330        self.offset = self.offset.saturating_sub(amount);
1331    }
1332
1333    /// Scroll down by the given number of rows, clamped to the maximum offset.
1334    pub fn scroll_down(&mut self, amount: usize) {
1335        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
1336        self.offset = (self.offset + amount).min(max_offset);
1337    }
1338
1339    /// Set the absolute scroll offset, clamped to `[0, content - viewport]`.
1340    ///
1341    /// Uses the same `max_offset` semantics as [`scroll_down`](Self::scroll_down).
1342    /// Click-to-jump and thumb-drag in [`Context::scrollbar`] route through
1343    /// this so an out-of-range target row never leaves the offset past the
1344    /// last full screen of content. Direct `state.offset = …` writes keep
1345    /// working; this is the clamping-safe alternative.
1346    ///
1347    /// [`Context::scrollbar`]: crate::Context::scrollbar
1348    ///
1349    /// ```no_run
1350    /// # use slt::widgets::ScrollState;
1351    /// let mut scroll = ScrollState::new();
1352    /// // Bounds are populated by the `scrollable` widget each frame; on a
1353    /// // fresh state max_offset is 0 so any target clamps to 0.
1354    /// scroll.set_offset(999);
1355    /// assert_eq!(scroll.offset, 0);
1356    /// ```
1357    pub fn set_offset(&mut self, offset: usize) {
1358        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
1359        self.offset = offset.min(max_offset);
1360    }
1361
1362    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
1363        self.content_height = content_height;
1364        self.viewport_height = viewport_height;
1365    }
1366
1367    /// Update the horizontal (x-axis) bounds (#247).
1368    ///
1369    /// Called by [`Context::scroll_row`] / [`Context::scrollable`] each frame
1370    /// when the bound scrollable scrolls horizontally. The vertical
1371    /// [`set_bounds`](Self::set_bounds) is left untouched, keeping the two axes
1372    /// independent.
1373    ///
1374    /// [`Context::scroll_row`]: crate::Context::scroll_row
1375    /// [`Context::scrollable`]: crate::Context::scrollable
1376    pub(crate) fn set_bounds_x(&mut self, content_width: u32, viewport_width: u32) {
1377        self.content_width = content_width;
1378        self.viewport_width = viewport_width;
1379    }
1380
1381    /// Check if scrolling left is possible (`offset_x` is greater than 0, #247).
1382    ///
1383    /// ```no_run
1384    /// # use slt::ScrollState;
1385    /// let scroll = ScrollState::new();
1386    /// assert!(!scroll.can_scroll_left());
1387    /// ```
1388    pub fn can_scroll_left(&self) -> bool {
1389        self.offset_x > 0
1390    }
1391
1392    /// Check if scrolling right is possible (content extends past the right
1393    /// edge of the viewport, #247).
1394    ///
1395    /// ```no_run
1396    /// # use slt::ScrollState;
1397    /// let scroll = ScrollState::new();
1398    /// // A fresh state with no content cannot scroll right.
1399    /// assert!(!scroll.can_scroll_right());
1400    /// ```
1401    pub fn can_scroll_right(&self) -> bool {
1402        (self.offset_x as u32) + self.viewport_width < self.content_width
1403    }
1404
1405    /// Total horizontal content width in columns (#247).
1406    pub fn content_width(&self) -> u32 {
1407        self.content_width
1408    }
1409
1410    /// Horizontal viewport width in columns (#247).
1411    pub fn viewport_width(&self) -> u32 {
1412        self.viewport_width
1413    }
1414
1415    /// Horizontal scroll progress as a ratio in `[0.0, 1.0]` (#247).
1416    ///
1417    /// The x-axis mirror of [`progress_ratio`](Self::progress_ratio). Returns
1418    /// `0.0` when the content fits the viewport (no horizontal overflow). Feed
1419    /// it to a future horizontal scrollbar, a position readout, or a minimap.
1420    ///
1421    /// ```no_run
1422    /// # use slt::ScrollState;
1423    /// let scroll = ScrollState::new();
1424    /// let p: f64 = scroll.progress_x();
1425    /// assert!((0.0..=1.0).contains(&p));
1426    /// ```
1427    pub fn progress_x(&self) -> f64 {
1428        let max = self.content_width.saturating_sub(self.viewport_width);
1429        if max == 0 {
1430            0.0
1431        } else {
1432            self.offset_x as f64 / max as f64
1433        }
1434    }
1435
1436    /// Scroll left by the given number of columns, clamped to 0 (#247).
1437    ///
1438    /// ```no_run
1439    /// # use slt::ScrollState;
1440    /// let mut scroll = ScrollState::new();
1441    /// scroll.scroll_left(4); // clamps at 0 with no content
1442    /// assert_eq!(scroll.offset_x, 0);
1443    /// ```
1444    pub fn scroll_left(&mut self, amount: usize) {
1445        self.offset_x = self.offset_x.saturating_sub(amount);
1446    }
1447
1448    /// Scroll right by the given number of columns, clamped to the maximum
1449    /// horizontal offset (#247).
1450    ///
1451    /// ```no_run
1452    /// # use slt::ScrollState;
1453    /// let mut scroll = ScrollState::new();
1454    /// scroll.scroll_right(4); // clamps to content bounds (0 with no content)
1455    /// assert_eq!(scroll.offset_x, 0);
1456    /// ```
1457    pub fn scroll_right(&mut self, amount: usize) {
1458        let max_offset = self.content_width.saturating_sub(self.viewport_width) as usize;
1459        self.offset_x = (self.offset_x + amount).min(max_offset);
1460    }
1461
1462    /// Set the active highlight ranges. Replaces any previous highlights.
1463    ///
1464    /// Selecting the first highlight automatically when the list is non-empty
1465    /// matches the behavior of search-result navigation in code editors.
1466    pub fn set_highlights(&mut self, ranges: &[HighlightRange]) {
1467        self.highlights.clear();
1468        self.highlights.extend_from_slice(ranges);
1469        self.current_highlight = if self.highlights.is_empty() {
1470            None
1471        } else {
1472            Some(0)
1473        };
1474    }
1475
1476    /// Read-only access to the active highlight ranges.
1477    pub fn highlights(&self) -> &[HighlightRange] {
1478        &self.highlights
1479    }
1480
1481    /// Index of the currently focused highlight, if any.
1482    pub fn current_highlight(&self) -> Option<usize> {
1483        self.current_highlight
1484    }
1485
1486    /// Clear all highlights and reset the current index.
1487    pub fn clear_highlights(&mut self) {
1488        self.highlights.clear();
1489        self.current_highlight = None;
1490    }
1491
1492    /// Advance to the next highlight, scrolling the viewport to show it.
1493    /// Wraps from last to first.
1494    pub fn highlight_next(&mut self) {
1495        if self.highlights.is_empty() {
1496            return;
1497        }
1498        let next = match self.current_highlight {
1499            Some(i) => (i + 1) % self.highlights.len(),
1500            None => 0,
1501        };
1502        self.current_highlight = Some(next);
1503        self.scroll_to_current_highlight();
1504    }
1505
1506    /// Move to the previous highlight, scrolling the viewport to show it.
1507    /// Wraps from first to last.
1508    pub fn highlight_previous(&mut self) {
1509        if self.highlights.is_empty() {
1510            return;
1511        }
1512        let next = match self.current_highlight {
1513            Some(i) => {
1514                if i == 0 {
1515                    self.highlights.len() - 1
1516                } else {
1517                    i - 1
1518                }
1519            }
1520            None => 0,
1521        };
1522        self.current_highlight = Some(next);
1523        self.scroll_to_current_highlight();
1524    }
1525
1526    /// Scroll the viewport so the currently focused highlight is visible
1527    /// with one line of context above when possible.
1528    pub fn scroll_to_current_highlight(&mut self) {
1529        let Some(idx) = self.current_highlight else {
1530            return;
1531        };
1532        let Some(range) = self.highlights.get(idx).copied() else {
1533            return;
1534        };
1535        let target = range.start_line;
1536        let viewport = self.viewport_height as usize;
1537        let content = self.content_height as usize;
1538        let max_offset = content.saturating_sub(viewport);
1539        if target < self.offset {
1540            self.offset = target.saturating_sub(1).min(max_offset);
1541        } else if viewport > 0 && target >= self.offset + viewport {
1542            let desired = target + 2;
1543            let new_offset = desired.saturating_sub(viewport);
1544            self.offset = new_offset.min(max_offset);
1545        } else if self.offset > max_offset {
1546            self.offset = max_offset;
1547        }
1548    }
1549}
1550
1551impl Default for ScrollState {
1552    fn default() -> Self {
1553        Self::new()
1554    }
1555}
1556
1557/// State for a [`crate::Context::split_pane`] /
1558/// [`crate::Context::vsplit_pane`] container.
1559///
1560/// Tracks the split ratio and drag state. Pass a mutable reference each frame
1561/// — the widget updates `ratio` in place when the user drags the handle or
1562/// presses arrow keys with the handle focused.
1563#[derive(Debug, Clone, PartialEq)]
1564pub struct SplitPaneState {
1565    /// Fraction of space given to the first pane. Clamped to
1566    /// `[min_ratio, 1.0 - min_ratio]`.
1567    pub ratio: f64,
1568    /// Whether the handle is currently being dragged.
1569    pub dragging: bool,
1570    /// Minimum fraction allocated to either pane. Default: `0.10`.
1571    pub min_ratio: f64,
1572}
1573
1574/// Default minimum fraction of either pane, used by [`SplitPaneState::new`].
1575///
1576/// Crate-internal: there is no public path that benefits from constructing
1577/// with this constant — call [`SplitPaneState::new`] for the default (0.10)
1578/// or [`SplitPaneState::with_min_ratio`] to override per-instance.
1579pub(crate) const DEFAULT_SPLIT_MIN_RATIO: f64 = 0.10;
1580
1581impl SplitPaneState {
1582    /// Create split state with the given initial ratio, clamped to
1583    /// `[DEFAULT_SPLIT_MIN_RATIO, 1.0 - DEFAULT_SPLIT_MIN_RATIO]` (default
1584    /// `[0.10, 0.90]`).
1585    pub fn new(ratio: f64) -> Self {
1586        let min_ratio = DEFAULT_SPLIT_MIN_RATIO;
1587        let clamped = ratio.clamp(min_ratio, 1.0 - min_ratio);
1588        Self {
1589            ratio: clamped,
1590            dragging: false,
1591            min_ratio,
1592        }
1593    }
1594
1595    /// Override the minimum ratio for either pane (clamped to `[0.0, 0.49]`).
1596    pub fn with_min_ratio(mut self, min: f64) -> Self {
1597        self.min_ratio = min.clamp(0.0, 0.49);
1598        self.ratio = self.ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
1599        self
1600    }
1601
1602    /// Set the ratio, clamped to `[min_ratio, 1.0 - min_ratio]`.
1603    pub fn set_ratio(&mut self, ratio: f64) {
1604        self.ratio = ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
1605    }
1606}
1607
1608impl Default for SplitPaneState {
1609    fn default() -> Self {
1610        Self::new(0.5)
1611    }
1612}
1613
1614/// Column specification for [`crate::Context::grid_with()`].
1615///
1616/// Controls the width allocation of individual columns in a grid layout.
1617///
1618/// # Example
1619///
1620/// ```no_run
1621/// use slt::GridColumn;
1622/// # slt::run(|ui: &mut slt::Context| {
1623/// ui.grid_with(&[
1624///     GridColumn::Fixed(8),   // label column: exactly 8 chars
1625///     GridColumn::Grow(1),    // flexible column
1626///     GridColumn::Grow(1),    // flexible column
1627///     GridColumn::Fixed(4),   // status column: exactly 4 chars
1628/// ], |ui| {
1629///     // children placed left-to-right, wrapping to next row
1630/// });
1631/// # });
1632/// ```
1633#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1634pub enum GridColumn {
1635    /// Equal-width column with grow weight 1 (default `grid()` behavior).
1636    Auto,
1637    /// Fixed-width column in character cells. Does not grow or shrink.
1638    Fixed(u32),
1639    /// Flexible column with a custom grow weight. Higher values take
1640    /// proportionally more space.
1641    Grow(u16),
1642    /// Column sized as a percentage (1–100) of the grid width.
1643    Percent(u8),
1644}
1645
1646#[cfg(test)]
1647mod table_v021_width_tests {
1648    use super::TableColumn;
1649    use super::TableState;
1650
1651    fn resolved(specs: &[TableColumn], content: &str, available: u32) -> u32 {
1652        let mut state = TableState::new(vec!["H"], vec![vec![content]]);
1653        state.column_widths_spec(specs);
1654        state.recompute_widths();
1655        state.resolve_column_widths(available);
1656        state.column_widths()[0]
1657    }
1658
1659    #[test]
1660    fn fixed_overrides_content() {
1661        assert_eq!(resolved(&[TableColumn::Fixed(5)], "averylongcell", 80), 5);
1662        assert_eq!(resolved(&[TableColumn::Fixed(20)], "x", 80), 20);
1663    }
1664
1665    #[test]
1666    fn min_floors_content() {
1667        // Content/header width is at most 1 here; Min raises it to 10.
1668        assert_eq!(resolved(&[TableColumn::Min(10)], "x", 80), 10);
1669        // Content already exceeds the floor -> unchanged.
1670        assert_eq!(resolved(&[TableColumn::Min(2)], "abcdef", 80), 6);
1671    }
1672
1673    #[test]
1674    fn max_caps_content() {
1675        assert_eq!(resolved(&[TableColumn::Max(4)], "abcdefghij", 80), 4);
1676        // Content below the cap -> unchanged.
1677        assert_eq!(resolved(&[TableColumn::Max(10)], "abc", 80), 3);
1678    }
1679
1680    #[test]
1681    fn percent_of_available() {
1682        let mut state = TableState::new(vec!["A", "B"], vec![vec!["x", "y"]]);
1683        state.column_widths_spec(&[TableColumn::Percent(50), TableColumn::Percent(50)]);
1684        state.recompute_widths();
1685        state.resolve_column_widths(40);
1686        assert_eq!(state.column_widths(), &[20, 20]);
1687    }
1688
1689    #[test]
1690    fn auto_equals_content_width() {
1691        // No spec -> resolve is a no-op and width is the content width.
1692        assert_eq!(resolved(&[], "hello", 80), 5);
1693        assert_eq!(resolved(&[TableColumn::Auto], "hello", 80), 5);
1694    }
1695
1696    #[test]
1697    fn select_range_fills_inclusive() {
1698        let mut state = TableState::new(vec!["N"], vec![vec!["a"]; 5]);
1699        state.select_range(1, 3);
1700        let mut got: Vec<usize> = state.multi_selected.iter().copied().collect();
1701        got.sort_unstable();
1702        assert_eq!(got, vec![1, 2, 3]);
1703        // Reversed args produce the same inclusive set.
1704        state.select_range(3, 1);
1705        let mut got: Vec<usize> = state.multi_selected.iter().copied().collect();
1706        got.sort_unstable();
1707        assert_eq!(got, vec![1, 2, 3]);
1708    }
1709
1710    #[test]
1711    fn toggle_row_inserts_then_removes() {
1712        let mut state = TableState::new(vec!["N"], vec![vec!["a"]; 3]);
1713        state.toggle_row(1);
1714        assert!(state.is_row_selected(1));
1715        state.toggle_row(1);
1716        assert!(!state.is_row_selected(1));
1717    }
1718
1719    proptest::proptest! {
1720        #[test]
1721        fn fixed_min_max_invariants(
1722            content_len in 0usize..40,
1723            spec_kind in 0u8..4,
1724            n in 0u32..30,
1725            available in 1u32..200,
1726        ) {
1727            let content: String = "x".repeat(content_len);
1728            let spec = match spec_kind {
1729                0 => TableColumn::Fixed(n),
1730                1 => TableColumn::Min(n),
1731                2 => TableColumn::Max(n),
1732                _ => TableColumn::Auto,
1733            };
1734            let w = resolved(&[spec], &content, available);
1735            match spec {
1736                TableColumn::Fixed(n) => proptest::prop_assert_eq!(w, n),
1737                TableColumn::Min(n) => proptest::prop_assert!(w >= n),
1738                TableColumn::Max(n) => proptest::prop_assert!(w <= n),
1739                _ => {}
1740            }
1741        }
1742
1743        #[test]
1744        fn percent_columns_never_exceed_available(
1745            pcts in proptest::collection::vec(1u8..=100, 1..6),
1746            available in 1u32..200,
1747        ) {
1748            let cols = pcts.len();
1749            let headers: Vec<String> = (0..cols).map(|i| format!("H{i}")).collect();
1750            let row: Vec<String> = (0..cols).map(|_| "v".to_string()).collect();
1751            let mut state = TableState::new(headers, vec![row]);
1752            let specs: Vec<TableColumn> = pcts.iter().map(|&p| TableColumn::Percent(p)).collect();
1753            state.column_widths_spec(&specs);
1754            state.recompute_widths();
1755            state.resolve_column_widths(available);
1756            // Each Percent column is floor(available * pct / 100) <= available.
1757            for (&w, &p) in state.column_widths().iter().zip(pcts.iter()) {
1758                let expected = (available.saturating_mul(p as u32)) / 100;
1759                proptest::prop_assert_eq!(w, expected);
1760                proptest::prop_assert!(w <= available);
1761            }
1762        }
1763    }
1764}
1765
1766#[cfg(test)]
1767mod list_state_height_tests {
1768    use super::ListState;
1769
1770    #[test]
1771    fn row_prefix_is_cumulative_sum() {
1772        let mut state = ListState::new(vec!["a", "b", "c", "d"]);
1773        state.set_item_heights(vec![2, 1, 3, 1]);
1774        state.ensure_row_prefix();
1775        // row_prefix[i] = total rows occupied by items 0..i.
1776        assert_eq!(state.row_prefix(), &[0, 2, 3, 6, 7]);
1777        // item_height reflects the stored (clamped) heights.
1778        assert_eq!(state.item_height(0), 2);
1779        assert_eq!(state.item_height(2), 3);
1780    }
1781
1782    #[test]
1783    fn heights_below_one_are_clamped() {
1784        let mut state = ListState::new(vec!["a", "b", "c"]);
1785        state.set_item_heights(vec![0, 0, 0]);
1786        state.ensure_row_prefix();
1787        assert_eq!(state.row_prefix(), &[0, 1, 2, 3]);
1788        assert_eq!(state.item_height(0), 1);
1789    }
1790
1791    #[test]
1792    fn dirty_gate_skips_rebuild_when_unchanged() {
1793        let mut state = ListState::new(vec!["a", "b"]);
1794        state.set_item_heights(vec![3, 2]);
1795        state.ensure_row_prefix();
1796        assert_eq!(state.row_prefix(), &[0, 3, 5]);
1797        // heights_dirty is now false; a second call must be a no-op and leave
1798        // the prefix intact (no panic, no recompute that changes the result).
1799        assert!(!state.heights_dirty);
1800        state.ensure_row_prefix();
1801        assert_eq!(state.row_prefix(), &[0, 3, 5]);
1802    }
1803
1804    #[test]
1805    fn no_heights_falls_back_to_uniform() {
1806        let mut state = ListState::new(vec!["a", "b", "c"]);
1807        assert!(!state.has_item_heights());
1808        state.ensure_row_prefix();
1809        assert_eq!(state.row_prefix(), &[0, 1, 2, 3]);
1810        assert_eq!(state.item_height(0), 1);
1811    }
1812
1813    #[test]
1814    fn clear_reverts_to_uniform() {
1815        let mut state = ListState::new(vec!["a", "b"]).with_item_heights(vec![4, 2]);
1816        state.ensure_row_prefix();
1817        assert_eq!(state.row_prefix(), &[0, 4, 6]);
1818        state.clear_item_heights();
1819        assert!(!state.has_item_heights());
1820        state.ensure_row_prefix();
1821        assert_eq!(state.row_prefix(), &[0, 1, 2]);
1822    }
1823
1824    #[test]
1825    fn set_items_marks_dirty_and_resizes_prefix() {
1826        let mut state = ListState::new(vec!["a", "b", "c"]).with_item_heights(vec![2, 2, 2]);
1827        state.ensure_row_prefix();
1828        assert_eq!(state.row_prefix(), &[0, 2, 4, 6]);
1829        // Replacing items must invalidate the stale prefix.
1830        state.set_items(vec!["x", "y"]);
1831        assert!(state.heights_dirty);
1832        state.ensure_row_prefix();
1833        // item_heights still carries 3 entries; items now has 2 → height 1 for
1834        // out-of-range indices is not consulted, the prefix matches the 2 items.
1835        assert_eq!(state.row_prefix(), &[0, 2, 4]);
1836    }
1837}
1838
1839#[cfg(test)]
1840mod scroll_state_progress_tests {
1841    use super::ScrollState;
1842
1843    /// Build a state with the bounds the `scrollable` widget would set, plus an
1844    /// offset, so `progress_ratio` exercises a realistic non-zero ratio.
1845    fn scrolled(content_height: u32, viewport_height: u32, offset: usize) -> ScrollState {
1846        let mut state = ScrollState::new();
1847        state.set_bounds(content_height, viewport_height);
1848        state.offset = offset;
1849        state
1850    }
1851
1852    #[test]
1853    fn progress_ratio_returns_f64_in_unit_range() {
1854        // Top of a scrollable region → 0.0.
1855        let top = scrolled(100, 20, 0);
1856        let ratio: f64 = top.progress_ratio();
1857        assert_eq!(ratio, 0.0);
1858
1859        // Halfway through the scrollable range (offset 40 of max 80) → 0.5.
1860        let mid = scrolled(100, 20, 40);
1861        assert_eq!(mid.progress_ratio(), 0.5);
1862
1863        // Fully scrolled (offset == max) → 1.0.
1864        let bottom = scrolled(100, 20, 80);
1865        assert_eq!(bottom.progress_ratio(), 1.0);
1866    }
1867
1868    #[test]
1869    fn progress_ratio_is_zero_when_content_fits_viewport() {
1870        // No overflow → no scroll range → 0.0 (and no divide-by-zero).
1871        let fits = scrolled(20, 20, 0);
1872        assert_eq!(fits.progress_ratio(), 0.0);
1873
1874        let smaller = scrolled(10, 20, 5);
1875        assert_eq!(smaller.progress_ratio(), 0.0);
1876    }
1877
1878    #[test]
1879    fn progress_ratio_preserves_f64_precision() {
1880        // 1/3 is lossy in f32; the f64 surface keeps more digits than `as f32`.
1881        let third = scrolled(40, 10, 10); // max = 30, offset = 10 → 1/3
1882        let ratio = third.progress_ratio();
1883        assert!((ratio - 1.0 / 3.0).abs() < 1e-12);
1884    }
1885
1886    #[test]
1887    #[allow(deprecated)]
1888    fn deprecated_progress_delegates_to_progress_ratio() {
1889        // The deprecated f32 alias must agree with the f64 source within f32 epsilon.
1890        let state = scrolled(100, 20, 40);
1891        let expected = state.progress_ratio() as f32;
1892        assert_eq!(state.progress(), expected);
1893        assert!((state.progress() - 0.5).abs() < f32::EPSILON);
1894    }
1895}