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