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}