Skip to main content

slt/widgets/
collections.rs

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