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