Skip to main content

slt/widgets/
collections.rs

1/// State for a selectable list widget.
2///
3/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
4/// keys (and `k`/`j`) move the selection when the widget is focused.
5#[derive(Debug, Clone, Default)]
6pub struct ListState {
7    /// The list items as display strings.
8    pub items: Vec<String>,
9    /// Index of the currently selected item.
10    pub selected: usize,
11    /// Case-insensitive substring filter applied to list items.
12    pub filter: String,
13    /// Top-row 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.
16    pub(crate) viewport_offset: usize,
17    view_indices: Vec<usize>,
18    /// Lowercase cache parallel to `items`, rebuilt only on `set_items` / `new`.
19    /// Mirrors the `row_search_cache` pattern in `TableState`.
20    item_search_cache: Vec<String>,
21}
22
23impl ListState {
24    /// Create a list with the given items. The first item is selected initially.
25    pub fn new(items: Vec<impl Into<String>>) -> Self {
26        let items: Vec<String> = items.into_iter().map(Into::into).collect();
27        let item_search_cache: Vec<String> =
28            items.iter().map(|s| s.to_lowercase()).collect();
29        let len = items.len();
30        Self {
31            items,
32            selected: 0,
33            filter: String::new(),
34            viewport_offset: 0,
35            view_indices: (0..len).collect(),
36            item_search_cache,
37        }
38    }
39
40    /// Replace the list items and rebuild the view index.
41    ///
42    /// Use this instead of assigning `items` directly to ensure the internal
43    /// filter/view state stays consistent.
44    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
45        self.items = items.into_iter().map(Into::into).collect();
46        self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
47        self.selected = self.selected.min(self.items.len().saturating_sub(1));
48        self.rebuild_view();
49    }
50
51    /// Set the filter string. Multiple space-separated tokens are AND'd
52    /// together — all tokens must match across any cell in the same row.
53    /// Empty string disables filtering.
54    pub fn set_filter(&mut self, filter: impl Into<String>) {
55        self.filter = filter.into();
56        self.rebuild_view();
57    }
58
59    /// Returns indices of items visible after filtering.
60    pub fn visible_indices(&self) -> &[usize] {
61        &self.view_indices
62    }
63
64    /// Get the currently selected item text, or `None` if the list is empty.
65    pub fn selected_item(&self) -> Option<&str> {
66        let data_idx = *self.view_indices.get(self.selected)?;
67        self.items.get(data_idx).map(String::as_str)
68    }
69
70    fn rebuild_view(&mut self) {
71        let tokens: Vec<String> = self
72            .filter
73            .split_whitespace()
74            .map(|t| t.to_lowercase())
75            .collect();
76        self.view_indices = if tokens.is_empty() {
77            (0..self.items.len()).collect()
78        } else {
79            (0..self.items.len())
80                .filter(|&i| {
81                    let cached = match self.item_search_cache.get(i) {
82                        Some(s) => s.as_str(),
83                        None => return false,
84                    };
85                    tokens.iter().all(|token| cached.contains(token.as_str()))
86                })
87                .collect()
88        };
89        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
90            self.selected = self.view_indices.len() - 1;
91        }
92    }
93}
94
95/// State for a file picker widget.
96///
97/// Tracks the current directory listing, filtering options, and selected file.
98#[derive(Debug, Clone)]
99pub struct FilePickerState {
100    /// Current directory being browsed.
101    pub current_dir: PathBuf,
102    /// Visible entries in the current directory.
103    pub entries: Vec<FileEntry>,
104    /// Selected entry index in `entries`.
105    pub selected: usize,
106    /// Currently selected file path, if any.
107    pub selected_file: Option<PathBuf>,
108    /// Whether dotfiles are included in the listing.
109    pub show_hidden: bool,
110    /// Allowed file extensions (lowercase, no leading dot).
111    pub extensions: Vec<String>,
112    /// Whether the directory listing needs refresh.
113    pub dirty: bool,
114}
115
116/// A directory entry shown by [`FilePickerState`].
117#[derive(Debug, Clone, Default)]
118pub struct FileEntry {
119    /// File or directory name.
120    pub name: String,
121    /// Full path to the entry.
122    pub path: PathBuf,
123    /// Whether this entry is a directory.
124    pub is_dir: bool,
125    /// File size in bytes (0 for directories).
126    pub size: u64,
127}
128
129impl FilePickerState {
130    /// Create a file picker rooted at `dir`.
131    pub fn new(dir: impl Into<PathBuf>) -> Self {
132        Self {
133            current_dir: dir.into(),
134            entries: Vec::new(),
135            selected: 0,
136            selected_file: None,
137            show_hidden: false,
138            extensions: Vec::new(),
139            dirty: true,
140        }
141    }
142
143    /// Configure whether hidden files should be shown.
144    pub fn show_hidden(mut self, show: bool) -> Self {
145        self.show_hidden = show;
146        self.dirty = true;
147        self
148    }
149
150    /// Restrict visible files to the provided extensions.
151    pub fn extensions(mut self, exts: &[&str]) -> Self {
152        self.extensions = exts
153            .iter()
154            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
155            .filter(|ext| !ext.is_empty())
156            .collect();
157        self.dirty = true;
158        self
159    }
160
161    /// Return the currently selected file path, if any.
162    ///
163    /// Disambiguates from the [`selected: usize`](Self::selected) field, which
164    /// is the entry index into [`entries`](Self::entries). This method returns
165    /// the resolved file path that the user picked via Enter — `None` until a
166    /// file (not a directory) is selected.
167    ///
168    /// # Example
169    ///
170    /// ```no_run
171    /// # use slt::widgets::FilePickerState;
172    /// # slt::run(|ui: &mut slt::Context| {
173    /// let mut state = FilePickerState::new(".");
174    /// if ui.file_picker(&mut state).changed {
175    ///     if let Some(path) = state.selected_file() {
176    ///         println!("picked: {}", path.display());
177    ///     }
178    /// }
179    /// # });
180    /// ```
181    pub fn selected_file(&self) -> Option<&PathBuf> {
182        self.selected_file.as_ref()
183    }
184
185    /// Return the currently selected file path.
186    ///
187    /// Deprecated alias for [`selected_file`](Self::selected_file). The
188    /// shorter name conflicts visually with the [`selected: usize`](Self::selected)
189    /// field — a getter returning a path alongside a public field returning
190    /// an index made call sites ambiguous. Migrate to `selected_file()` for
191    /// new code; this stub stays callable until v1.0.
192    #[deprecated(since = "0.20.0", note = "use selected_file() — disambiguates from the `selected: usize` field index")]
193    pub fn selected(&self) -> Option<&PathBuf> {
194        self.selected_file()
195    }
196
197    /// Re-scan the current directory and rebuild entries.
198    pub fn refresh(&mut self) {
199        let mut entries = Vec::new();
200
201        if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
202            for dir_entry in read_dir.flatten() {
203                let name = dir_entry.file_name().to_string_lossy().to_string();
204                if !self.show_hidden && name.starts_with('.') {
205                    continue;
206                }
207
208                let Ok(file_type) = dir_entry.file_type() else {
209                    continue;
210                };
211                if file_type.is_symlink() {
212                    continue;
213                }
214
215                let path = dir_entry.path();
216                let is_dir = file_type.is_dir();
217
218                if !is_dir && !self.extensions.is_empty() {
219                    let ext = path
220                        .extension()
221                        .and_then(|e| e.to_str())
222                        .map(|e| e.to_ascii_lowercase());
223                    let Some(ext) = ext else {
224                        continue;
225                    };
226                    if !self.extensions.iter().any(|allowed| allowed == &ext) {
227                        continue;
228                    }
229                }
230
231                let size = if is_dir {
232                    0
233                } else {
234                    fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
235                };
236
237                entries.push(FileEntry {
238                    name,
239                    path,
240                    is_dir,
241                    size,
242                });
243            }
244        }
245
246        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
247            (true, false) => std::cmp::Ordering::Less,
248            (false, true) => std::cmp::Ordering::Greater,
249            _ => a
250                .name
251                .to_ascii_lowercase()
252                .cmp(&b.name.to_ascii_lowercase())
253                .then_with(|| a.name.cmp(&b.name)),
254        });
255
256        self.entries = entries;
257        if self.entries.is_empty() {
258            self.selected = 0;
259        } else {
260            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
261        }
262        self.dirty = false;
263    }
264}
265
266impl Default for FilePickerState {
267    fn default() -> Self {
268        Self::new(".")
269    }
270}
271
272/// State for a tab navigation widget.
273///
274/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
275/// keys cycle through tabs when the widget is focused.
276#[derive(Debug, Clone, Default)]
277pub struct TabsState {
278    /// The tab labels displayed in the bar.
279    pub labels: Vec<String>,
280    /// Index of the currently active tab.
281    pub selected: usize,
282}
283
284impl TabsState {
285    /// Create tabs with the given labels. The first tab is active initially.
286    pub fn new(labels: Vec<impl Into<String>>) -> Self {
287        Self {
288            labels: labels.into_iter().map(Into::into).collect(),
289            selected: 0,
290        }
291    }
292
293    /// Get the currently selected tab label, or `None` if there are no tabs.
294    pub fn selected_label(&self) -> Option<&str> {
295        self.labels.get(self.selected).map(String::as_str)
296    }
297}
298
299/// State for a data table widget.
300///
301/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
302/// keys move the row selection when the widget is focused. Column widths are
303/// computed automatically from header and cell content.
304#[derive(Debug, Clone)]
305pub struct TableState {
306    /// Column header labels.
307    pub headers: Vec<String>,
308    /// Table rows, each a `Vec` of cell strings.
309    pub rows: Vec<Vec<String>>,
310    /// Index of the currently selected row.
311    pub selected: usize,
312    column_widths: Vec<u32>,
313    widths_dirty: bool,
314    /// Sorted column index (`None` means no sorting).
315    pub sort_column: Option<usize>,
316    /// Sort direction (`true` for ascending).
317    pub sort_ascending: bool,
318    /// Case-insensitive substring filter applied across all cells.
319    pub filter: String,
320    /// Current page (0-based) when pagination is enabled.
321    pub page: usize,
322    /// Rows per page (`0` disables pagination).
323    pub page_size: usize,
324    /// Whether alternating row backgrounds are enabled.
325    pub zebra: bool,
326    view_indices: Vec<usize>,
327    row_search_cache: Vec<String>,
328    filter_tokens: Vec<String>,
329}
330
331impl Default for TableState {
332    fn default() -> Self {
333        Self {
334            headers: Vec::new(),
335            rows: Vec::new(),
336            selected: 0,
337            column_widths: Vec::new(),
338            widths_dirty: true,
339            sort_column: None,
340            sort_ascending: true,
341            filter: String::new(),
342            page: 0,
343            page_size: 0,
344            zebra: false,
345            view_indices: Vec::new(),
346            row_search_cache: Vec::new(),
347            filter_tokens: Vec::new(),
348        }
349    }
350}
351
352impl TableState {
353    /// Create a table with headers and rows. Column widths are computed immediately.
354    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
355        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
356        let rows: Vec<Vec<String>> = rows
357            .into_iter()
358            .map(|r| r.into_iter().map(Into::into).collect())
359            .collect();
360        let mut state = Self {
361            headers,
362            rows,
363            selected: 0,
364            column_widths: Vec::new(),
365            widths_dirty: true,
366            sort_column: None,
367            sort_ascending: true,
368            filter: String::new(),
369            page: 0,
370            page_size: 0,
371            zebra: false,
372            view_indices: Vec::new(),
373            row_search_cache: Vec::new(),
374            filter_tokens: Vec::new(),
375        };
376        state.rebuild_row_search_cache();
377        state.rebuild_view();
378        state.recompute_widths();
379        state
380    }
381
382    /// Replace all rows, preserving the selection index if possible.
383    ///
384    /// If the current selection is beyond the new row count, it is clamped to
385    /// the last row.
386    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
387        self.rows = rows
388            .into_iter()
389            .map(|r| r.into_iter().map(Into::into).collect())
390            .collect();
391        self.rebuild_row_search_cache();
392        self.rebuild_view();
393    }
394
395    /// Sort by a specific column index. If already sorted by this column, toggles direction.
396    pub fn toggle_sort(&mut self, column: usize) {
397        if self.sort_column == Some(column) {
398            self.sort_ascending = !self.sort_ascending;
399        } else {
400            self.sort_column = Some(column);
401            self.sort_ascending = true;
402        }
403        self.rebuild_view();
404    }
405
406    /// Sort by column without toggling (always sets to ascending first).
407    pub fn sort_by(&mut self, column: usize) {
408        if self.sort_column == Some(column) && self.sort_ascending {
409            return;
410        }
411        self.sort_column = Some(column);
412        self.sort_ascending = true;
413        self.rebuild_view();
414    }
415
416    /// Set the filter string. Multiple space-separated tokens are AND'd
417    /// together — all tokens must match across any cell in the same row.
418    /// Empty string disables filtering.
419    pub fn set_filter(&mut self, filter: impl Into<String>) {
420        let filter = filter.into();
421        if self.filter == filter {
422            return;
423        }
424        self.filter = filter;
425        self.filter_tokens = Self::tokenize_filter(&self.filter);
426        self.page = 0;
427        self.rebuild_view();
428    }
429
430    /// Clear sorting.
431    pub fn clear_sort(&mut self) {
432        if self.sort_column.is_none() && self.sort_ascending {
433            return;
434        }
435        self.sort_column = None;
436        self.sort_ascending = true;
437        self.rebuild_view();
438    }
439
440    /// Move to the next page. Does nothing if already on the last page.
441    pub fn next_page(&mut self) {
442        if self.page_size == 0 {
443            return;
444        }
445        let last_page = self.total_pages().saturating_sub(1);
446        self.page = (self.page + 1).min(last_page);
447    }
448
449    /// Move to the previous page. Does nothing if already on page 0.
450    pub fn prev_page(&mut self) {
451        self.page = self.page.saturating_sub(1);
452    }
453
454    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
455    pub fn total_pages(&self) -> usize {
456        if self.page_size == 0 {
457            return 1;
458        }
459
460        let len = self.view_indices.len();
461        if len == 0 {
462            1
463        } else {
464            len.div_ceil(self.page_size)
465        }
466    }
467
468    /// Get the visible row indices after filtering and sorting (used internally by table()).
469    pub fn visible_indices(&self) -> &[usize] {
470        &self.view_indices
471    }
472
473    /// Get the currently selected row data, or `None` if the table is empty.
474    pub fn selected_row(&self) -> Option<&[String]> {
475        if self.view_indices.is_empty() {
476            return None;
477        }
478        let data_idx = self.view_indices.get(self.selected)?;
479        self.rows.get(*data_idx).map(|r| r.as_slice())
480    }
481
482    /// Recompute view_indices based on current sort + filter settings.
483    fn rebuild_view(&mut self) {
484        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
485
486        if !self.filter_tokens.is_empty() {
487            indices.retain(|&idx| {
488                let searchable = match self.row_search_cache.get(idx) {
489                    Some(row) => row,
490                    None => return false,
491                };
492                self.filter_tokens
493                    .iter()
494                    .all(|token| searchable.contains(token.as_str()))
495            });
496        }
497
498        if let Some(column) = self.sort_column {
499            indices.sort_by(|a, b| {
500                let left = self
501                    .rows
502                    .get(*a)
503                    .and_then(|row| row.get(column))
504                    .map(String::as_str)
505                    .unwrap_or("");
506                let right = self
507                    .rows
508                    .get(*b)
509                    .and_then(|row| row.get(column))
510                    .map(String::as_str)
511                    .unwrap_or("");
512
513                match (left.parse::<f64>(), right.parse::<f64>()) {
514                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
515                    _ => left
516                        .chars()
517                        .flat_map(char::to_lowercase)
518                        .cmp(right.chars().flat_map(char::to_lowercase)),
519                }
520            });
521
522            if !self.sort_ascending {
523                indices.reverse();
524            }
525        }
526
527        self.view_indices = indices;
528
529        if self.page_size > 0 {
530            self.page = self.page.min(self.total_pages().saturating_sub(1));
531        } else {
532            self.page = 0;
533        }
534
535        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
536        self.widths_dirty = true;
537    }
538
539    fn rebuild_row_search_cache(&mut self) {
540        self.row_search_cache = self
541            .rows
542            .iter()
543            .map(|row| {
544                let mut searchable = String::new();
545                for (idx, cell) in row.iter().enumerate() {
546                    if idx > 0 {
547                        searchable.push('\n');
548                    }
549                    searchable.extend(cell.chars().flat_map(char::to_lowercase));
550                }
551                searchable
552            })
553            .collect();
554        self.filter_tokens = Self::tokenize_filter(&self.filter);
555        self.widths_dirty = true;
556    }
557
558    fn tokenize_filter(filter: &str) -> Vec<String> {
559        filter
560            .split_whitespace()
561            .map(|t| t.to_lowercase())
562            .collect()
563    }
564
565    pub(crate) fn recompute_widths(&mut self) {
566        // Skip when no mutation since the last computation. `widths_dirty` is
567        // set by `rebuild_view` (covers `set_rows`, `set_filter`, sort) and at
568        // construction. Frames without data mutation become a no-op.
569        if !self.widths_dirty {
570            return;
571        }
572        let col_count = self.headers.len();
573        self.column_widths = vec![0u32; col_count];
574        for (i, header) in self.headers.iter().enumerate() {
575            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
576            if self.sort_column == Some(i) {
577                width += 2;
578            }
579            self.column_widths[i] = width;
580        }
581        for row in &self.rows {
582            for (i, cell) in row.iter().enumerate() {
583                if i < col_count {
584                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
585                    self.column_widths[i] = self.column_widths[i].max(w);
586                }
587            }
588        }
589        self.widths_dirty = false;
590    }
591
592    pub(crate) fn column_widths(&self) -> &[u32] {
593        &self.column_widths
594    }
595
596    pub(crate) fn is_dirty(&self) -> bool {
597        self.widths_dirty
598    }
599}
600
601/// A highlighted line range within a scrollable region.
602///
603/// Used with [`ScrollState::set_highlights`] to mark search results, error
604/// lines, or any per-line emphasis. The `scrollable_with_gutter` widget reads
605/// the active highlights and renders a background band on matching lines.
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub struct HighlightRange {
608    /// First line (0-based, relative to content top).
609    pub start_line: usize,
610    /// Number of lines in the range (1 = single line).
611    pub line_count: usize,
612}
613
614impl HighlightRange {
615    /// Create a single-line highlight at `line`.
616    ///
617    /// Field-name pairing: `start_line` + `line_count` → constructor named
618    /// `line`. Use [`Self::span`] for multi-line ranges.
619    pub fn line(line: usize) -> Self {
620        Self {
621            start_line: line,
622            line_count: 1,
623        }
624    }
625
626    /// Create a multi-line highlight starting at `start_line` covering `line_count` rows.
627    pub fn span(start_line: usize, line_count: usize) -> Self {
628        Self {
629            start_line,
630            line_count: line_count.max(1),
631        }
632    }
633
634    /// Check whether the given absolute line index falls within this range.
635    pub fn contains(&self, line: usize) -> bool {
636        line >= self.start_line && line < self.start_line + self.line_count
637    }
638}
639
640/// State for a scrollable container.
641///
642/// Pass a mutable reference to `Context::scrollable` each frame. The context
643/// updates `offset` and the internal bounds automatically based on mouse wheel
644/// and drag events.
645#[derive(Debug, Clone)]
646pub struct ScrollState {
647    /// Current vertical scroll offset in rows.
648    pub offset: usize,
649    content_height: u32,
650    viewport_height: u32,
651    highlights: Vec<HighlightRange>,
652    current_highlight: Option<usize>,
653}
654
655impl ScrollState {
656    /// Create scroll state starting at offset 0.
657    pub fn new() -> Self {
658        Self {
659            offset: 0,
660            content_height: 0,
661            viewport_height: 0,
662            highlights: Vec::new(),
663            current_highlight: None,
664        }
665    }
666
667    /// Check if scrolling upward is possible (offset is greater than 0).
668    pub fn can_scroll_up(&self) -> bool {
669        self.offset > 0
670    }
671
672    /// Check if scrolling downward is possible (content extends below the viewport).
673    pub fn can_scroll_down(&self) -> bool {
674        (self.offset as u32) + self.viewport_height < self.content_height
675    }
676
677    /// Get the total content height in rows.
678    pub fn content_height(&self) -> u32 {
679        self.content_height
680    }
681
682    /// Get the viewport height in rows.
683    pub fn viewport_height(&self) -> u32 {
684        self.viewport_height
685    }
686
687    /// Get the scroll progress as a ratio in [0.0, 1.0].
688    pub fn progress(&self) -> f32 {
689        let max = self.content_height.saturating_sub(self.viewport_height);
690        if max == 0 {
691            0.0
692        } else {
693            self.offset as f32 / max as f32
694        }
695    }
696
697    /// Scroll up by the given number of rows, clamped to 0.
698    pub fn scroll_up(&mut self, amount: usize) {
699        self.offset = self.offset.saturating_sub(amount);
700    }
701
702    /// Scroll down by the given number of rows, clamped to the maximum offset.
703    pub fn scroll_down(&mut self, amount: usize) {
704        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
705        self.offset = (self.offset + amount).min(max_offset);
706    }
707
708    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
709        self.content_height = content_height;
710        self.viewport_height = viewport_height;
711    }
712
713    /// Set the active highlight ranges. Replaces any previous highlights.
714    ///
715    /// Selecting the first highlight automatically when the list is non-empty
716    /// matches the behavior of search-result navigation in code editors.
717    pub fn set_highlights(&mut self, ranges: &[HighlightRange]) {
718        self.highlights.clear();
719        self.highlights.extend_from_slice(ranges);
720        self.current_highlight = if self.highlights.is_empty() {
721            None
722        } else {
723            Some(0)
724        };
725    }
726
727    /// Read-only access to the active highlight ranges.
728    pub fn highlights(&self) -> &[HighlightRange] {
729        &self.highlights
730    }
731
732    /// Index of the currently focused highlight, if any.
733    pub fn current_highlight(&self) -> Option<usize> {
734        self.current_highlight
735    }
736
737    /// Clear all highlights and reset the current index.
738    pub fn clear_highlights(&mut self) {
739        self.highlights.clear();
740        self.current_highlight = None;
741    }
742
743    /// Advance to the next highlight, scrolling the viewport to show it.
744    /// Wraps from last to first.
745    pub fn highlight_next(&mut self) {
746        if self.highlights.is_empty() {
747            return;
748        }
749        let next = match self.current_highlight {
750            Some(i) => (i + 1) % self.highlights.len(),
751            None => 0,
752        };
753        self.current_highlight = Some(next);
754        self.scroll_to_current_highlight();
755    }
756
757    /// Move to the previous highlight, scrolling the viewport to show it.
758    /// Wraps from first to last.
759    pub fn highlight_previous(&mut self) {
760        if self.highlights.is_empty() {
761            return;
762        }
763        let next = match self.current_highlight {
764            Some(i) => {
765                if i == 0 {
766                    self.highlights.len() - 1
767                } else {
768                    i - 1
769                }
770            }
771            None => 0,
772        };
773        self.current_highlight = Some(next);
774        self.scroll_to_current_highlight();
775    }
776
777    /// Scroll the viewport so the currently focused highlight is visible
778    /// with one line of context above when possible.
779    pub fn scroll_to_current_highlight(&mut self) {
780        let Some(idx) = self.current_highlight else {
781            return;
782        };
783        let Some(range) = self.highlights.get(idx).copied() else {
784            return;
785        };
786        let target = range.start_line;
787        let viewport = self.viewport_height as usize;
788        let content = self.content_height as usize;
789        let max_offset = content.saturating_sub(viewport);
790        if target < self.offset {
791            self.offset = target.saturating_sub(1).min(max_offset);
792        } else if viewport > 0 && target >= self.offset + viewport {
793            let desired = target + 2;
794            let new_offset = desired.saturating_sub(viewport);
795            self.offset = new_offset.min(max_offset);
796        } else if self.offset > max_offset {
797            self.offset = max_offset;
798        }
799    }
800}
801
802impl Default for ScrollState {
803    fn default() -> Self {
804        Self::new()
805    }
806}
807
808/// State for a [`crate::Context::split_pane`] /
809/// [`crate::Context::vsplit_pane`] container.
810///
811/// Tracks the split ratio and drag state. Pass a mutable reference each frame
812/// — the widget updates `ratio` in place when the user drags the handle or
813/// presses arrow keys with the handle focused.
814#[derive(Debug, Clone, PartialEq)]
815pub struct SplitPaneState {
816    /// Fraction of space given to the first pane. Clamped to
817    /// `[min_ratio, 1.0 - min_ratio]`.
818    pub ratio: f64,
819    /// Whether the handle is currently being dragged.
820    pub dragging: bool,
821    /// Minimum fraction allocated to either pane. Default: `0.10`.
822    pub min_ratio: f64,
823}
824
825/// Default minimum fraction of either pane, used by [`SplitPaneState::new`].
826///
827/// Crate-internal: there is no public path that benefits from constructing
828/// with this constant — call [`SplitPaneState::new`] for the default (0.10)
829/// or [`SplitPaneState::with_min_ratio`] to override per-instance.
830pub(crate) const DEFAULT_SPLIT_MIN_RATIO: f64 = 0.10;
831
832impl SplitPaneState {
833    /// Create split state with the given initial ratio, clamped to
834    /// `[DEFAULT_SPLIT_MIN_RATIO, 1.0 - DEFAULT_SPLIT_MIN_RATIO]` (default
835    /// `[0.10, 0.90]`).
836    pub fn new(ratio: f64) -> Self {
837        let min_ratio = DEFAULT_SPLIT_MIN_RATIO;
838        let clamped = ratio.clamp(min_ratio, 1.0 - min_ratio);
839        Self {
840            ratio: clamped,
841            dragging: false,
842            min_ratio,
843        }
844    }
845
846    /// Override the minimum ratio for either pane (clamped to `[0.0, 0.49]`).
847    pub fn with_min_ratio(mut self, min: f64) -> Self {
848        self.min_ratio = min.clamp(0.0, 0.49);
849        self.ratio = self.ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
850        self
851    }
852
853    /// Set the ratio, clamped to `[min_ratio, 1.0 - min_ratio]`.
854    pub fn set_ratio(&mut self, ratio: f64) {
855        self.ratio = ratio.clamp(self.min_ratio, 1.0 - self.min_ratio);
856    }
857}
858
859impl Default for SplitPaneState {
860    fn default() -> Self {
861        Self::new(0.5)
862    }
863}
864
865/// Column specification for [`crate::Context::grid_with()`].
866///
867/// Controls the width allocation of individual columns in a grid layout.
868///
869/// # Example
870///
871/// ```no_run
872/// use slt::GridColumn;
873/// # slt::run(|ui: &mut slt::Context| {
874/// ui.grid_with(&[
875///     GridColumn::Fixed(8),   // label column: exactly 8 chars
876///     GridColumn::Grow(1),    // flexible column
877///     GridColumn::Grow(1),    // flexible column
878///     GridColumn::Fixed(4),   // status column: exactly 4 chars
879/// ], |ui| {
880///     // children placed left-to-right, wrapping to next row
881/// });
882/// # });
883/// ```
884#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
885pub enum GridColumn {
886    /// Equal-width column with grow weight 1 (default `grid()` behavior).
887    Auto,
888    /// Fixed-width column in character cells. Does not grow or shrink.
889    Fixed(u32),
890    /// Flexible column with a custom grow weight. Higher values take
891    /// proportionally more space.
892    Grow(u16),
893    /// Column sized as a percentage (1–100) of the grid width.
894    Percent(u8),
895}