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 && from < heights.len()
246 {
247 let h = heights.remove(from);
248 heights.insert(to.min(heights.len()), h);
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 && anchor >= view_len
925 {
926 self.selection_anchor = None;
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}