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    items: Vec<String>,
9    /// Index of the currently selected item.
10    pub selected: usize,
11    /// Case-insensitive substring filter applied to list items.
12    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> = items.iter().map(|s| s.to_lowercase()).collect();
49        let len = items.len();
50        Self {
51            items,
52            selected: 0,
53            filter: String::new(),
54            viewport_offset: 0,
55            viewport_row_offset: 0,
56            item_heights: None,
57            row_prefix: Vec::new(),
58            heights_dirty: true,
59            view_indices: (0..len).collect(),
60            item_search_cache,
61        }
62    }
63
64    /// Replace the list items and rebuild the view index.
65    ///
66    /// Use this instead of assigning `items` directly to ensure the internal
67    /// filter/view state stays consistent.
68    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
69        self.items = items.into_iter().map(Into::into).collect();
70        self.rebuild_item_caches();
71    }
72
73    /// Return all items in their underlying, unfiltered order.
74    pub fn items(&self) -> &[String] {
75        &self.items
76    }
77
78    /// Return the number of underlying items.
79    pub fn len(&self) -> usize {
80        self.items.len()
81    }
82
83    /// Return whether the underlying list is empty.
84    pub fn is_empty(&self) -> bool {
85        self.items.is_empty()
86    }
87
88    /// Return an item by underlying data index.
89    pub fn item(&self, index: usize) -> Option<&str> {
90        self.items.get(index).map(String::as_str)
91    }
92
93    /// Append an item and rebuild cache-coupled view state.
94    pub fn push_item(&mut self, item: impl Into<String>) {
95        self.items.push(item.into());
96        self.rebuild_item_caches();
97    }
98
99    /// Insert an item at a data index. Returns `false` when out of bounds.
100    pub fn insert_item(&mut self, index: usize, item: impl Into<String>) -> bool {
101        if index > self.items.len() {
102            return false;
103        }
104        self.items.insert(index, item.into());
105        self.rebuild_item_caches();
106        true
107    }
108
109    /// Remove and return an item by data index.
110    pub fn remove_item(&mut self, index: usize) -> Option<String> {
111        if index >= self.items.len() {
112            return None;
113        }
114        let item = self.items.remove(index);
115        self.rebuild_item_caches();
116        Some(item)
117    }
118
119    /// Remove all items and reset cache-coupled selection and viewport state.
120    pub fn clear_items(&mut self) {
121        self.items.clear();
122        self.rebuild_item_caches();
123    }
124
125    /// Return the active filter text.
126    pub fn filter(&self) -> &str {
127        &self.filter
128    }
129
130    /// Provide a per-item row height (each clamped to `>= 1`) and return `self`.
131    ///
132    /// Enables variable-height virtualization via
133    /// [`Context::virtual_list_variable`](crate::Context::virtual_list_variable),
134    /// the chat/feed bubble use case where each item occupies a different
135    /// number of rows. Each entry corresponds to the item at the same index;
136    /// missing entries fall back to a height of `1`.
137    ///
138    /// # Example
139    ///
140    /// ```no_run
141    /// use slt::widgets::ListState;
142    ///
143    /// let state = ListState::new(vec!["short", "a\nthree\nline bubble", "ok"])
144    ///     .with_item_heights(vec![1, 3, 1]);
145    /// # let _ = state;
146    /// ```
147    ///
148    /// Available since `0.21.0`.
149    pub fn with_item_heights(mut self, heights: Vec<u32>) -> Self {
150        self.set_item_heights(heights);
151        self
152    }
153
154    /// Set per-item row heights (each clamped to `>= 1`).
155    ///
156    /// Marks the cached prefix sum dirty so it is rebuilt on the next render.
157    /// Length should match [`items`](ListState::items); missing entries fall
158    /// back to a height of `1` and extra entries are ignored.
159    ///
160    /// # Example
161    ///
162    /// ```no_run
163    /// use slt::widgets::ListState;
164    ///
165    /// let mut state = ListState::new(vec!["a", "b", "c"]);
166    /// state.set_item_heights(vec![2, 1, 4]);
167    /// # let _ = state;
168    /// ```
169    ///
170    /// Available since `0.21.0`.
171    pub fn set_item_heights(&mut self, heights: Vec<u32>) {
172        self.item_heights = Some(heights.into_iter().map(|h| h.max(1)).collect());
173        self.heights_dirty = true;
174    }
175
176    /// Clear per-item heights, reverting to the uniform one-row-per-item model.
177    ///
178    /// After this call [`Context::virtual_list_variable`](crate::Context::virtual_list_variable)
179    /// behaves identically to [`Context::virtual_list`](crate::Context::virtual_list).
180    ///
181    /// # Example
182    ///
183    /// ```no_run
184    /// use slt::widgets::ListState;
185    ///
186    /// let mut state = ListState::new(vec!["a", "b"]).with_item_heights(vec![3, 2]);
187    /// state.clear_item_heights();
188    /// # let _ = state;
189    /// ```
190    ///
191    /// Available since `0.21.0`.
192    pub fn clear_item_heights(&mut self) {
193        self.item_heights = None;
194        self.heights_dirty = true;
195    }
196
197    /// Whether per-item heights are currently set.
198    pub(crate) fn has_item_heights(&self) -> bool {
199        self.item_heights.is_some()
200    }
201
202    /// Height of item `idx` in rows (`1` when no per-item heights are set or the
203    /// index has no explicit height).
204    pub(crate) fn item_height(&self, idx: usize) -> u32 {
205        self.item_heights
206            .as_ref()
207            .and_then(|h| h.get(idx).copied())
208            .unwrap_or(1)
209    }
210
211    /// Rebuild `row_prefix` if dirty. After this call `row_prefix[i]` is the
212    /// total number of rows occupied by items `0..i`, and
213    /// `row_prefix.len() == items.len() + 1`. Rebuild is `O(n)` and skipped
214    /// entirely when `heights_dirty` is `false`.
215    pub(crate) fn ensure_row_prefix(&mut self) {
216        if !self.heights_dirty && self.row_prefix.len() == self.items.len() + 1 {
217            return;
218        }
219        let n = self.items.len();
220        self.row_prefix.clear();
221        self.row_prefix.reserve(n + 1);
222        let mut acc = 0u32;
223        self.row_prefix.push(0);
224        for i in 0..n {
225            acc = acc.saturating_add(self.item_height(i));
226            self.row_prefix.push(acc);
227        }
228        self.heights_dirty = false;
229    }
230
231    /// Read-only access to the cached prefix sum (test/helper use).
232    pub(crate) fn row_prefix(&self) -> &[u32] {
233        &self.row_prefix
234    }
235
236    /// Set the filter string. Multiple space-separated tokens are AND'd
237    /// together — all tokens must match across any cell in the same row.
238    /// Empty string disables filtering.
239    pub fn set_filter(&mut self, filter: impl Into<String>) {
240        self.filter = filter.into();
241        self.rebuild_view();
242    }
243
244    /// Returns indices of items visible after filtering.
245    pub fn visible_indices(&self) -> &[usize] {
246        &self.view_indices
247    }
248
249    /// Get the currently selected item text, or `None` if the list is empty.
250    pub fn selected_item(&self) -> Option<&str> {
251        let data_idx = *self.view_indices.get(self.selected)?;
252        self.items.get(data_idx).map(String::as_str)
253    }
254
255    /// Move the item at data index `from` to data index `to`, preserving
256    /// selection on the moved item.
257    ///
258    /// Indices address the underlying [`items`](ListState::items) vector (the
259    /// unfiltered order), not the filtered view. Out-of-range indices and a
260    /// no-op `from == to` move leave the list untouched and return `false`.
261    /// The parallel search cache and any per-item heights are kept in sync, and
262    /// the filtered view is rebuilt so `selected` continues to point at the item
263    /// that was moved when it remains visible.
264    ///
265    /// # Example
266    ///
267    /// ```
268    /// use slt::widgets::ListState;
269    ///
270    /// let mut state = ListState::new(vec!["a", "b", "c"]);
271    /// assert!(state.move_item(0, 2));
272    /// assert_eq!(state.selected_item(), Some("a"));
273    /// ```
274    ///
275    /// Available since `0.21.1`.
276    pub fn move_item(&mut self, from: usize, to: usize) -> bool {
277        let len = self.items.len();
278        if from >= len || to >= len || from == to {
279            return false;
280        }
281
282        // Remember which data index is currently selected so selection can
283        // follow the moved item (or stay on whatever item the user had).
284        let selected_data = self.view_indices.get(self.selected).copied();
285
286        let item = self.items.remove(from);
287        self.items.insert(to, item);
288
289        // Keep the lowercase search cache aligned with `items`.
290        if from < self.item_search_cache.len() {
291            let cached = self.item_search_cache.remove(from);
292            self.item_search_cache
293                .insert(to.min(self.item_search_cache.len()), cached);
294        }
295
296        // Keep per-item heights aligned with `items` when present.
297        if let Some(heights) = self.item_heights.as_mut()
298            && from < heights.len()
299        {
300            let h = heights.remove(from);
301            heights.insert(to.min(heights.len()), h);
302        }
303        self.heights_dirty = true;
304
305        self.rebuild_view();
306
307        // Re-point `selected` at the same data item if it is still visible.
308        if let Some(data_idx) = selected_data {
309            // The moved item's data index is now `to`; anything that was
310            // `selected` shifts with the rotation, so re-derive from data idx.
311            let new_data_idx = if data_idx == from {
312                to
313            } else if from < to && data_idx > from && data_idx <= to {
314                data_idx - 1
315            } else if to < from && data_idx >= to && data_idx < from {
316                data_idx + 1
317            } else {
318                data_idx
319            };
320            if let Some(view_pos) = self.view_indices.iter().position(|&i| i == new_data_idx) {
321                self.selected = view_pos;
322            }
323        }
324
325        true
326    }
327
328    fn rebuild_view(&mut self) {
329        let tokens: Vec<String> = self
330            .filter
331            .split_whitespace()
332            .map(|t| t.to_lowercase())
333            .collect();
334        self.view_indices = if tokens.is_empty() {
335            (0..self.items.len()).collect()
336        } else {
337            (0..self.items.len())
338                .filter(|&i| {
339                    let cached = match self.item_search_cache.get(i) {
340                        Some(s) => s.as_str(),
341                        None => return false,
342                    };
343                    tokens.iter().all(|token| cached.contains(token.as_str()))
344                })
345                .collect()
346        };
347        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
348            self.selected = self.view_indices.len() - 1;
349        } else if self.view_indices.is_empty() {
350            self.selected = 0;
351        }
352    }
353
354    fn rebuild_item_caches(&mut self) {
355        self.item_search_cache = self.items.iter().map(|item| item.to_lowercase()).collect();
356        if let Some(heights) = self.item_heights.as_mut() {
357            heights.truncate(self.items.len());
358        }
359        self.heights_dirty = true;
360        self.viewport_offset = self.viewport_offset.min(self.items.len().saturating_sub(1));
361        self.rebuild_view();
362    }
363}
364
365/// Response from [`Context::list_reorderable`](crate::Context::list_reorderable).
366///
367/// Wraps the row-level [`Response`] (selection/hover/rect/focus) and additionally
368/// exposes the `(from, to)` data indices of an item that was reordered this frame
369/// via the keyboard. Implements `Deref<Target = Response>` so `r.changed`,
370/// `r.hovered`, `r.rect`, etc. work directly.
371///
372/// # Example
373///
374/// ```no_run
375/// # use slt::widgets::ListState;
376/// # let mut list = ListState::new(vec!["a", "b", "c"]);
377/// # slt::run(move |ui: &mut slt::Context| {
378/// let r = ui.list_reorderable(&mut list);
379/// if let Some((from, to)) = r.reordered {
380///     // persist the new order: item moved from `from` to `to`
381///     let _ = (from, to);
382/// }
383/// # });
384/// ```
385///
386/// Available since `0.21.1`.
387#[derive(Debug, Clone, Default)]
388#[must_use = "ListResponse contains interaction state — check .reordered, .changed, or .hovered"]
389pub struct ListResponse {
390    /// The row-level interaction response (selection change, hover, rect, focus).
391    pub response: Response,
392    /// `(from, to)` data indices of the item moved this frame, if any.
393    pub reordered: Option<(usize, usize)>,
394}
395
396impl std::ops::Deref for ListResponse {
397    type Target = Response;
398    fn deref(&self) -> &Response {
399        &self.response
400    }
401}
402
403/// Filesystem operation that failed during a file-picker scan.
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum FilePickerScanOperation {
406    /// Opening the root directory failed. No replacement listing is produced.
407    ReadDirectory,
408    /// Reading one entry from an open directory failed. Other entries are kept.
409    ReadEntry,
410    /// Inspecting an entry's file type failed. That entry is skipped.
411    ReadFileType,
412    /// Reading file metadata failed. The entry is kept with an unknown size.
413    ReadMetadata,
414}
415
416/// A structured failure captured while scanning a file-picker directory.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct FilePickerScanError {
419    /// Operation that failed.
420    pub operation: FilePickerScanOperation,
421    /// Path associated with the failure.
422    pub path: PathBuf,
423    /// Portable I/O error category.
424    pub kind: std::io::ErrorKind,
425    /// Original operating-system error message.
426    pub message: String,
427}
428
429impl std::fmt::Display for FilePickerScanError {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        write!(
432            f,
433            "{}: {} ({})",
434            self.path.display(),
435            self.message,
436            match self.operation {
437                FilePickerScanOperation::ReadDirectory => "read directory",
438                FilePickerScanOperation::ReadEntry => "read entry",
439                FilePickerScanOperation::ReadFileType => "read file type",
440                FilePickerScanOperation::ReadMetadata => "read metadata",
441            }
442        )
443    }
444}
445
446impl std::error::Error for FilePickerScanError {}
447
448/// Current freshness of a file-picker directory listing.
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum FilePickerScanStatus {
451    /// The directory has not been scanned yet.
452    Pending,
453    /// The listing is complete and current.
454    Ready,
455    /// Configuration or the directory changed and the retained listing is stale.
456    Stale,
457    /// The latest scan failed fully or partially.
458    Error,
459}
460
461/// State for a file picker widget.
462///
463/// Tracks the current directory listing, filtering options, and selected file.
464#[derive(Debug, Clone)]
465pub struct FilePickerState {
466    /// Current directory being browsed.
467    pub current_dir: PathBuf,
468    /// Visible entries in the current directory.
469    pub entries: Vec<FileEntry>,
470    /// Selected entry index in `entries`.
471    pub selected: usize,
472    /// Currently selected file path, if any.
473    pub selected_file: Option<PathBuf>,
474    /// Whether dotfiles are included in the listing.
475    pub show_hidden: bool,
476    /// Allowed file extensions (lowercase, no leading dot).
477    pub extensions: Vec<String>,
478    /// Whether the directory listing needs refresh.
479    pub dirty: bool,
480    scan_status: FilePickerScanStatus,
481    scan_errors: Vec<FilePickerScanError>,
482    viewport_offset: usize,
483}
484
485/// A directory entry shown by [`FilePickerState`].
486#[derive(Debug, Clone, Default)]
487pub struct FileEntry {
488    /// File or directory name.
489    pub name: String,
490    /// Full path to the entry.
491    pub path: PathBuf,
492    /// Whether this entry is a directory.
493    pub is_dir: bool,
494    /// File size in bytes, or `None` for directories and unreadable metadata.
495    pub size: Option<u64>,
496    sort_key: String,
497}
498
499impl FilePickerState {
500    /// Create a file picker rooted at `dir`.
501    pub fn new(dir: impl Into<PathBuf>) -> Self {
502        Self {
503            current_dir: dir.into(),
504            entries: Vec::new(),
505            selected: 0,
506            selected_file: None,
507            show_hidden: false,
508            extensions: Vec::new(),
509            dirty: true,
510            scan_status: FilePickerScanStatus::Pending,
511            scan_errors: Vec::new(),
512            viewport_offset: 0,
513        }
514    }
515
516    /// Configure whether hidden files should be shown.
517    pub fn show_hidden(mut self, show: bool) -> Self {
518        self.show_hidden = show;
519        self.invalidate_scan();
520        self
521    }
522
523    /// Restrict visible files to the provided extensions.
524    pub fn extensions(mut self, exts: &[&str]) -> Self {
525        self.extensions = exts
526            .iter()
527            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
528            .filter(|ext| !ext.is_empty())
529            .collect();
530        self.invalidate_scan();
531        self
532    }
533
534    /// Return the freshness of the current directory listing.
535    pub fn scan_status(&self) -> FilePickerScanStatus {
536        self.scan_status
537    }
538
539    /// Return every failure captured by the most recent scan.
540    ///
541    /// A root `read_dir` failure produces one error and retains the previous
542    /// listing. Per-entry and metadata failures produce a best-effort listing.
543    pub fn scan_errors(&self) -> &[FilePickerScanError] {
544        &self.scan_errors
545    }
546
547    /// Return the most recent failure from the latest scan.
548    pub fn last_error(&self) -> Option<&FilePickerScanError> {
549        self.scan_errors.last()
550    }
551
552    /// Request another scan after a transient failure.
553    pub fn retry(&mut self) {
554        self.scan_errors.clear();
555        self.scan_status = if self.entries.is_empty() {
556            FilePickerScanStatus::Pending
557        } else {
558            FilePickerScanStatus::Stale
559        };
560        self.dirty = true;
561    }
562
563    pub(crate) fn invalidate_scan(&mut self) {
564        self.retry();
565        self.viewport_offset = 0;
566    }
567
568    pub(crate) fn viewport_offset(&self) -> usize {
569        self.viewport_offset
570    }
571
572    pub(crate) fn keep_selected_visible(&mut self, visible_rows: usize) {
573        if self.entries.is_empty() || visible_rows == 0 {
574            self.viewport_offset = 0;
575            return;
576        }
577        let max_offset = self.entries.len().saturating_sub(visible_rows);
578        if self.selected < self.viewport_offset {
579            self.viewport_offset = self.selected;
580        } else if self.selected >= self.viewport_offset.saturating_add(visible_rows) {
581            self.viewport_offset = self.selected + 1 - visible_rows;
582        }
583        self.viewport_offset = self.viewport_offset.min(max_offset);
584    }
585
586    /// Return the currently selected file path, if any.
587    ///
588    /// Disambiguates from the [`selected: usize`](Self::selected) field, which
589    /// is the entry index into [`entries`](Self::entries). This method returns
590    /// the resolved file path that the user picked via Enter — `None` until a
591    /// file (not a directory) is selected.
592    ///
593    /// # Example
594    ///
595    /// ```no_run
596    /// # use slt::widgets::FilePickerState;
597    /// # slt::run(|ui: &mut slt::Context| {
598    /// let mut state = FilePickerState::new(".");
599    /// if ui.file_picker(&mut state).changed {
600    ///     if let Some(path) = state.selected_file() {
601    ///         println!("picked: {}", path.display());
602    ///     }
603    /// }
604    /// # });
605    /// ```
606    pub fn selected_file(&self) -> Option<&PathBuf> {
607        self.selected_file.as_ref()
608    }
609
610    /// Return the currently selected file path.
611    ///
612    /// Deprecated alias for [`selected_file`](Self::selected_file). The
613    /// shorter name conflicts visually with the [`selected: usize`](Self::selected)
614    /// field — a getter returning a path alongside a public field returning
615    /// an index made call sites ambiguous. Migrate to `selected_file()` for
616    /// new code; this stub stays callable until v1.0.
617    #[deprecated(
618        since = "0.20.0",
619        note = "use selected_file() — disambiguates from the `selected: usize` field index"
620    )]
621    pub fn selected(&self) -> Option<&PathBuf> {
622        self.selected_file()
623    }
624
625    /// Re-scan the current directory, retaining failures in [`scan_errors`](Self::scan_errors).
626    ///
627    /// This compatibility wrapper ignores the returned error. Use
628    /// [`try_refresh`](Self::try_refresh) when the caller needs immediate
629    /// fallible control flow.
630    pub fn refresh(&mut self) {
631        let _ = self.try_refresh();
632    }
633
634    /// Re-scan the current directory and rebuild entries.
635    ///
636    /// Root directory failures retain the previous listing. Entry-read and
637    /// file-type failures skip only the affected entry. Metadata failures keep
638    /// the file with [`FileEntry::size`] set to `None`. Any partial failure is
639    /// returned and remains inspectable through [`scan_errors`](Self::scan_errors).
640    pub fn try_refresh(&mut self) -> Result<(), FilePickerScanError> {
641        self.try_refresh_impl(|_| {})
642    }
643
644    #[cfg(test)]
645    pub(crate) fn try_refresh_with_metadata_hook(
646        &mut self,
647        hook: impl FnMut(&std::path::Path),
648    ) -> Result<(), FilePickerScanError> {
649        self.try_refresh_impl(hook)
650    }
651
652    fn try_refresh_impl(
653        &mut self,
654        mut before_metadata: impl FnMut(&std::path::Path),
655    ) -> Result<(), FilePickerScanError> {
656        let mut entries = Vec::new();
657        let mut errors = Vec::new();
658
659        let read_dir = match fs::read_dir(&self.current_dir) {
660            Ok(read_dir) => read_dir,
661            Err(error) => {
662                let error = FilePickerScanError {
663                    operation: FilePickerScanOperation::ReadDirectory,
664                    path: self.current_dir.clone(),
665                    kind: error.kind(),
666                    message: error.to_string(),
667                };
668                self.scan_errors = vec![error.clone()];
669                self.scan_status = FilePickerScanStatus::Error;
670                self.dirty = true;
671                return Err(error);
672            }
673        };
674
675        for result in read_dir {
676            let dir_entry = match result {
677                Ok(entry) => entry,
678                Err(error) => {
679                    errors.push(FilePickerScanError {
680                        operation: FilePickerScanOperation::ReadEntry,
681                        path: self.current_dir.clone(),
682                        kind: error.kind(),
683                        message: error.to_string(),
684                    });
685                    continue;
686                }
687            };
688            let name = dir_entry.file_name().to_string_lossy().to_string();
689            if !self.show_hidden && name.starts_with('.') {
690                continue;
691            }
692
693            let file_type = match dir_entry.file_type() {
694                Ok(file_type) => file_type,
695                Err(error) => {
696                    errors.push(FilePickerScanError {
697                        operation: FilePickerScanOperation::ReadFileType,
698                        path: dir_entry.path(),
699                        kind: error.kind(),
700                        message: error.to_string(),
701                    });
702                    continue;
703                }
704            };
705            if file_type.is_symlink() {
706                continue;
707            }
708
709            let path = dir_entry.path();
710            let is_dir = file_type.is_dir();
711
712            if !is_dir && !self.extensions.is_empty() {
713                let ext = path
714                    .extension()
715                    .and_then(|e| e.to_str())
716                    .map(|e| e.to_ascii_lowercase());
717                let Some(ext) = ext else {
718                    continue;
719                };
720                if !self.extensions.iter().any(|allowed| allowed == &ext) {
721                    continue;
722                }
723            }
724
725            let size = if is_dir {
726                None
727            } else {
728                before_metadata(&path);
729                match fs::symlink_metadata(&path) {
730                    Ok(metadata) => Some(metadata.len()),
731                    Err(error) => {
732                        errors.push(FilePickerScanError {
733                            operation: FilePickerScanOperation::ReadMetadata,
734                            path: path.clone(),
735                            kind: error.kind(),
736                            message: error.to_string(),
737                        });
738                        None
739                    }
740                }
741            };
742
743            let sort_key = name.to_lowercase();
744            entries.push(FileEntry {
745                name,
746                path,
747                is_dir,
748                size,
749                sort_key,
750            });
751        }
752
753        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
754            (true, false) => std::cmp::Ordering::Less,
755            (false, true) => std::cmp::Ordering::Greater,
756            _ => a
757                .sort_key
758                .cmp(&b.sort_key)
759                .then_with(|| a.name.cmp(&b.name)),
760        });
761
762        self.entries = entries;
763        if self.entries.is_empty() {
764            self.selected = 0;
765        } else {
766            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
767        }
768        self.viewport_offset = self
769            .viewport_offset
770            .min(self.entries.len().saturating_sub(1));
771        self.scan_errors = errors;
772        if let Some(error) = self.scan_errors.last().cloned() {
773            self.scan_status = FilePickerScanStatus::Error;
774            self.dirty = true;
775            Err(error)
776        } else {
777            self.scan_status = FilePickerScanStatus::Ready;
778            self.dirty = false;
779            Ok(())
780        }
781    }
782}
783
784impl Default for FilePickerState {
785    fn default() -> Self {
786        Self::new(".")
787    }
788}
789
790/// State for a tab navigation widget.
791///
792/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
793/// keys cycle through tabs when the widget is focused.
794#[derive(Debug, Clone, Default)]
795pub struct TabsState {
796    /// The tab labels displayed in the bar.
797    pub labels: Vec<String>,
798    /// Index of the currently active tab.
799    pub selected: usize,
800}
801
802impl TabsState {
803    /// Create tabs with the given labels. The first tab is active initially.
804    pub fn new(labels: Vec<impl Into<String>>) -> Self {
805        Self {
806            labels: labels.into_iter().map(Into::into).collect(),
807            selected: 0,
808        }
809    }
810
811    /// Get the currently selected tab label, or `None` if there are no tabs.
812    pub fn selected_label(&self) -> Option<&str> {
813        self.labels.get(self.selected).map(String::as_str)
814    }
815}
816
817/// Per-column width policy for a [`TableState`].
818///
819/// Mirrors the semantics of [`GridColumn`] and
820/// [`WidthSpec`](crate::WidthSpec) for the string-grid table model. Apply a
821/// slice of these via [`TableState::column_widths_spec`]; columns without an
822/// entry (or set to [`TableColumn::Auto`]) keep the default content-derived
823/// sizing.
824///
825/// Available since v0.21.0.
826///
827/// # Example
828///
829/// ```no_run
830/// use slt::{TableColumn, widgets::TableState};
831/// # slt::run(|ui: &mut slt::Context| {
832/// let mut table = TableState::new(
833///     vec!["Name", "Status"],
834///     vec![vec!["build", "ok"]],
835/// );
836/// // Pin the status column to 6 cells, leave the name column automatic.
837/// table.column_widths_spec(&[TableColumn::Auto, TableColumn::Fixed(6)]);
838/// ui.table(&mut table);
839/// # });
840/// ```
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
842pub enum TableColumn {
843    /// Size the column to its content (header + widest cell). Default.
844    Auto,
845    /// Exact cell width in character cells. Content is padded or truncated to fit.
846    Fixed(u32),
847    /// Content width, floored at `n` cells (never narrower than `n`).
848    Min(u32),
849    /// Content width, capped at `n` cells (truncated with an ellipsis if longer).
850    Max(u32),
851    /// Width as a percentage (`1..=100`) of the available table content width.
852    Percent(u8),
853}
854
855/// State for a data table widget.
856///
857/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
858/// keys move the row selection when the widget is focused. Column widths are
859/// computed automatically from header and cell content, or constrained per
860/// column via [`column_widths_spec`](TableState::column_widths_spec).
861///
862/// Multi-row selection (Space / Shift+Up/Down / Ctrl+Space and modifier
863/// clicks) is tracked in [`multi_selected`](TableState::multi_selected); the
864/// `selected` field always remains the focused/cursor row.
865#[derive(Debug, Clone)]
866pub struct TableState {
867    /// Column header labels.
868    headers: Vec<String>,
869    /// Table rows, each a `Vec` of cell strings.
870    rows: Vec<Vec<String>>,
871    /// Focused/cursor row (view index). Unchanged single-select semantics.
872    pub selected: usize,
873    /// Multi-row selection as view indices. Empty means no multi-selection.
874    ///
875    /// Available since v0.21.0.
876    pub multi_selected: HashSet<usize>,
877    /// Range-selection anchor (view index) for Shift extension.
878    pub(crate) selection_anchor: Option<usize>,
879    /// Per-column width policy. Empty means every column is [`TableColumn::Auto`].
880    column_specs: Vec<TableColumn>,
881    column_widths: Vec<u32>,
882    /// Content-derived widths before per-column specs are resolved.
883    content_widths: Vec<u32>,
884    widths_dirty: bool,
885    /// Available content width used to resolve [`TableColumn::Percent`].
886    resolved_width: u32,
887    /// Sorted column index (`None` means no sorting).
888    pub sort_column: Option<usize>,
889    /// Sort direction (`true` for ascending).
890    pub sort_ascending: bool,
891    /// Case-insensitive substring filter applied across all cells.
892    filter: String,
893    /// Current page (0-based) when pagination is enabled.
894    pub page: usize,
895    /// Rows per page (`0` disables pagination).
896    pub page_size: usize,
897    /// Whether alternating row backgrounds are enabled.
898    pub zebra: bool,
899    view_indices: Vec<usize>,
900    row_search_cache: Vec<String>,
901    filter_tokens: Vec<String>,
902}
903
904impl Default for TableState {
905    fn default() -> Self {
906        Self {
907            headers: Vec::new(),
908            rows: Vec::new(),
909            selected: 0,
910            multi_selected: HashSet::new(),
911            selection_anchor: None,
912            column_specs: Vec::new(),
913            column_widths: Vec::new(),
914            content_widths: Vec::new(),
915            widths_dirty: true,
916            resolved_width: 0,
917            sort_column: None,
918            sort_ascending: true,
919            filter: String::new(),
920            page: 0,
921            page_size: 0,
922            zebra: false,
923            view_indices: Vec::new(),
924            row_search_cache: Vec::new(),
925            filter_tokens: Vec::new(),
926        }
927    }
928}
929
930impl TableState {
931    /// Create a table with headers and rows. Column widths are computed immediately.
932    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
933        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
934        let rows: Vec<Vec<String>> = rows
935            .into_iter()
936            .map(|r| r.into_iter().map(Into::into).collect())
937            .collect();
938        let mut state = Self {
939            headers,
940            rows,
941            selected: 0,
942            multi_selected: HashSet::new(),
943            selection_anchor: None,
944            column_specs: Vec::new(),
945            column_widths: Vec::new(),
946            content_widths: Vec::new(),
947            widths_dirty: true,
948            resolved_width: 0,
949            sort_column: None,
950            sort_ascending: true,
951            filter: String::new(),
952            page: 0,
953            page_size: 0,
954            zebra: false,
955            view_indices: Vec::new(),
956            row_search_cache: Vec::new(),
957            filter_tokens: Vec::new(),
958        };
959        state.rebuild_row_search_cache();
960        state.rebuild_view();
961        state.recompute_widths();
962        state
963    }
964
965    /// Replace all rows, preserving the selection index if possible.
966    ///
967    /// If the current selection is beyond the new row count, it is clamped to
968    /// the last row.
969    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
970        self.rows = rows
971            .into_iter()
972            .map(|r| r.into_iter().map(Into::into).collect())
973            .collect();
974        self.rebuild_row_search_cache();
975        self.rebuild_view();
976    }
977
978    /// Return column headers.
979    pub fn headers(&self) -> &[String] {
980        &self.headers
981    }
982
983    /// Replace column headers and invalidate width caches.
984    pub fn set_headers(&mut self, headers: Vec<impl Into<String>>) {
985        self.headers = headers.into_iter().map(Into::into).collect();
986        if self
987            .sort_column
988            .is_some_and(|column| column >= self.headers.len())
989        {
990            self.sort_column = None;
991            self.rebuild_view();
992        }
993        self.widths_dirty = true;
994    }
995
996    /// Return all rows in underlying data order.
997    pub fn rows(&self) -> &[Vec<String>] {
998        &self.rows
999    }
1000
1001    /// Return a row by underlying data index.
1002    pub fn row(&self, index: usize) -> Option<&[String]> {
1003        self.rows.get(index).map(Vec::as_slice)
1004    }
1005
1006    /// Return the number of underlying rows.
1007    pub fn row_count(&self) -> usize {
1008        self.rows.len()
1009    }
1010
1011    /// Return whether the table has no rows.
1012    pub fn is_empty(&self) -> bool {
1013        self.rows.is_empty()
1014    }
1015
1016    /// Append a row and rebuild cache-coupled view state.
1017    pub fn push_row(&mut self, row: Vec<impl Into<String>>) {
1018        self.rows.push(row.into_iter().map(Into::into).collect());
1019        self.rebuild_row_state();
1020    }
1021
1022    /// Insert a row at a data index. Returns `false` when out of bounds.
1023    pub fn insert_row(&mut self, index: usize, row: Vec<impl Into<String>>) -> bool {
1024        if index > self.rows.len() {
1025            return false;
1026        }
1027        self.rows
1028            .insert(index, row.into_iter().map(Into::into).collect());
1029        self.rebuild_row_state();
1030        true
1031    }
1032
1033    /// Remove and return a row by underlying data index.
1034    pub fn remove_row(&mut self, index: usize) -> Option<Vec<String>> {
1035        if index >= self.rows.len() {
1036            return None;
1037        }
1038        let row = self.rows.remove(index);
1039        self.rebuild_row_state();
1040        Some(row)
1041    }
1042
1043    /// Remove all rows and reset cache-coupled selection and pagination state.
1044    pub fn clear_rows(&mut self) {
1045        self.rows.clear();
1046        self.rebuild_row_state();
1047    }
1048
1049    /// Return the active filter text.
1050    pub fn filter(&self) -> &str {
1051        &self.filter
1052    }
1053
1054    /// Sort by a specific column index. If already sorted by this column, toggles direction.
1055    pub fn toggle_sort(&mut self, column: usize) {
1056        if self.sort_column == Some(column) {
1057            self.sort_ascending = !self.sort_ascending;
1058        } else {
1059            self.sort_column = Some(column);
1060            self.sort_ascending = true;
1061        }
1062        self.rebuild_view();
1063    }
1064
1065    /// Sort by column without toggling (always sets to ascending first).
1066    pub fn sort_by(&mut self, column: usize) {
1067        if self.sort_column == Some(column) && self.sort_ascending {
1068            return;
1069        }
1070        self.sort_column = Some(column);
1071        self.sort_ascending = true;
1072        self.rebuild_view();
1073    }
1074
1075    /// Set the filter string. Multiple space-separated tokens are AND'd
1076    /// together — all tokens must match across any cell in the same row.
1077    /// Empty string disables filtering.
1078    pub fn set_filter(&mut self, filter: impl Into<String>) {
1079        let filter = filter.into();
1080        if self.filter == filter {
1081            return;
1082        }
1083        self.filter = filter;
1084        self.filter_tokens = Self::tokenize_filter(&self.filter);
1085        self.page = 0;
1086        self.rebuild_view();
1087    }
1088
1089    /// Clear sorting.
1090    pub fn clear_sort(&mut self) {
1091        if self.sort_column.is_none() && self.sort_ascending {
1092            return;
1093        }
1094        self.sort_column = None;
1095        self.sort_ascending = true;
1096        self.rebuild_view();
1097    }
1098
1099    /// Move to the next page. Does nothing if already on the last page.
1100    pub fn next_page(&mut self) {
1101        if self.page_size == 0 {
1102            return;
1103        }
1104        let last_page = self.total_pages().saturating_sub(1);
1105        self.page = (self.page + 1).min(last_page);
1106    }
1107
1108    /// Move to the previous page. Does nothing if already on page 0.
1109    pub fn prev_page(&mut self) {
1110        self.page = self.page.saturating_sub(1);
1111    }
1112
1113    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
1114    pub fn total_pages(&self) -> usize {
1115        if self.page_size == 0 {
1116            return 1;
1117        }
1118
1119        let len = self.view_indices.len();
1120        if len == 0 {
1121            1
1122        } else {
1123            len.div_ceil(self.page_size)
1124        }
1125    }
1126
1127    /// Get the visible row indices after filtering and sorting (used internally by table()).
1128    pub fn visible_indices(&self) -> &[usize] {
1129        &self.view_indices
1130    }
1131
1132    /// Get the currently selected row data, or `None` if the table is empty.
1133    pub fn selected_row(&self) -> Option<&[String]> {
1134        if self.view_indices.is_empty() {
1135            return None;
1136        }
1137        let data_idx = self.view_indices.get(self.selected)?;
1138        self.rows.get(*data_idx).map(|r| r.as_slice())
1139    }
1140
1141    /// Set the per-column width policy.
1142    ///
1143    /// The slice is index-aligned with [`headers`](TableState::headers); a
1144    /// shorter slice leaves trailing columns at [`TableColumn::Auto`]. Passing
1145    /// an empty slice resets every column to automatic sizing.
1146    ///
1147    /// Available since v0.21.0.
1148    ///
1149    /// # Example
1150    ///
1151    /// ```no_run
1152    /// use slt::{TableColumn, widgets::TableState};
1153    /// # slt::run(|ui: &mut slt::Context| {
1154    /// let mut table = TableState::new(
1155    ///     vec!["Name", "Note"],
1156    ///     vec![vec!["a", "a very long note that should be capped"]],
1157    /// );
1158    /// table.column_widths_spec(&[TableColumn::Fixed(6), TableColumn::Max(10)]);
1159    /// ui.table(&mut table);
1160    /// # });
1161    /// ```
1162    pub fn column_widths_spec(&mut self, specs: &[TableColumn]) {
1163        self.column_specs = specs.to_vec();
1164        self.widths_dirty = true;
1165    }
1166
1167    /// Return the multi-selected rows in ascending view order.
1168    ///
1169    /// View indices are resolved against the current sort/filter view, so the
1170    /// returned slices reflect what the user sees. Stale indices (beyond the
1171    /// current view) are skipped.
1172    ///
1173    /// Available since v0.21.0.
1174    ///
1175    /// # Example
1176    ///
1177    /// ```no_run
1178    /// use slt::widgets::TableState;
1179    /// # slt::run(|ui: &mut slt::Context| {
1180    /// let mut table = TableState::new(
1181    ///     vec!["Name"],
1182    ///     vec![vec!["a"], vec!["b"]],
1183    /// );
1184    /// ui.table(&mut table);
1185    /// for row in table.selected_rows() {
1186    ///     let _ = row;
1187    /// }
1188    /// # });
1189    /// ```
1190    pub fn selected_rows(&self) -> Vec<&[String]> {
1191        let mut indices: Vec<usize> = self.multi_selected.iter().copied().collect();
1192        indices.sort_unstable();
1193        indices
1194            .iter()
1195            .filter_map(|&view_idx| self.view_indices.get(view_idx))
1196            .filter_map(|&data_idx| self.rows.get(data_idx).map(|r| r.as_slice()))
1197            .collect()
1198    }
1199
1200    /// Returns `true` if the row at `view_idx` is in the multi-selection set.
1201    ///
1202    /// Available since v0.21.0.
1203    ///
1204    /// # Example
1205    ///
1206    /// ```no_run
1207    /// use slt::widgets::TableState;
1208    /// # slt::run(|ui: &mut slt::Context| {
1209    /// let mut table = TableState::new(vec!["Name"], vec![vec!["a"]]);
1210    /// ui.table(&mut table);
1211    /// let _ = table.is_row_selected(0);
1212    /// # });
1213    /// ```
1214    pub fn is_row_selected(&self, view_idx: usize) -> bool {
1215        self.multi_selected.contains(&view_idx)
1216    }
1217
1218    /// Clear the multi-selection set and the range anchor.
1219    ///
1220    /// The focused [`selected`](TableState::selected) cursor row is unaffected.
1221    ///
1222    /// Available since v0.21.0.
1223    ///
1224    /// # Example
1225    ///
1226    /// ```no_run
1227    /// use slt::widgets::TableState;
1228    /// # slt::run(|ui: &mut slt::Context| {
1229    /// let mut table = TableState::new(vec!["Name"], vec![vec!["a"]]);
1230    /// ui.table(&mut table);
1231    /// table.clear_selection();
1232    /// # });
1233    /// ```
1234    pub fn clear_selection(&mut self) {
1235        self.multi_selected.clear();
1236        self.selection_anchor = None;
1237    }
1238
1239    /// Toggle the multi-selection state for the row at `view_idx`, and set the
1240    /// range anchor to it. Mirrors [`MultiSelectState::toggle`].
1241    pub(crate) fn toggle_row(&mut self, view_idx: usize) {
1242        if self.multi_selected.contains(&view_idx) {
1243            self.multi_selected.remove(&view_idx);
1244        } else {
1245            self.multi_selected.insert(view_idx);
1246        }
1247        self.selection_anchor = Some(view_idx);
1248    }
1249
1250    /// Replace the multi-selection with the single row at `view_idx` and reset
1251    /// the anchor to it.
1252    pub(crate) fn select_single(&mut self, view_idx: usize) {
1253        self.multi_selected.clear();
1254        self.multi_selected.insert(view_idx);
1255        self.selection_anchor = Some(view_idx);
1256    }
1257
1258    /// Select the inclusive contiguous range `[min(from,to)..=max(from,to)]`,
1259    /// replacing the current multi-selection. The anchor is left at `from`.
1260    pub(crate) fn select_range(&mut self, from: usize, to: usize) {
1261        let (lo, hi) = if from <= to { (from, to) } else { (to, from) };
1262        self.multi_selected.clear();
1263        for idx in lo..=hi {
1264            self.multi_selected.insert(idx);
1265        }
1266        self.selection_anchor = Some(from);
1267    }
1268
1269    /// Remove any multi-selection indices that are no longer valid view
1270    /// indices, and clamp the anchor. Called after the view is rebuilt.
1271    fn prune_selection(&mut self) {
1272        let view_len = self.view_indices.len();
1273        self.multi_selected.retain(|&idx| idx < view_len);
1274        if let Some(anchor) = self.selection_anchor
1275            && anchor >= view_len
1276        {
1277            self.selection_anchor = None;
1278        }
1279    }
1280
1281    /// Recompute view_indices based on current sort + filter settings.
1282    fn rebuild_view(&mut self) {
1283        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
1284
1285        if !self.filter_tokens.is_empty() {
1286            indices.retain(|&idx| {
1287                let searchable = match self.row_search_cache.get(idx) {
1288                    Some(row) => row,
1289                    None => return false,
1290                };
1291                self.filter_tokens
1292                    .iter()
1293                    .all(|token| searchable.contains(token.as_str()))
1294            });
1295        }
1296
1297        if let Some(column) = self.sort_column {
1298            indices.sort_by(|a, b| {
1299                let left = self
1300                    .rows
1301                    .get(*a)
1302                    .and_then(|row| row.get(column))
1303                    .map(String::as_str)
1304                    .unwrap_or("");
1305                let right = self
1306                    .rows
1307                    .get(*b)
1308                    .and_then(|row| row.get(column))
1309                    .map(String::as_str)
1310                    .unwrap_or("");
1311
1312                match (left.parse::<f64>(), right.parse::<f64>()) {
1313                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
1314                    _ => left
1315                        .chars()
1316                        .flat_map(char::to_lowercase)
1317                        .cmp(right.chars().flat_map(char::to_lowercase)),
1318                }
1319            });
1320
1321            if !self.sort_ascending {
1322                indices.reverse();
1323            }
1324        }
1325
1326        self.view_indices = indices;
1327
1328        if self.page_size > 0 {
1329            self.page = self.page.min(self.total_pages().saturating_sub(1));
1330        } else {
1331            self.page = 0;
1332        }
1333
1334        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
1335        self.prune_selection();
1336        self.widths_dirty = true;
1337    }
1338
1339    fn rebuild_row_search_cache(&mut self) {
1340        self.row_search_cache = self
1341            .rows
1342            .iter()
1343            .map(|row| {
1344                let mut searchable = String::new();
1345                for (idx, cell) in row.iter().enumerate() {
1346                    if idx > 0 {
1347                        searchable.push('\n');
1348                    }
1349                    searchable.extend(cell.chars().flat_map(char::to_lowercase));
1350                }
1351                searchable
1352            })
1353            .collect();
1354        self.filter_tokens = Self::tokenize_filter(&self.filter);
1355        self.widths_dirty = true;
1356    }
1357
1358    fn rebuild_row_state(&mut self) {
1359        self.rebuild_row_search_cache();
1360        self.rebuild_view();
1361    }
1362
1363    fn tokenize_filter(filter: &str) -> Vec<String> {
1364        filter
1365            .split_whitespace()
1366            .map(|t| t.to_lowercase())
1367            .collect()
1368    }
1369
1370    pub(crate) fn recompute_widths(&mut self) {
1371        // Skip when no mutation since the last computation. `widths_dirty` is
1372        // set by `rebuild_view` (covers `set_rows`, `set_filter`, sort),
1373        // `column_widths_spec`, and at construction. Frames without data
1374        // mutation become a no-op.
1375        if !self.widths_dirty {
1376            return;
1377        }
1378        let col_count = self.headers.len();
1379        self.content_widths = vec![0u32; col_count];
1380        for (i, header) in self.headers.iter().enumerate() {
1381            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
1382            if self.sort_column == Some(i) {
1383                width += 2;
1384            }
1385            self.content_widths[i] = width;
1386        }
1387        for row in &self.rows {
1388            for (i, cell) in row.iter().enumerate() {
1389                if i < col_count {
1390                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
1391                    self.content_widths[i] = self.content_widths[i].max(w);
1392                }
1393            }
1394        }
1395        // Default resolved widths to the content widths; `resolve_column_widths`
1396        // overlays the per-column specs each frame once the available width is
1397        // known. When no spec is set this is the pre-v0.21 behavior verbatim.
1398        self.column_widths = self.content_widths.clone();
1399        self.widths_dirty = false;
1400    }
1401
1402    /// Resolve per-column width specs against the content widths, using
1403    /// `available` as the total table content width for `Percent`. A no-op
1404    /// when no spec is set, so all-`Auto` tables render byte-identically.
1405    pub(crate) fn resolve_column_widths(&mut self, available: u32) {
1406        if self.column_specs.is_empty() {
1407            return;
1408        }
1409        // Re-derive base content widths if the available width changed since
1410        // the last resolution (the previous frame may have shrunk a column).
1411        if self.resolved_width != available {
1412            self.column_widths = self.content_widths.clone();
1413            self.resolved_width = available;
1414        }
1415        let col_count = self.column_widths.len();
1416        for i in 0..col_count {
1417            let content = self.content_widths.get(i).copied().unwrap_or(0);
1418            let spec = self
1419                .column_specs
1420                .get(i)
1421                .copied()
1422                .unwrap_or(TableColumn::Auto);
1423            let resolved = match spec {
1424                TableColumn::Auto => content,
1425                TableColumn::Fixed(n) => n,
1426                TableColumn::Min(n) => content.max(n),
1427                TableColumn::Max(n) => content.min(n),
1428                TableColumn::Percent(pct) => {
1429                    let pct = pct.clamp(1, 100) as u32;
1430                    (available.saturating_mul(pct)) / 100
1431                }
1432            };
1433            self.column_widths[i] = resolved;
1434        }
1435    }
1436
1437    pub(crate) fn column_widths(&self) -> &[u32] {
1438        &self.column_widths
1439    }
1440
1441    pub(crate) fn is_dirty(&self) -> bool {
1442        self.widths_dirty
1443    }
1444}
1445
1446/// Visual style for [`Context::paginator`](crate::Context::paginator).
1447///
1448/// `Dots` renders one `●`/`○` glyph per page and is the default; it falls back
1449/// to `Arabic` automatically once there are more than 12 pages so the indicator
1450/// never overflows. `Arabic` renders a compact `{page}/{total}` counter.
1451#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1452pub enum PaginatorStyle {
1453    /// One `●`/`○` glyph per page. Auto-falls back to [`Self::Arabic`] past 12 pages.
1454    #[default]
1455    Dots,
1456    /// Compact `{page}/{total}` counter.
1457    Arabic,
1458}
1459
1460/// Standalone pagination state, decoupled from any list or table.
1461///
1462/// Owns a page index over an arbitrary item count, so you can paginate a
1463/// wizard, slide deck, onboarding flow, carousel, or any non-table data. Pass a
1464/// mutable reference to [`Context::paginator`](crate::Context::paginator) each
1465/// frame; Left/`h`/PageUp move to the previous page and Right/`l`/PageDown move
1466/// to the next page when the widget is focused.
1467///
1468/// # Example
1469///
1470/// ```no_run
1471/// use slt::{PaginatorState, PaginatorStyle};
1472///
1473/// let mut state = PaginatorState::new(42, 10); // 42 items, 10 per page
1474/// state.style = PaginatorStyle::Arabic;
1475/// assert_eq!(state.total_pages(), 5);
1476/// let (start, end) = state.page_bounds(); // slice your own data with these
1477/// assert_eq!((start, end), (0, 10));
1478/// ```
1479#[derive(Debug, Clone)]
1480pub struct PaginatorState {
1481    /// Total number of items being paged over.
1482    pub total_items: usize,
1483    /// Items per page (clamped to `>= 1` internally).
1484    pub per_page: usize,
1485    /// Current page (0-based).
1486    pub page: usize,
1487    /// Rendering style.
1488    pub style: PaginatorStyle,
1489}
1490
1491impl PaginatorState {
1492    /// Create a paginator over `total_items` with `per_page` items per page.
1493    ///
1494    /// `per_page` is clamped to at least `1` internally (so a `0` argument is
1495    /// treated as `1`, avoiding division by zero). The current page starts at
1496    /// `0` and the style defaults to [`PaginatorStyle::Dots`].
1497    ///
1498    /// # Example
1499    ///
1500    /// ```no_run
1501    /// use slt::PaginatorState;
1502    ///
1503    /// let state = PaginatorState::new(30, 0); // 0 per_page -> clamped to 1
1504    /// assert_eq!(state.per_page, 1);
1505    /// assert_eq!(state.total_pages(), 30);
1506    /// ```
1507    pub fn new(total_items: usize, per_page: usize) -> Self {
1508        Self {
1509            total_items,
1510            per_page: per_page.max(1),
1511            page: 0,
1512            style: PaginatorStyle::default(),
1513        }
1514    }
1515
1516    /// Total number of pages; always `>= 1` (returns `1` when there are no items).
1517    ///
1518    /// # Example
1519    ///
1520    /// ```no_run
1521    /// use slt::PaginatorState;
1522    ///
1523    /// assert_eq!(PaginatorState::new(0, 5).total_pages(), 1);
1524    /// assert_eq!(PaginatorState::new(10, 3).total_pages(), 4);
1525    /// assert_eq!(PaginatorState::new(9, 3).total_pages(), 3);
1526    /// ```
1527    pub fn total_pages(&self) -> usize {
1528        self.total_items.div_ceil(self.per_page.max(1)).max(1)
1529    }
1530
1531    /// Inclusive-start / exclusive-end item indices for the current page.
1532    ///
1533    /// `end` is clamped to `total_items`, so callers can slice their own data
1534    /// with `&items[start..end]` without bounds-checking the tail page.
1535    ///
1536    /// # Example
1537    ///
1538    /// ```no_run
1539    /// use slt::PaginatorState;
1540    ///
1541    /// let mut state = PaginatorState::new(10, 3);
1542    /// assert_eq!(state.page_bounds(), (0, 3));
1543    /// state.set_page(3); // last (partial) page
1544    /// assert_eq!(state.page_bounds(), (9, 10));
1545    /// ```
1546    pub fn page_bounds(&self) -> (usize, usize) {
1547        let start = self
1548            .page
1549            .saturating_mul(self.per_page)
1550            .min(self.total_items);
1551        let end = start.saturating_add(self.per_page).min(self.total_items);
1552        (start, end)
1553    }
1554
1555    /// Advance one page, clamped to the last page (no wrap).
1556    ///
1557    /// # Example
1558    ///
1559    /// ```no_run
1560    /// use slt::PaginatorState;
1561    ///
1562    /// let mut state = PaginatorState::new(6, 3); // 2 pages
1563    /// state.next_page();
1564    /// assert_eq!(state.page, 1);
1565    /// state.next_page(); // already last page -> clamped
1566    /// assert_eq!(state.page, 1);
1567    /// ```
1568    pub fn next_page(&mut self) {
1569        self.page = (self.page + 1).min(self.total_pages().saturating_sub(1));
1570    }
1571
1572    /// Go back one page, clamped to `0` (no wrap).
1573    ///
1574    /// # Example
1575    ///
1576    /// ```no_run
1577    /// use slt::PaginatorState;
1578    ///
1579    /// let mut state = PaginatorState::new(6, 3);
1580    /// state.prev_page(); // already page 0 -> clamped
1581    /// assert_eq!(state.page, 0);
1582    /// ```
1583    pub fn prev_page(&mut self) {
1584        self.page = self.page.saturating_sub(1);
1585    }
1586
1587    /// Jump to a specific page, clamped into `[0, total_pages() - 1]`.
1588    ///
1589    /// # Example
1590    ///
1591    /// ```no_run
1592    /// use slt::PaginatorState;
1593    ///
1594    /// let mut state = PaginatorState::new(10, 3); // 4 pages
1595    /// state.set_page(99);
1596    /// assert_eq!(state.page, 3);
1597    /// ```
1598    pub fn set_page(&mut self, page: usize) {
1599        self.page = page.min(self.total_pages().saturating_sub(1));
1600    }
1601
1602    /// Update the item count and re-clamp the current page into range.
1603    ///
1604    /// # Example
1605    ///
1606    /// ```no_run
1607    /// use slt::PaginatorState;
1608    ///
1609    /// let mut state = PaginatorState::new(10, 3);
1610    /// state.set_page(3); // last page
1611    /// state.set_total_items(3); // now only 1 page
1612    /// assert_eq!(state.page, 0);
1613    /// ```
1614    pub fn set_total_items(&mut self, total: usize) {
1615        self.total_items = total;
1616        self.page = self.page.min(self.total_pages().saturating_sub(1));
1617    }
1618
1619    /// Update items-per-page (clamped to `>= 1`) and re-clamp the current page.
1620    ///
1621    /// # Example
1622    ///
1623    /// ```no_run
1624    /// use slt::PaginatorState;
1625    ///
1626    /// let mut state = PaginatorState::new(10, 3); // 4 pages
1627    /// state.set_page(3);
1628    /// state.set_per_page(10); // now only 1 page
1629    /// assert_eq!(state.per_page, 10);
1630    /// assert_eq!(state.page, 0);
1631    /// ```
1632    pub fn set_per_page(&mut self, per_page: usize) {
1633        self.per_page = per_page.max(1);
1634        self.page = self.page.min(self.total_pages().saturating_sub(1));
1635    }
1636}
1637
1638/// A highlighted line range within a scrollable region.
1639///
1640/// Used with [`ScrollState::set_highlights`] to mark search results, error
1641/// lines, or any per-line emphasis. The `scrollable_with_gutter` widget reads
1642/// the active highlights and renders a background band on matching lines.
1643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1644pub struct HighlightRange {
1645    /// First line (0-based, relative to content top).
1646    pub start_line: usize,
1647    /// Number of lines in the range (1 = single line).
1648    pub line_count: usize,
1649}
1650
1651impl HighlightRange {
1652    /// Create a single-line highlight at `line`.
1653    ///
1654    /// Field-name pairing: `start_line` + `line_count` → constructor named
1655    /// `line`. Use [`Self::span`] for multi-line ranges.
1656    pub fn line(line: usize) -> Self {
1657        Self {
1658            start_line: line,
1659            line_count: 1,
1660        }
1661    }
1662
1663    /// Create a multi-line highlight starting at `start_line` covering `line_count` rows.
1664    pub fn span(start_line: usize, line_count: usize) -> Self {
1665        Self {
1666            start_line,
1667            line_count: line_count.max(1),
1668        }
1669    }
1670
1671    /// Check whether the given absolute line index falls within this range.
1672    pub fn contains(&self, line: usize) -> bool {
1673        line >= self.start_line && line < self.start_line + self.line_count
1674    }
1675}
1676
1677/// State for a scrollable container.
1678///
1679/// Pass a mutable reference to `Context::scrollable` each frame. The context
1680/// updates `offset` and the internal bounds automatically based on mouse wheel
1681/// and drag events.
1682///
1683/// Both axes are tracked (#247): the vertical axis (`offset`, [`scroll_up`] /
1684/// [`scroll_down`]) drives [`Context::scroll_col`], and the horizontal axis
1685/// (`offset_x`, [`scroll_left`] / [`scroll_right`]) drives
1686/// [`Context::scroll_row`]. A single [`ScrollState`] scrolls one axis per
1687/// container — nest a `scroll_row` inside a `scroll_col` for both. The vertical
1688/// API is unchanged from earlier versions.
1689///
1690/// [`scroll_up`]: ScrollState::scroll_up
1691/// [`scroll_down`]: ScrollState::scroll_down
1692/// [`scroll_left`]: ScrollState::scroll_left
1693/// [`scroll_right`]: ScrollState::scroll_right
1694/// [`Context::scroll_col`]: crate::Context::scroll_col
1695/// [`Context::scroll_row`]: crate::Context::scroll_row
1696#[derive(Debug, Clone)]
1697pub struct ScrollState {
1698    /// Current vertical scroll offset in rows.
1699    pub offset: usize,
1700    /// Current horizontal scroll offset in columns (#247).
1701    pub offset_x: usize,
1702    /// Whether the scrollbar thumb is currently being dragged.
1703    ///
1704    /// Set to `true` by [`Context::scrollbar`] on a mouse-down inside the
1705    /// thumb and back to `false` on mouse-up, mirroring
1706    /// [`SplitPaneState::dragging`](crate::widgets::SplitPaneState). Persists
1707    /// across frames so cursor motion outside the thumb (or even outside the
1708    /// track on the x-axis) keeps scrolling while the button is held.
1709    ///
1710    /// [`Context::scrollbar`]: crate::Context::scrollbar
1711    pub dragging: bool,
1712    content_height: u32,
1713    viewport_height: u32,
1714    content_width: u32,
1715    viewport_width: u32,
1716    highlights: Vec<HighlightRange>,
1717    current_highlight: Option<usize>,
1718}
1719
1720impl ScrollState {
1721    /// Create scroll state starting at offset 0.
1722    pub fn new() -> Self {
1723        Self {
1724            offset: 0,
1725            offset_x: 0,
1726            dragging: false,
1727            content_height: 0,
1728            viewport_height: 0,
1729            content_width: 0,
1730            viewport_width: 0,
1731            highlights: Vec::new(),
1732            current_highlight: None,
1733        }
1734    }
1735
1736    /// Check if scrolling upward is possible (offset is greater than 0).
1737    pub fn can_scroll_up(&self) -> bool {
1738        self.offset > 0
1739    }
1740
1741    /// Check if scrolling downward is possible (content extends below the viewport).
1742    pub fn can_scroll_down(&self) -> bool {
1743        (self.offset as u32) + self.viewport_height < self.content_height
1744    }
1745
1746    /// Get the total content height in rows.
1747    pub fn content_height(&self) -> u32 {
1748        self.content_height
1749    }
1750
1751    /// Get the viewport height in rows.
1752    pub fn viewport_height(&self) -> u32 {
1753        self.viewport_height
1754    }
1755
1756    /// Get the scroll progress as a ratio in `[0.0, 1.0]`.
1757    ///
1758    /// Returns `f64` to match the rest of the ratio surface unified in v0.20
1759    /// (`Gauge::ratio`, `SplitPaneState::ratio`, `progress(ratio)`,
1760    /// `progress_bar(ratio)`). Feed the value straight into [`Context::gauge`]
1761    /// or [`Context::progress_bar`] without a cast.
1762    ///
1763    /// [`Context::gauge`]: crate::Context::gauge
1764    /// [`Context::progress_bar`]: crate::Context::progress_bar
1765    ///
1766    /// ```no_run
1767    /// # use slt::ScrollState;
1768    /// let scroll = ScrollState::new();
1769    /// // Bounds are populated by the `scrollable` widget each frame; a fresh
1770    /// // state with no content reports 0.0.
1771    /// let ratio: f64 = scroll.progress_ratio();
1772    /// assert!((0.0..=1.0).contains(&ratio));
1773    /// ```
1774    pub fn progress_ratio(&self) -> f64 {
1775        let max = self.content_height.saturating_sub(self.viewport_height);
1776        if max == 0 {
1777            0.0
1778        } else {
1779            self.offset as f64 / max as f64
1780        }
1781    }
1782
1783    /// Deprecated `f32` alias for [`progress_ratio`](Self::progress_ratio).
1784    ///
1785    /// `ScrollState::progress` was the only `f32` ratio left after the v0.20
1786    /// `f32 → f64` ratio unification. Migrate to [`progress_ratio`](Self::progress_ratio):
1787    /// call sites that wrapped the result in `as f64` can drop the cast, while
1788    /// call sites passing the value to `gauge` / `progress_bar` (which already
1789    /// take `f64`) need no cast at all.
1790    #[deprecated(
1791        since = "0.21.0",
1792        note = "use progress_ratio() — f64 matches the rest of the v0.20+ ratio surface (gauge/progress_bar take f64; drop any `as f64` cast)"
1793    )]
1794    pub fn progress(&self) -> f32 {
1795        self.progress_ratio() as f32
1796    }
1797
1798    /// Scroll up by the given number of rows, clamped to 0.
1799    pub fn scroll_up(&mut self, amount: usize) {
1800        self.offset = self.offset.saturating_sub(amount);
1801    }
1802
1803    /// Scroll down by the given number of rows, clamped to the maximum offset.
1804    pub fn scroll_down(&mut self, amount: usize) {
1805        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
1806        self.offset = (self.offset + amount).min(max_offset);
1807    }
1808
1809    /// Set the absolute scroll offset, clamped to `[0, content - viewport]`.
1810    ///
1811    /// Uses the same `max_offset` semantics as [`scroll_down`](Self::scroll_down).
1812    /// Click-to-jump and thumb-drag in [`Context::scrollbar`] route through
1813    /// this so an out-of-range target row never leaves the offset past the
1814    /// last full screen of content. Direct `state.offset = …` writes keep
1815    /// working; this is the clamping-safe alternative.
1816    ///
1817    /// [`Context::scrollbar`]: crate::Context::scrollbar
1818    ///
1819    /// ```no_run
1820    /// # use slt::widgets::ScrollState;
1821    /// let mut scroll = ScrollState::new();
1822    /// // Bounds are populated by the `scrollable` widget each frame; on a
1823    /// // fresh state max_offset is 0 so any target clamps to 0.
1824    /// scroll.set_offset(999);
1825    /// assert_eq!(scroll.offset, 0);
1826    /// ```
1827    pub fn set_offset(&mut self, offset: usize) {
1828        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
1829        self.offset = offset.min(max_offset);
1830    }
1831
1832    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
1833        self.content_height = content_height;
1834        self.viewport_height = viewport_height;
1835    }
1836
1837    /// Update the horizontal (x-axis) bounds (#247).
1838    ///
1839    /// Called by [`Context::scroll_row`] / [`Context::scrollable`] each frame
1840    /// when the bound scrollable scrolls horizontally. The vertical
1841    /// [`set_bounds`](Self::set_bounds) is left untouched, keeping the two axes
1842    /// independent.
1843    ///
1844    /// [`Context::scroll_row`]: crate::Context::scroll_row
1845    /// [`Context::scrollable`]: crate::Context::scrollable
1846    pub(crate) fn set_bounds_x(&mut self, content_width: u32, viewport_width: u32) {
1847        self.content_width = content_width;
1848        self.viewport_width = viewport_width;
1849    }
1850
1851    /// Check if scrolling left is possible (`offset_x` is greater than 0, #247).
1852    ///
1853    /// ```no_run
1854    /// # use slt::ScrollState;
1855    /// let scroll = ScrollState::new();
1856    /// assert!(!scroll.can_scroll_left());
1857    /// ```
1858    pub fn can_scroll_left(&self) -> bool {
1859        self.offset_x > 0
1860    }
1861
1862    /// Check if scrolling right is possible (content extends past the right
1863    /// edge of the viewport, #247).
1864    ///
1865    /// ```no_run
1866    /// # use slt::ScrollState;
1867    /// let scroll = ScrollState::new();
1868    /// // A fresh state with no content cannot scroll right.
1869    /// assert!(!scroll.can_scroll_right());
1870    /// ```
1871    pub fn can_scroll_right(&self) -> bool {
1872        (self.offset_x as u32) + self.viewport_width < self.content_width
1873    }
1874
1875    /// Total horizontal content width in columns (#247).
1876    pub fn content_width(&self) -> u32 {
1877        self.content_width
1878    }
1879
1880    /// Horizontal viewport width in columns (#247).
1881    pub fn viewport_width(&self) -> u32 {
1882        self.viewport_width
1883    }
1884
1885    /// Horizontal scroll progress as a ratio in `[0.0, 1.0]` (#247).
1886    ///
1887    /// The x-axis mirror of [`progress_ratio`](Self::progress_ratio). Returns
1888    /// `0.0` when the content fits the viewport (no horizontal overflow). Feed
1889    /// it to a future horizontal scrollbar, a position readout, or a minimap.
1890    ///
1891    /// ```no_run
1892    /// # use slt::ScrollState;
1893    /// let scroll = ScrollState::new();
1894    /// let p: f64 = scroll.progress_x();
1895    /// assert!((0.0..=1.0).contains(&p));
1896    /// ```
1897    pub fn progress_x(&self) -> f64 {
1898        let max = self.content_width.saturating_sub(self.viewport_width);
1899        if max == 0 {
1900            0.0
1901        } else {
1902            self.offset_x as f64 / max as f64
1903        }
1904    }
1905
1906    /// Scroll left by the given number of columns, clamped to 0 (#247).
1907    ///
1908    /// ```no_run
1909    /// # use slt::ScrollState;
1910    /// let mut scroll = ScrollState::new();
1911    /// scroll.scroll_left(4); // clamps at 0 with no content
1912    /// assert_eq!(scroll.offset_x, 0);
1913    /// ```
1914    pub fn scroll_left(&mut self, amount: usize) {
1915        self.offset_x = self.offset_x.saturating_sub(amount);
1916    }
1917
1918    /// Scroll right by the given number of columns, clamped to the maximum
1919    /// horizontal offset (#247).
1920    ///
1921    /// ```no_run
1922    /// # use slt::ScrollState;
1923    /// let mut scroll = ScrollState::new();
1924    /// scroll.scroll_right(4); // clamps to content bounds (0 with no content)
1925    /// assert_eq!(scroll.offset_x, 0);
1926    /// ```
1927    pub fn scroll_right(&mut self, amount: usize) {
1928        let max_offset = self.content_width.saturating_sub(self.viewport_width) as usize;
1929        self.offset_x = (self.offset_x + amount).min(max_offset);
1930    }
1931
1932    /// Set the active highlight ranges. Replaces any previous highlights.
1933    ///
1934    /// Selecting the first highlight automatically when the list is non-empty
1935    /// matches the behavior of search-result navigation in code editors.
1936    pub fn set_highlights(&mut self, ranges: &[HighlightRange]) {
1937        self.highlights.clear();
1938        self.highlights.extend_from_slice(ranges);
1939        self.current_highlight = if self.highlights.is_empty() {
1940            None
1941        } else {
1942            Some(0)
1943        };
1944    }
1945
1946    /// Read-only access to the active highlight ranges.
1947    pub fn highlights(&self) -> &[HighlightRange] {
1948        &self.highlights
1949    }
1950
1951    /// Index of the currently focused highlight, if any.
1952    pub fn current_highlight(&self) -> Option<usize> {
1953        self.current_highlight
1954    }
1955
1956    /// Clear all highlights and reset the current index.
1957    pub fn clear_highlights(&mut self) {
1958        self.highlights.clear();
1959        self.current_highlight = None;
1960    }
1961
1962    /// Advance to the next highlight, scrolling the viewport to show it.
1963    /// Wraps from last to first.
1964    pub fn highlight_next(&mut self) {
1965        if self.highlights.is_empty() {
1966            return;
1967        }
1968        let next = match self.current_highlight {
1969            Some(i) => (i + 1) % self.highlights.len(),
1970            None => 0,
1971        };
1972        self.current_highlight = Some(next);
1973        self.scroll_to_current_highlight();
1974    }
1975
1976    /// Move to the previous highlight, scrolling the viewport to show it.
1977    /// Wraps from first to last.
1978    pub fn highlight_previous(&mut self) {
1979        if self.highlights.is_empty() {
1980            return;
1981        }
1982        let next = match self.current_highlight {
1983            Some(i) => {
1984                if i == 0 {
1985                    self.highlights.len() - 1
1986                } else {
1987                    i - 1
1988                }
1989            }
1990            None => 0,
1991        };
1992        self.current_highlight = Some(next);
1993        self.scroll_to_current_highlight();
1994    }
1995
1996    /// Scroll the viewport so the currently focused highlight is visible
1997    /// with one line of context above when possible.
1998    pub fn scroll_to_current_highlight(&mut self) {
1999        let Some(idx) = self.current_highlight else {
2000            return;
2001        };
2002        let Some(range) = self.highlights.get(idx).copied() else {
2003            return;
2004        };
2005        let target = range.start_line;
2006        let viewport = self.viewport_height as usize;
2007        let content = self.content_height as usize;
2008        let max_offset = content.saturating_sub(viewport);
2009        if target < self.offset {
2010            self.offset = target.saturating_sub(1).min(max_offset);
2011        } else if viewport > 0 && target >= self.offset + viewport {
2012            let desired = target + 2;
2013            let new_offset = desired.saturating_sub(viewport);
2014            self.offset = new_offset.min(max_offset);
2015        } else if self.offset > max_offset {
2016            self.offset = max_offset;
2017        }
2018    }
2019}
2020
2021impl Default for ScrollState {
2022    fn default() -> Self {
2023        Self::new()
2024    }
2025}
2026
2027/// State for a [`crate::Context::split_pane`] /
2028/// [`crate::Context::vsplit_pane`] container.
2029///
2030/// Tracks the split ratio and drag state. Pass a mutable reference each frame
2031/// — the widget updates `ratio` in place when the user drags the handle or
2032/// presses arrow keys with the handle focused.
2033#[derive(Debug, Clone, PartialEq)]
2034pub struct SplitPaneState {
2035    /// Fraction of space given to the first pane. Clamped to
2036    /// `[min_ratio, 1.0 - min_ratio]`.
2037    pub ratio: f64,
2038    /// Whether the handle is currently being dragged.
2039    pub dragging: bool,
2040    /// Minimum fraction allocated to either pane. Default: `0.10`.
2041    pub min_ratio: f64,
2042}
2043
2044/// Default minimum fraction of either pane, used by [`SplitPaneState::new`].
2045///
2046/// Crate-internal: there is no public path that benefits from constructing
2047/// with this constant — call [`SplitPaneState::new`] for the default (0.10)
2048/// or [`SplitPaneState::with_min_ratio`] to override per-instance.
2049pub(crate) const DEFAULT_SPLIT_MIN_RATIO: f64 = 0.10;
2050
2051impl SplitPaneState {
2052    /// Create split state with the given initial ratio, clamped to
2053    /// `[DEFAULT_SPLIT_MIN_RATIO, 1.0 - DEFAULT_SPLIT_MIN_RATIO]` (default
2054    /// `[0.10, 0.90]`).
2055    pub fn new(ratio: f64) -> Self {
2056        let min_ratio = DEFAULT_SPLIT_MIN_RATIO;
2057        let clamped = ratio.clamp(min_ratio, 1.0 - min_ratio);
2058        Self {
2059            ratio: clamped,
2060            dragging: false,
2061            min_ratio,
2062        }
2063    }
2064
2065    /// Override the minimum ratio for either pane (clamped to `[0.0, 0.49]`).
2066    pub fn with_min_ratio(mut self, min: f64) -> Self {
2067        self.min_ratio = min.clamp(0.0, 0.49);
2068        self.ratio = self.ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
2069        self
2070    }
2071
2072    /// Set the ratio, clamped to `[min_ratio, 1.0 - min_ratio]`.
2073    pub fn set_ratio(&mut self, ratio: f64) {
2074        self.ratio = ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
2075    }
2076}
2077
2078impl Default for SplitPaneState {
2079    fn default() -> Self {
2080        Self::new(0.5)
2081    }
2082}
2083
2084/// Column specification for [`crate::Context::grid_with()`].
2085///
2086/// Controls the width allocation of individual columns in a grid layout.
2087///
2088/// # Example
2089///
2090/// ```no_run
2091/// use slt::GridColumn;
2092/// # slt::run(|ui: &mut slt::Context| {
2093/// ui.grid_with(&[
2094///     GridColumn::Fixed(8),   // label column: exactly 8 chars
2095///     GridColumn::Grow(1),    // flexible column
2096///     GridColumn::Grow(1),    // flexible column
2097///     GridColumn::Fixed(4),   // status column: exactly 4 chars
2098/// ], |ui| {
2099///     // children placed left-to-right, wrapping to next row
2100/// });
2101/// # });
2102/// ```
2103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2104pub enum GridColumn {
2105    /// Equal-width column with grow weight 1 (default `grid()` behavior).
2106    Auto,
2107    /// Fixed-width column in character cells. Does not grow or shrink.
2108    Fixed(u32),
2109    /// Flexible column with a custom grow weight. Higher values take
2110    /// proportionally more space.
2111    Grow(u16),
2112    /// Column sized as a percentage (1–100) of the grid width.
2113    Percent(u8),
2114}
2115
2116#[cfg(test)]
2117mod table_v021_width_tests {
2118    use super::TableColumn;
2119    use super::TableState;
2120
2121    fn resolved(specs: &[TableColumn], content: &str, available: u32) -> u32 {
2122        let mut state = TableState::new(vec!["H"], vec![vec![content]]);
2123        state.column_widths_spec(specs);
2124        state.recompute_widths();
2125        state.resolve_column_widths(available);
2126        state.column_widths()[0]
2127    }
2128
2129    #[test]
2130    fn fixed_overrides_content() {
2131        assert_eq!(resolved(&[TableColumn::Fixed(5)], "averylongcell", 80), 5);
2132        assert_eq!(resolved(&[TableColumn::Fixed(20)], "x", 80), 20);
2133    }
2134
2135    #[test]
2136    fn min_floors_content() {
2137        // Content/header width is at most 1 here; Min raises it to 10.
2138        assert_eq!(resolved(&[TableColumn::Min(10)], "x", 80), 10);
2139        // Content already exceeds the floor -> unchanged.
2140        assert_eq!(resolved(&[TableColumn::Min(2)], "abcdef", 80), 6);
2141    }
2142
2143    #[test]
2144    fn max_caps_content() {
2145        assert_eq!(resolved(&[TableColumn::Max(4)], "abcdefghij", 80), 4);
2146        // Content below the cap -> unchanged.
2147        assert_eq!(resolved(&[TableColumn::Max(10)], "abc", 80), 3);
2148    }
2149
2150    #[test]
2151    fn percent_of_available() {
2152        let mut state = TableState::new(vec!["A", "B"], vec![vec!["x", "y"]]);
2153        state.column_widths_spec(&[TableColumn::Percent(50), TableColumn::Percent(50)]);
2154        state.recompute_widths();
2155        state.resolve_column_widths(40);
2156        assert_eq!(state.column_widths(), &[20, 20]);
2157    }
2158
2159    #[test]
2160    fn auto_equals_content_width() {
2161        // No spec -> resolve is a no-op and width is the content width.
2162        assert_eq!(resolved(&[], "hello", 80), 5);
2163        assert_eq!(resolved(&[TableColumn::Auto], "hello", 80), 5);
2164    }
2165
2166    #[test]
2167    fn select_range_fills_inclusive() {
2168        let mut state = TableState::new(vec!["N"], vec![vec!["a"]; 5]);
2169        state.select_range(1, 3);
2170        let mut got: Vec<usize> = state.multi_selected.iter().copied().collect();
2171        got.sort_unstable();
2172        assert_eq!(got, vec![1, 2, 3]);
2173        // Reversed args produce the same inclusive set.
2174        state.select_range(3, 1);
2175        let mut got: Vec<usize> = state.multi_selected.iter().copied().collect();
2176        got.sort_unstable();
2177        assert_eq!(got, vec![1, 2, 3]);
2178    }
2179
2180    #[test]
2181    fn toggle_row_inserts_then_removes() {
2182        let mut state = TableState::new(vec!["N"], vec![vec!["a"]; 3]);
2183        state.toggle_row(1);
2184        assert!(state.is_row_selected(1));
2185        state.toggle_row(1);
2186        assert!(!state.is_row_selected(1));
2187    }
2188
2189    proptest::proptest! {
2190        #[test]
2191        fn fixed_min_max_invariants(
2192            content_len in 0usize..40,
2193            spec_kind in 0u8..4,
2194            n in 0u32..30,
2195            available in 1u32..200,
2196        ) {
2197            let content: String = "x".repeat(content_len);
2198            let spec = match spec_kind {
2199                0 => TableColumn::Fixed(n),
2200                1 => TableColumn::Min(n),
2201                2 => TableColumn::Max(n),
2202                _ => TableColumn::Auto,
2203            };
2204            let w = resolved(&[spec], &content, available);
2205            match spec {
2206                TableColumn::Fixed(n) => proptest::prop_assert_eq!(w, n),
2207                TableColumn::Min(n) => proptest::prop_assert!(w >= n),
2208                TableColumn::Max(n) => proptest::prop_assert!(w <= n),
2209                _ => {}
2210            }
2211        }
2212
2213        #[test]
2214        fn percent_columns_never_exceed_available(
2215            pcts in proptest::collection::vec(1u8..=100, 1..6),
2216            available in 1u32..200,
2217        ) {
2218            let cols = pcts.len();
2219            let headers: Vec<String> = (0..cols).map(|i| format!("H{i}")).collect();
2220            let row: Vec<String> = (0..cols).map(|_| "v".to_string()).collect();
2221            let mut state = TableState::new(headers, vec![row]);
2222            let specs: Vec<TableColumn> = pcts.iter().map(|&p| TableColumn::Percent(p)).collect();
2223            state.column_widths_spec(&specs);
2224            state.recompute_widths();
2225            state.resolve_column_widths(available);
2226            // Each Percent column is floor(available * pct / 100) <= available.
2227            for (&w, &p) in state.column_widths().iter().zip(pcts.iter()) {
2228                let expected = (available.saturating_mul(p as u32)) / 100;
2229                proptest::prop_assert_eq!(w, expected);
2230                proptest::prop_assert!(w <= available);
2231            }
2232        }
2233    }
2234}
2235
2236#[cfg(test)]
2237mod list_state_height_tests {
2238    use super::ListState;
2239
2240    #[test]
2241    fn row_prefix_is_cumulative_sum() {
2242        let mut state = ListState::new(vec!["a", "b", "c", "d"]);
2243        state.set_item_heights(vec![2, 1, 3, 1]);
2244        state.ensure_row_prefix();
2245        // row_prefix[i] = total rows occupied by items 0..i.
2246        assert_eq!(state.row_prefix(), &[0, 2, 3, 6, 7]);
2247        // item_height reflects the stored (clamped) heights.
2248        assert_eq!(state.item_height(0), 2);
2249        assert_eq!(state.item_height(2), 3);
2250    }
2251
2252    #[test]
2253    fn heights_below_one_are_clamped() {
2254        let mut state = ListState::new(vec!["a", "b", "c"]);
2255        state.set_item_heights(vec![0, 0, 0]);
2256        state.ensure_row_prefix();
2257        assert_eq!(state.row_prefix(), &[0, 1, 2, 3]);
2258        assert_eq!(state.item_height(0), 1);
2259    }
2260
2261    #[test]
2262    fn dirty_gate_skips_rebuild_when_unchanged() {
2263        let mut state = ListState::new(vec!["a", "b"]);
2264        state.set_item_heights(vec![3, 2]);
2265        state.ensure_row_prefix();
2266        assert_eq!(state.row_prefix(), &[0, 3, 5]);
2267        // heights_dirty is now false; a second call must be a no-op and leave
2268        // the prefix intact (no panic, no recompute that changes the result).
2269        assert!(!state.heights_dirty);
2270        state.ensure_row_prefix();
2271        assert_eq!(state.row_prefix(), &[0, 3, 5]);
2272    }
2273
2274    #[test]
2275    fn no_heights_falls_back_to_uniform() {
2276        let mut state = ListState::new(vec!["a", "b", "c"]);
2277        assert!(!state.has_item_heights());
2278        state.ensure_row_prefix();
2279        assert_eq!(state.row_prefix(), &[0, 1, 2, 3]);
2280        assert_eq!(state.item_height(0), 1);
2281    }
2282
2283    #[test]
2284    fn clear_reverts_to_uniform() {
2285        let mut state = ListState::new(vec!["a", "b"]).with_item_heights(vec![4, 2]);
2286        state.ensure_row_prefix();
2287        assert_eq!(state.row_prefix(), &[0, 4, 6]);
2288        state.clear_item_heights();
2289        assert!(!state.has_item_heights());
2290        state.ensure_row_prefix();
2291        assert_eq!(state.row_prefix(), &[0, 1, 2]);
2292    }
2293
2294    #[test]
2295    fn set_items_marks_dirty_and_resizes_prefix() {
2296        let mut state = ListState::new(vec!["a", "b", "c"]).with_item_heights(vec![2, 2, 2]);
2297        state.ensure_row_prefix();
2298        assert_eq!(state.row_prefix(), &[0, 2, 4, 6]);
2299        // Replacing items must invalidate the stale prefix.
2300        state.set_items(vec!["x", "y"]);
2301        assert!(state.heights_dirty);
2302        state.ensure_row_prefix();
2303        assert_eq!(state.row_prefix(), &[0, 2, 4]);
2304    }
2305
2306    #[test]
2307    fn set_items_truncates_stale_per_item_heights() {
2308        let mut state =
2309            ListState::new(vec!["a", "b", "c", "d"]).with_item_heights(vec![2, 3, 4, 5]);
2310        state.set_items(vec!["x", "y"]);
2311
2312        assert_eq!(state.item_height(0), 2);
2313        assert_eq!(state.item_height(1), 3);
2314        assert_eq!(state.item_height(2), 1);
2315        state.ensure_row_prefix();
2316        assert_eq!(state.row_prefix(), &[0, 2, 5]);
2317    }
2318}
2319
2320#[cfg(test)]
2321mod scroll_state_progress_tests {
2322    use super::ScrollState;
2323
2324    /// Build a state with the bounds the `scrollable` widget would set, plus an
2325    /// offset, so `progress_ratio` exercises a realistic non-zero ratio.
2326    fn scrolled(content_height: u32, viewport_height: u32, offset: usize) -> ScrollState {
2327        let mut state = ScrollState::new();
2328        state.set_bounds(content_height, viewport_height);
2329        state.offset = offset;
2330        state
2331    }
2332
2333    #[test]
2334    fn progress_ratio_returns_f64_in_unit_range() {
2335        // Top of a scrollable region → 0.0.
2336        let top = scrolled(100, 20, 0);
2337        let ratio: f64 = top.progress_ratio();
2338        assert_eq!(ratio, 0.0);
2339
2340        // Halfway through the scrollable range (offset 40 of max 80) → 0.5.
2341        let mid = scrolled(100, 20, 40);
2342        assert_eq!(mid.progress_ratio(), 0.5);
2343
2344        // Fully scrolled (offset == max) → 1.0.
2345        let bottom = scrolled(100, 20, 80);
2346        assert_eq!(bottom.progress_ratio(), 1.0);
2347    }
2348
2349    #[test]
2350    fn progress_ratio_is_zero_when_content_fits_viewport() {
2351        // No overflow → no scroll range → 0.0 (and no divide-by-zero).
2352        let fits = scrolled(20, 20, 0);
2353        assert_eq!(fits.progress_ratio(), 0.0);
2354
2355        let smaller = scrolled(10, 20, 5);
2356        assert_eq!(smaller.progress_ratio(), 0.0);
2357    }
2358
2359    #[test]
2360    fn progress_ratio_preserves_f64_precision() {
2361        // 1/3 is lossy in f32; the f64 surface keeps more digits than `as f32`.
2362        let third = scrolled(40, 10, 10); // max = 30, offset = 10 → 1/3
2363        let ratio = third.progress_ratio();
2364        assert!((ratio - 1.0 / 3.0).abs() < 1e-12);
2365    }
2366
2367    #[test]
2368    #[allow(deprecated)]
2369    fn deprecated_progress_delegates_to_progress_ratio() {
2370        // The deprecated f32 alias must agree with the f64 source within f32 epsilon.
2371        let state = scrolled(100, 20, 40);
2372        let expected = state.progress_ratio() as f32;
2373        assert_eq!(state.progress(), expected);
2374        assert!((state.progress() - 0.5).abs() < f32::EPSILON);
2375    }
2376}
2377
2378#[cfg(test)]
2379mod list_state_reorder_tests {
2380    use super::ListState;
2381
2382    #[test]
2383    fn move_item_forward_reorders_and_keeps_selection() {
2384        let mut state = ListState::new(vec!["a", "b", "c", "d"]);
2385        state.selected = 0; // "a"
2386        assert!(state.move_item(0, 2));
2387        assert_eq!(state.items, vec!["b", "c", "a", "d"]);
2388        // Selection follows the moved item.
2389        assert_eq!(state.selected_item(), Some("a"));
2390        assert_eq!(state.selected, 2);
2391    }
2392
2393    #[test]
2394    fn move_item_backward_reorders_and_keeps_selection() {
2395        let mut state = ListState::new(vec!["a", "b", "c", "d"]);
2396        state.selected = 3; // "d"
2397        assert!(state.move_item(3, 1));
2398        assert_eq!(state.items, vec!["a", "d", "b", "c"]);
2399        assert_eq!(state.selected_item(), Some("d"));
2400        assert_eq!(state.selected, 1);
2401    }
2402
2403    #[test]
2404    fn move_item_keeps_search_cache_aligned() {
2405        let mut state = ListState::new(vec!["Apple", "Banana", "Cherry"]);
2406        assert!(state.move_item(0, 2));
2407        // After the move the filter must address the reordered items.
2408        state.set_filter("apple");
2409        assert_eq!(state.visible_indices().len(), 1);
2410        assert_eq!(state.selected_item(), Some("Apple"));
2411    }
2412
2413    #[test]
2414    fn move_item_keeps_per_item_heights_aligned() {
2415        let mut state = ListState::new(vec!["a", "b", "c"]).with_item_heights(vec![1, 2, 3]);
2416        assert!(state.move_item(0, 2));
2417        state.ensure_row_prefix();
2418        // Heights travel with their items: order is now b(2), c(3), a(1).
2419        assert_eq!(state.item_height(0), 2);
2420        assert_eq!(state.item_height(1), 3);
2421        assert_eq!(state.item_height(2), 1);
2422    }
2423
2424    #[test]
2425    fn move_item_noop_when_from_equals_to() {
2426        let mut state = ListState::new(vec!["a", "b", "c"]);
2427        state.selected = 1;
2428        assert!(!state.move_item(1, 1));
2429        assert_eq!(state.items, vec!["a", "b", "c"]);
2430        assert_eq!(state.selected, 1);
2431    }
2432
2433    #[test]
2434    fn move_item_out_of_bounds_is_rejected() {
2435        let mut state = ListState::new(vec!["a", "b", "c"]);
2436        assert!(!state.move_item(0, 9));
2437        assert!(!state.move_item(9, 0));
2438        assert_eq!(state.items, vec!["a", "b", "c"]);
2439    }
2440
2441    #[test]
2442    fn move_item_empty_list_is_rejected() {
2443        let mut state = ListState::new(Vec::<String>::new());
2444        assert!(!state.move_item(0, 0));
2445        assert!(state.items.is_empty());
2446    }
2447
2448    #[test]
2449    fn move_item_leaves_unrelated_selection_in_place() {
2450        // Moving an item that is not selected should keep selection on the
2451        // same logical item.
2452        let mut state = ListState::new(vec!["a", "b", "c", "d"]);
2453        state.selected = 3; // "d"
2454        assert!(state.move_item(0, 1)); // swap a/b; "d" stays last
2455        assert_eq!(state.items, vec!["b", "a", "c", "d"]);
2456        assert_eq!(state.selected_item(), Some("d"));
2457        assert_eq!(state.selected, 3);
2458    }
2459}