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