Skip to main content

slt/
widgets.rs

1//! Widget state types passed to [`Context`](crate::Context) widget methods.
2//!
3//! Each interactive widget (text input, list, tabs, table, etc.) has a
4//! corresponding state struct defined here. Create the state once, then pass
5//! a `&mut` reference each frame.
6
7use std::collections::HashSet;
8use std::fs;
9use std::path::PathBuf;
10use unicode_width::UnicodeWidthStr;
11
12type FormValidator = fn(&str) -> Result<(), String>;
13type TextInputValidator = Box<dyn Fn(&str) -> Result<(), String>>;
14
15/// State for a single-line text input widget.
16///
17/// Pass a mutable reference to `Context::text_input` each frame. The widget
18/// handles all keyboard events when focused.
19///
20/// # Example
21///
22/// ```no_run
23/// # use slt::widgets::TextInputState;
24/// # slt::run(|ui: &mut slt::Context| {
25/// let mut input = TextInputState::with_placeholder("Type here...");
26/// ui.text_input(&mut input);
27/// println!("{}", input.value);
28/// # });
29/// ```
30pub struct TextInputState {
31    /// The current input text.
32    pub value: String,
33    /// Cursor position as a character index into `value`.
34    pub cursor: usize,
35    /// Placeholder text shown when `value` is empty.
36    pub placeholder: String,
37    /// Maximum character count. Input is rejected beyond this limit.
38    pub max_length: Option<usize>,
39    /// The most recent validation error message, if any.
40    pub validation_error: Option<String>,
41    /// When `true`, input is displayed as `•` characters (for passwords).
42    pub masked: bool,
43    pub suggestions: Vec<String>,
44    pub suggestion_index: usize,
45    pub show_suggestions: bool,
46    /// Multiple validators that produce their own error messages.
47    validators: Vec<TextInputValidator>,
48    /// All current validation errors from all validators.
49    validation_errors: Vec<String>,
50}
51
52impl TextInputState {
53    /// Create an empty text input state.
54    pub fn new() -> Self {
55        Self {
56            value: String::new(),
57            cursor: 0,
58            placeholder: String::new(),
59            max_length: None,
60            validation_error: None,
61            masked: false,
62            suggestions: Vec::new(),
63            suggestion_index: 0,
64            show_suggestions: false,
65            validators: Vec::new(),
66            validation_errors: Vec::new(),
67        }
68    }
69
70    /// Create a text input with placeholder text shown when the value is empty.
71    pub fn with_placeholder(p: impl Into<String>) -> Self {
72        Self {
73            placeholder: p.into(),
74            ..Self::new()
75        }
76    }
77
78    /// Set the maximum allowed character count.
79    pub fn max_length(mut self, len: usize) -> Self {
80        self.max_length = Some(len);
81        self
82    }
83
84    /// Validate the current value and store the latest error message.
85    ///
86    /// Sets [`TextInputState::validation_error`] to `None` when validation
87    /// succeeds, or to `Some(error)` when validation fails.
88    ///
89    /// This is a backward-compatible shorthand that runs a single validator.
90    /// For multiple validators, use [`add_validator`](Self::add_validator) and [`run_validators`](Self::run_validators).
91    pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
92        self.validation_error = validator(&self.value).err();
93    }
94
95    /// Add a validator function that produces its own error message.
96    ///
97    /// Multiple validators can be added. Call [`run_validators`](Self::run_validators)
98    /// to execute all validators and collect their errors.
99    pub fn add_validator(&mut self, f: impl Fn(&str) -> Result<(), String> + 'static) {
100        self.validators.push(Box::new(f));
101    }
102
103    /// Run all registered validators and collect their error messages.
104    ///
105    /// Updates [`validation_errors`](Self::validation_errors) with all errors from all validators.
106    /// Also updates [`validation_error`](Self::validation_error) to the first error for backward compatibility.
107    pub fn run_validators(&mut self) {
108        self.validation_errors.clear();
109        for validator in &self.validators {
110            if let Err(err) = validator(&self.value) {
111                self.validation_errors.push(err);
112            }
113        }
114        self.validation_error = self.validation_errors.first().cloned();
115    }
116
117    /// Get all current validation errors from all validators.
118    pub fn errors(&self) -> &[String] {
119        &self.validation_errors
120    }
121
122    pub fn set_suggestions(&mut self, suggestions: Vec<String>) {
123        self.suggestions = suggestions;
124        self.suggestion_index = 0;
125        self.show_suggestions = !self.suggestions.is_empty();
126    }
127
128    pub fn matched_suggestions(&self) -> Vec<&str> {
129        if self.value.is_empty() {
130            return Vec::new();
131        }
132        let lower = self.value.to_lowercase();
133        self.suggestions
134            .iter()
135            .filter(|s| s.to_lowercase().starts_with(&lower))
136            .map(|s| s.as_str())
137            .collect()
138    }
139}
140
141impl Default for TextInputState {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147/// A single form field with label and validation.
148pub struct FormField {
149    /// Field label shown above the input.
150    pub label: String,
151    /// Text input state for this field.
152    pub input: TextInputState,
153    /// Validation error shown below the input when present.
154    pub error: Option<String>,
155}
156
157impl FormField {
158    /// Create a new form field with the given label.
159    pub fn new(label: impl Into<String>) -> Self {
160        Self {
161            label: label.into(),
162            input: TextInputState::new(),
163            error: None,
164        }
165    }
166
167    /// Set placeholder text for this field's input.
168    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
169        self.input.placeholder = p.into();
170        self
171    }
172}
173
174/// State for a form with multiple fields.
175pub struct FormState {
176    /// Ordered list of form fields.
177    pub fields: Vec<FormField>,
178    /// Whether the form has been successfully submitted.
179    pub submitted: bool,
180}
181
182impl FormState {
183    /// Create an empty form state.
184    pub fn new() -> Self {
185        Self {
186            fields: Vec::new(),
187            submitted: false,
188        }
189    }
190
191    /// Add a field and return the updated form for chaining.
192    pub fn field(mut self, field: FormField) -> Self {
193        self.fields.push(field);
194        self
195    }
196
197    /// Validate all fields with the given validators.
198    ///
199    /// Returns `true` when all validations pass.
200    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
201        let mut all_valid = true;
202        for (i, field) in self.fields.iter_mut().enumerate() {
203            if let Some(validator) = validators.get(i) {
204                match validator(&field.input.value) {
205                    Ok(()) => field.error = None,
206                    Err(msg) => {
207                        field.error = Some(msg);
208                        all_valid = false;
209                    }
210                }
211            }
212        }
213        all_valid
214    }
215
216    /// Get field value by index.
217    pub fn value(&self, index: usize) -> &str {
218        self.fields
219            .get(index)
220            .map(|f| f.input.value.as_str())
221            .unwrap_or("")
222    }
223}
224
225impl Default for FormState {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231/// State for toast notification display.
232///
233/// Add messages with [`ToastState::info`], [`ToastState::success`],
234/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
235/// `Context::toast` each frame. Expired messages are removed automatically.
236pub struct ToastState {
237    /// Active toast messages, ordered oldest-first.
238    pub messages: Vec<ToastMessage>,
239}
240
241/// A single toast notification message.
242pub struct ToastMessage {
243    /// The text content of the notification.
244    pub text: String,
245    /// Severity level, used to choose the display color.
246    pub level: ToastLevel,
247    /// The tick at which this message was created.
248    pub created_tick: u64,
249    /// How many ticks the message remains visible.
250    pub duration_ticks: u64,
251}
252
253/// Severity level for a [`ToastMessage`].
254pub enum ToastLevel {
255    /// Informational message (primary color).
256    Info,
257    /// Success message (success color).
258    Success,
259    /// Warning message (warning color).
260    Warning,
261    /// Error message (error color).
262    Error,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum AlertLevel {
267    Info,
268    Success,
269    Warning,
270    Error,
271}
272
273impl ToastState {
274    /// Create an empty toast state with no messages.
275    pub fn new() -> Self {
276        Self {
277            messages: Vec::new(),
278        }
279    }
280
281    /// Push an informational toast visible for 30 ticks.
282    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
283        self.push(text, ToastLevel::Info, tick, 30);
284    }
285
286    /// Push a success toast visible for 30 ticks.
287    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
288        self.push(text, ToastLevel::Success, tick, 30);
289    }
290
291    /// Push a warning toast visible for 50 ticks.
292    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
293        self.push(text, ToastLevel::Warning, tick, 50);
294    }
295
296    /// Push an error toast visible for 80 ticks.
297    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
298        self.push(text, ToastLevel::Error, tick, 80);
299    }
300
301    /// Push a toast with a custom level and duration.
302    pub fn push(
303        &mut self,
304        text: impl Into<String>,
305        level: ToastLevel,
306        tick: u64,
307        duration_ticks: u64,
308    ) {
309        self.messages.push(ToastMessage {
310            text: text.into(),
311            level,
312            created_tick: tick,
313            duration_ticks,
314        });
315    }
316
317    /// Remove all messages whose display duration has elapsed.
318    ///
319    /// Called automatically by `Context::toast` before rendering.
320    pub fn cleanup(&mut self, current_tick: u64) {
321        self.messages.retain(|message| {
322            current_tick < message.created_tick.saturating_add(message.duration_ticks)
323        });
324    }
325}
326
327impl Default for ToastState {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333/// State for a multi-line text area widget.
334///
335/// Pass a mutable reference to `Context::textarea` each frame along with the
336/// number of visible rows. The widget handles all keyboard events when focused.
337pub struct TextareaState {
338    /// The lines of text, one entry per line.
339    pub lines: Vec<String>,
340    /// Row index of the cursor (0-based, logical line).
341    pub cursor_row: usize,
342    /// Column index of the cursor within the current row (character index).
343    pub cursor_col: usize,
344    /// Maximum total character count across all lines.
345    pub max_length: Option<usize>,
346    /// When set, lines longer than this display-column width are soft-wrapped.
347    pub wrap_width: Option<u32>,
348    /// First visible visual line (managed internally by `textarea()`).
349    pub scroll_offset: usize,
350}
351
352impl TextareaState {
353    /// Create an empty text area state with one blank line.
354    pub fn new() -> Self {
355        Self {
356            lines: vec![String::new()],
357            cursor_row: 0,
358            cursor_col: 0,
359            max_length: None,
360            wrap_width: None,
361            scroll_offset: 0,
362        }
363    }
364
365    /// Return all lines joined with newline characters.
366    pub fn value(&self) -> String {
367        self.lines.join("\n")
368    }
369
370    /// Replace the content with the given text, splitting on newlines.
371    ///
372    /// Resets the cursor to the beginning of the first line.
373    pub fn set_value(&mut self, text: impl Into<String>) {
374        let value = text.into();
375        self.lines = value.split('\n').map(str::to_string).collect();
376        if self.lines.is_empty() {
377            self.lines.push(String::new());
378        }
379        self.cursor_row = 0;
380        self.cursor_col = 0;
381        self.scroll_offset = 0;
382    }
383
384    /// Set the maximum allowed total character count.
385    pub fn max_length(mut self, len: usize) -> Self {
386        self.max_length = Some(len);
387        self
388    }
389
390    /// Enable soft word-wrap at the given display-column width.
391    pub fn word_wrap(mut self, width: u32) -> Self {
392        self.wrap_width = Some(width);
393        self
394    }
395}
396
397impl Default for TextareaState {
398    fn default() -> Self {
399        Self::new()
400    }
401}
402
403/// State for an animated spinner widget.
404///
405/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
406/// `Context::spinner` each frame. The frame advances automatically with the
407/// tick counter.
408pub struct SpinnerState {
409    chars: Vec<char>,
410}
411
412impl SpinnerState {
413    /// Create a dots-style spinner using braille characters.
414    ///
415    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
416    pub fn dots() -> Self {
417        Self {
418            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
419        }
420    }
421
422    /// Create a line-style spinner using ASCII characters.
423    ///
424    /// Cycles through: `| / - \`
425    pub fn line() -> Self {
426        Self {
427            chars: vec!['|', '/', '-', '\\'],
428        }
429    }
430
431    /// Return the spinner character for the given tick.
432    pub fn frame(&self, tick: u64) -> char {
433        if self.chars.is_empty() {
434            return ' ';
435        }
436        self.chars[tick as usize % self.chars.len()]
437    }
438}
439
440impl Default for SpinnerState {
441    fn default() -> Self {
442        Self::dots()
443    }
444}
445
446/// State for a selectable list widget.
447///
448/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
449/// keys (and `k`/`j`) move the selection when the widget is focused.
450pub struct ListState {
451    /// The list items as display strings.
452    pub items: Vec<String>,
453    /// Index of the currently selected item.
454    pub selected: usize,
455    /// Case-insensitive substring filter applied to list items.
456    pub filter: String,
457    view_indices: Vec<usize>,
458}
459
460impl ListState {
461    /// Create a list with the given items. The first item is selected initially.
462    pub fn new(items: Vec<impl Into<String>>) -> Self {
463        let len = items.len();
464        Self {
465            items: items.into_iter().map(Into::into).collect(),
466            selected: 0,
467            filter: String::new(),
468            view_indices: (0..len).collect(),
469        }
470    }
471
472    /// Replace the list items and rebuild the view index.
473    ///
474    /// Use this instead of assigning `items` directly to ensure the internal
475    /// filter/view state stays consistent.
476    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
477        self.items = items.into_iter().map(Into::into).collect();
478        self.selected = self.selected.min(self.items.len().saturating_sub(1));
479        self.rebuild_view();
480    }
481
482    /// Set the filter string. Multiple space-separated tokens are AND'd
483    /// together — all tokens must match across any cell in the same row.
484    /// Empty string disables filtering.
485    pub fn set_filter(&mut self, filter: impl Into<String>) {
486        self.filter = filter.into();
487        self.rebuild_view();
488    }
489
490    /// Returns indices of items visible after filtering.
491    pub fn visible_indices(&self) -> &[usize] {
492        &self.view_indices
493    }
494
495    /// Get the currently selected item text, or `None` if the list is empty.
496    pub fn selected_item(&self) -> Option<&str> {
497        let data_idx = *self.view_indices.get(self.selected)?;
498        self.items.get(data_idx).map(String::as_str)
499    }
500
501    fn rebuild_view(&mut self) {
502        let tokens: Vec<String> = self
503            .filter
504            .split_whitespace()
505            .map(|t| t.to_lowercase())
506            .collect();
507        self.view_indices = if tokens.is_empty() {
508            (0..self.items.len()).collect()
509        } else {
510            (0..self.items.len())
511                .filter(|&i| {
512                    tokens
513                        .iter()
514                        .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
515                })
516                .collect()
517        };
518        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
519            self.selected = self.view_indices.len() - 1;
520        }
521    }
522}
523
524#[derive(Debug, Clone)]
525pub struct FilePickerState {
526    pub current_dir: PathBuf,
527    pub entries: Vec<FileEntry>,
528    pub selected: usize,
529    pub selected_file: Option<PathBuf>,
530    pub show_hidden: bool,
531    pub extensions: Vec<String>,
532    pub dirty: bool,
533}
534
535#[derive(Debug, Clone)]
536pub struct FileEntry {
537    pub name: String,
538    pub path: PathBuf,
539    pub is_dir: bool,
540    pub size: u64,
541}
542
543impl FilePickerState {
544    pub fn new(dir: impl Into<PathBuf>) -> Self {
545        Self {
546            current_dir: dir.into(),
547            entries: Vec::new(),
548            selected: 0,
549            selected_file: None,
550            show_hidden: false,
551            extensions: Vec::new(),
552            dirty: true,
553        }
554    }
555
556    pub fn show_hidden(mut self, show: bool) -> Self {
557        self.show_hidden = show;
558        self.dirty = true;
559        self
560    }
561
562    pub fn extensions(mut self, exts: &[&str]) -> Self {
563        self.extensions = exts
564            .iter()
565            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
566            .filter(|ext| !ext.is_empty())
567            .collect();
568        self.dirty = true;
569        self
570    }
571
572    pub fn selected(&self) -> Option<&PathBuf> {
573        self.selected_file.as_ref()
574    }
575
576    pub fn refresh(&mut self) {
577        let mut entries = Vec::new();
578
579        if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
580            for dir_entry in read_dir.flatten() {
581                let name = dir_entry.file_name().to_string_lossy().to_string();
582                if !self.show_hidden && name.starts_with('.') {
583                    continue;
584                }
585
586                let Ok(file_type) = dir_entry.file_type() else {
587                    continue;
588                };
589                if file_type.is_symlink() {
590                    continue;
591                }
592
593                let path = dir_entry.path();
594                let is_dir = file_type.is_dir();
595
596                if !is_dir && !self.extensions.is_empty() {
597                    let ext = path
598                        .extension()
599                        .and_then(|e| e.to_str())
600                        .map(|e| e.to_ascii_lowercase());
601                    let Some(ext) = ext else {
602                        continue;
603                    };
604                    if !self.extensions.iter().any(|allowed| allowed == &ext) {
605                        continue;
606                    }
607                }
608
609                let size = if is_dir {
610                    0
611                } else {
612                    fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
613                };
614
615                entries.push(FileEntry {
616                    name,
617                    path,
618                    is_dir,
619                    size,
620                });
621            }
622        }
623
624        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
625            (true, false) => std::cmp::Ordering::Less,
626            (false, true) => std::cmp::Ordering::Greater,
627            _ => a
628                .name
629                .to_ascii_lowercase()
630                .cmp(&b.name.to_ascii_lowercase())
631                .then_with(|| a.name.cmp(&b.name)),
632        });
633
634        self.entries = entries;
635        if self.entries.is_empty() {
636            self.selected = 0;
637        } else {
638            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
639        }
640        self.dirty = false;
641    }
642}
643
644impl Default for FilePickerState {
645    fn default() -> Self {
646        Self::new(".")
647    }
648}
649
650/// State for a tab navigation widget.
651///
652/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
653/// keys cycle through tabs when the widget is focused.
654pub struct TabsState {
655    /// The tab labels displayed in the bar.
656    pub labels: Vec<String>,
657    /// Index of the currently active tab.
658    pub selected: usize,
659}
660
661impl TabsState {
662    /// Create tabs with the given labels. The first tab is active initially.
663    pub fn new(labels: Vec<impl Into<String>>) -> Self {
664        Self {
665            labels: labels.into_iter().map(Into::into).collect(),
666            selected: 0,
667        }
668    }
669
670    /// Get the currently selected tab label, or `None` if there are no tabs.
671    pub fn selected_label(&self) -> Option<&str> {
672        self.labels.get(self.selected).map(String::as_str)
673    }
674}
675
676/// State for a data table widget.
677///
678/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
679/// keys move the row selection when the widget is focused. Column widths are
680/// computed automatically from header and cell content.
681pub struct TableState {
682    /// Column header labels.
683    pub headers: Vec<String>,
684    /// Table rows, each a `Vec` of cell strings.
685    pub rows: Vec<Vec<String>>,
686    /// Index of the currently selected row.
687    pub selected: usize,
688    column_widths: Vec<u32>,
689    dirty: bool,
690    /// Sorted column index (`None` means no sorting).
691    pub sort_column: Option<usize>,
692    /// Sort direction (`true` for ascending).
693    pub sort_ascending: bool,
694    /// Case-insensitive substring filter applied across all cells.
695    pub filter: String,
696    /// Current page (0-based) when pagination is enabled.
697    pub page: usize,
698    /// Rows per page (`0` disables pagination).
699    pub page_size: usize,
700    view_indices: Vec<usize>,
701}
702
703impl TableState {
704    /// Create a table with headers and rows. Column widths are computed immediately.
705    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
706        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
707        let rows: Vec<Vec<String>> = rows
708            .into_iter()
709            .map(|r| r.into_iter().map(Into::into).collect())
710            .collect();
711        let mut state = Self {
712            headers,
713            rows,
714            selected: 0,
715            column_widths: Vec::new(),
716            dirty: true,
717            sort_column: None,
718            sort_ascending: true,
719            filter: String::new(),
720            page: 0,
721            page_size: 0,
722            view_indices: Vec::new(),
723        };
724        state.rebuild_view();
725        state.recompute_widths();
726        state
727    }
728
729    /// Replace all rows, preserving the selection index if possible.
730    ///
731    /// If the current selection is beyond the new row count, it is clamped to
732    /// the last row.
733    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
734        self.rows = rows
735            .into_iter()
736            .map(|r| r.into_iter().map(Into::into).collect())
737            .collect();
738        self.rebuild_view();
739    }
740
741    /// Sort by a specific column index. If already sorted by this column, toggles direction.
742    pub fn toggle_sort(&mut self, column: usize) {
743        if self.sort_column == Some(column) {
744            self.sort_ascending = !self.sort_ascending;
745        } else {
746            self.sort_column = Some(column);
747            self.sort_ascending = true;
748        }
749        self.rebuild_view();
750    }
751
752    /// Sort by column without toggling (always sets to ascending first).
753    pub fn sort_by(&mut self, column: usize) {
754        self.sort_column = Some(column);
755        self.sort_ascending = true;
756        self.rebuild_view();
757    }
758
759    /// Set the filter string. Multiple space-separated tokens are AND'd
760    /// together — all tokens must match across any cell in the same row.
761    /// Empty string disables filtering.
762    pub fn set_filter(&mut self, filter: impl Into<String>) {
763        self.filter = filter.into();
764        self.page = 0;
765        self.rebuild_view();
766    }
767
768    /// Clear sorting.
769    pub fn clear_sort(&mut self) {
770        self.sort_column = None;
771        self.sort_ascending = true;
772        self.rebuild_view();
773    }
774
775    /// Move to the next page. Does nothing if already on the last page.
776    pub fn next_page(&mut self) {
777        if self.page_size == 0 {
778            return;
779        }
780        let last_page = self.total_pages().saturating_sub(1);
781        self.page = (self.page + 1).min(last_page);
782    }
783
784    /// Move to the previous page. Does nothing if already on page 0.
785    pub fn prev_page(&mut self) {
786        self.page = self.page.saturating_sub(1);
787    }
788
789    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
790    pub fn total_pages(&self) -> usize {
791        if self.page_size == 0 {
792            return 1;
793        }
794
795        let len = self.view_indices.len();
796        if len == 0 {
797            1
798        } else {
799            len.div_ceil(self.page_size)
800        }
801    }
802
803    /// Get the visible row indices after filtering and sorting (used internally by table()).
804    pub fn visible_indices(&self) -> &[usize] {
805        &self.view_indices
806    }
807
808    /// Get the currently selected row data, or `None` if the table is empty.
809    pub fn selected_row(&self) -> Option<&[String]> {
810        if self.view_indices.is_empty() {
811            return None;
812        }
813        let data_idx = self.view_indices.get(self.selected)?;
814        self.rows.get(*data_idx).map(|r| r.as_slice())
815    }
816
817    /// Recompute view_indices based on current sort + filter settings.
818    fn rebuild_view(&mut self) {
819        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
820
821        let tokens: Vec<String> = self
822            .filter
823            .split_whitespace()
824            .map(|t| t.to_lowercase())
825            .collect();
826        if !tokens.is_empty() {
827            indices.retain(|&idx| {
828                let row = match self.rows.get(idx) {
829                    Some(r) => r,
830                    None => return false,
831                };
832                tokens.iter().all(|token| {
833                    row.iter()
834                        .any(|cell| cell.to_lowercase().contains(token.as_str()))
835                })
836            });
837        }
838
839        if let Some(column) = self.sort_column {
840            indices.sort_by(|a, b| {
841                let left = self
842                    .rows
843                    .get(*a)
844                    .and_then(|row| row.get(column))
845                    .map(String::as_str)
846                    .unwrap_or("");
847                let right = self
848                    .rows
849                    .get(*b)
850                    .and_then(|row| row.get(column))
851                    .map(String::as_str)
852                    .unwrap_or("");
853
854                match (left.parse::<f64>(), right.parse::<f64>()) {
855                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
856                    _ => left.to_lowercase().cmp(&right.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.dirty = true;
875    }
876
877    pub(crate) fn recompute_widths(&mut self) {
878        let col_count = self.headers.len();
879        self.column_widths = vec![0u32; col_count];
880        for (i, header) in self.headers.iter().enumerate() {
881            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
882            if self.sort_column == Some(i) {
883                width += 2;
884            }
885            self.column_widths[i] = width;
886        }
887        for row in &self.rows {
888            for (i, cell) in row.iter().enumerate() {
889                if i < col_count {
890                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
891                    self.column_widths[i] = self.column_widths[i].max(w);
892                }
893            }
894        }
895        self.dirty = false;
896    }
897
898    pub(crate) fn column_widths(&self) -> &[u32] {
899        &self.column_widths
900    }
901
902    pub(crate) fn is_dirty(&self) -> bool {
903        self.dirty
904    }
905}
906
907/// State for a scrollable container.
908///
909/// Pass a mutable reference to `Context::scrollable` each frame. The context
910/// updates `offset` and the internal bounds automatically based on mouse wheel
911/// and drag events.
912pub struct ScrollState {
913    /// Current vertical scroll offset in rows.
914    pub offset: usize,
915    content_height: u32,
916    viewport_height: u32,
917}
918
919impl ScrollState {
920    /// Create scroll state starting at offset 0.
921    pub fn new() -> Self {
922        Self {
923            offset: 0,
924            content_height: 0,
925            viewport_height: 0,
926        }
927    }
928
929    /// Check if scrolling upward is possible (offset is greater than 0).
930    pub fn can_scroll_up(&self) -> bool {
931        self.offset > 0
932    }
933
934    /// Check if scrolling downward is possible (content extends below the viewport).
935    pub fn can_scroll_down(&self) -> bool {
936        (self.offset as u32) + self.viewport_height < self.content_height
937    }
938
939    /// Get the total content height in rows.
940    pub fn content_height(&self) -> u32 {
941        self.content_height
942    }
943
944    /// Get the viewport height in rows.
945    pub fn viewport_height(&self) -> u32 {
946        self.viewport_height
947    }
948
949    /// Get the scroll progress as a ratio in [0.0, 1.0].
950    pub fn progress(&self) -> f32 {
951        let max = self.content_height.saturating_sub(self.viewport_height);
952        if max == 0 {
953            0.0
954        } else {
955            self.offset as f32 / max as f32
956        }
957    }
958
959    /// Scroll up by the given number of rows, clamped to 0.
960    pub fn scroll_up(&mut self, amount: usize) {
961        self.offset = self.offset.saturating_sub(amount);
962    }
963
964    /// Scroll down by the given number of rows, clamped to the maximum offset.
965    pub fn scroll_down(&mut self, amount: usize) {
966        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
967        self.offset = (self.offset + amount).min(max_offset);
968    }
969
970    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
971        self.content_height = content_height;
972        self.viewport_height = viewport_height;
973    }
974}
975
976impl Default for ScrollState {
977    fn default() -> Self {
978        Self::new()
979    }
980}
981
982/// Visual variant for buttons.
983///
984/// Controls the color scheme used when rendering a button. Pass to
985/// [`crate::Context::button_with`] to create styled button variants.
986///
987/// - `Default` — theme text color, primary when focused (same as `button()`)
988/// - `Primary` — primary color background with contrasting text
989/// - `Danger` — error/red color for destructive actions
990/// - `Outline` — bordered appearance without fill
991#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
992pub enum ButtonVariant {
993    /// Standard button style.
994    #[default]
995    Default,
996    /// Filled button with primary background color.
997    Primary,
998    /// Filled button with error/danger background color.
999    Danger,
1000    /// Bordered button without background fill.
1001    Outline,
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1005pub enum Trend {
1006    Up,
1007    Down,
1008}
1009
1010// ── Select / Dropdown ─────────────────────────────────────────────────
1011
1012/// State for a dropdown select widget.
1013///
1014/// Renders as a single-line button showing the selected option. When activated,
1015/// expands into a vertical list overlay for picking an option.
1016pub struct SelectState {
1017    pub items: Vec<String>,
1018    pub selected: usize,
1019    pub open: bool,
1020    pub placeholder: String,
1021    cursor: usize,
1022}
1023
1024impl SelectState {
1025    pub fn new(items: Vec<impl Into<String>>) -> Self {
1026        Self {
1027            items: items.into_iter().map(Into::into).collect(),
1028            selected: 0,
1029            open: false,
1030            placeholder: String::new(),
1031            cursor: 0,
1032        }
1033    }
1034
1035    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
1036        self.placeholder = p.into();
1037        self
1038    }
1039
1040    pub fn selected_item(&self) -> Option<&str> {
1041        self.items.get(self.selected).map(String::as_str)
1042    }
1043
1044    pub(crate) fn cursor(&self) -> usize {
1045        self.cursor
1046    }
1047
1048    pub(crate) fn set_cursor(&mut self, c: usize) {
1049        self.cursor = c;
1050    }
1051}
1052
1053// ── Radio ─────────────────────────────────────────────────────────────
1054
1055/// State for a radio button group.
1056///
1057/// Renders a vertical list of mutually-exclusive options with `●`/`○` markers.
1058pub struct RadioState {
1059    pub items: Vec<String>,
1060    pub selected: usize,
1061}
1062
1063impl RadioState {
1064    pub fn new(items: Vec<impl Into<String>>) -> Self {
1065        Self {
1066            items: items.into_iter().map(Into::into).collect(),
1067            selected: 0,
1068        }
1069    }
1070
1071    pub fn selected_item(&self) -> Option<&str> {
1072        self.items.get(self.selected).map(String::as_str)
1073    }
1074}
1075
1076// ── Multi-Select ──────────────────────────────────────────────────────
1077
1078/// State for a multi-select list.
1079///
1080/// Like [`ListState`] but allows toggling multiple items with Space.
1081pub struct MultiSelectState {
1082    pub items: Vec<String>,
1083    pub cursor: usize,
1084    pub selected: HashSet<usize>,
1085}
1086
1087impl MultiSelectState {
1088    pub fn new(items: Vec<impl Into<String>>) -> Self {
1089        Self {
1090            items: items.into_iter().map(Into::into).collect(),
1091            cursor: 0,
1092            selected: HashSet::new(),
1093        }
1094    }
1095
1096    pub fn selected_items(&self) -> Vec<&str> {
1097        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1098        indices.sort();
1099        indices
1100            .iter()
1101            .filter_map(|&i| self.items.get(i).map(String::as_str))
1102            .collect()
1103    }
1104
1105    pub fn toggle(&mut self, index: usize) {
1106        if self.selected.contains(&index) {
1107            self.selected.remove(&index);
1108        } else {
1109            self.selected.insert(index);
1110        }
1111    }
1112}
1113
1114// ── Tree ──────────────────────────────────────────────────────────────
1115
1116/// A node in a tree view.
1117pub struct TreeNode {
1118    pub label: String,
1119    pub children: Vec<TreeNode>,
1120    pub expanded: bool,
1121}
1122
1123impl TreeNode {
1124    pub fn new(label: impl Into<String>) -> Self {
1125        Self {
1126            label: label.into(),
1127            children: Vec::new(),
1128            expanded: false,
1129        }
1130    }
1131
1132    pub fn expanded(mut self) -> Self {
1133        self.expanded = true;
1134        self
1135    }
1136
1137    pub fn children(mut self, children: Vec<TreeNode>) -> Self {
1138        self.children = children;
1139        self
1140    }
1141
1142    pub fn is_leaf(&self) -> bool {
1143        self.children.is_empty()
1144    }
1145
1146    fn flatten(&self, depth: usize, out: &mut Vec<FlatTreeEntry>) {
1147        out.push(FlatTreeEntry {
1148            depth,
1149            label: self.label.clone(),
1150            is_leaf: self.is_leaf(),
1151            expanded: self.expanded,
1152        });
1153        if self.expanded {
1154            for child in &self.children {
1155                child.flatten(depth + 1, out);
1156            }
1157        }
1158    }
1159}
1160
1161pub(crate) struct FlatTreeEntry {
1162    pub depth: usize,
1163    pub label: String,
1164    pub is_leaf: bool,
1165    pub expanded: bool,
1166}
1167
1168/// State for a hierarchical tree view widget.
1169pub struct TreeState {
1170    pub nodes: Vec<TreeNode>,
1171    pub selected: usize,
1172}
1173
1174impl TreeState {
1175    pub fn new(nodes: Vec<TreeNode>) -> Self {
1176        Self { nodes, selected: 0 }
1177    }
1178
1179    pub(crate) fn flatten(&self) -> Vec<FlatTreeEntry> {
1180        let mut entries = Vec::new();
1181        for node in &self.nodes {
1182            node.flatten(0, &mut entries);
1183        }
1184        entries
1185    }
1186
1187    pub(crate) fn toggle_at(&mut self, flat_index: usize) {
1188        let mut counter = 0usize;
1189        Self::toggle_recursive(&mut self.nodes, flat_index, &mut counter);
1190    }
1191
1192    fn toggle_recursive(nodes: &mut [TreeNode], target: usize, counter: &mut usize) -> bool {
1193        for node in nodes.iter_mut() {
1194            if *counter == target {
1195                if !node.is_leaf() {
1196                    node.expanded = !node.expanded;
1197                }
1198                return true;
1199            }
1200            *counter += 1;
1201            if node.expanded && Self::toggle_recursive(&mut node.children, target, counter) {
1202                return true;
1203            }
1204        }
1205        false
1206    }
1207}
1208
1209// ── Command Palette ───────────────────────────────────────────────────
1210
1211/// A single command entry in the palette.
1212pub struct PaletteCommand {
1213    pub label: String,
1214    pub description: String,
1215    pub shortcut: Option<String>,
1216}
1217
1218impl PaletteCommand {
1219    pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
1220        Self {
1221            label: label.into(),
1222            description: description.into(),
1223            shortcut: None,
1224        }
1225    }
1226
1227    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
1228        self.shortcut = Some(s.into());
1229        self
1230    }
1231}
1232
1233/// State for a command palette overlay.
1234///
1235/// Renders as a modal with a search input and filtered command list.
1236pub struct CommandPaletteState {
1237    pub commands: Vec<PaletteCommand>,
1238    pub input: String,
1239    pub cursor: usize,
1240    pub open: bool,
1241    selected: usize,
1242}
1243
1244impl CommandPaletteState {
1245    pub fn new(commands: Vec<PaletteCommand>) -> Self {
1246        Self {
1247            commands,
1248            input: String::new(),
1249            cursor: 0,
1250            open: false,
1251            selected: 0,
1252        }
1253    }
1254
1255    pub fn toggle(&mut self) {
1256        self.open = !self.open;
1257        if self.open {
1258            self.input.clear();
1259            self.cursor = 0;
1260            self.selected = 0;
1261        }
1262    }
1263
1264    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
1265        let tokens: Vec<String> = self
1266            .input
1267            .split_whitespace()
1268            .map(|t| t.to_lowercase())
1269            .collect();
1270        if tokens.is_empty() {
1271            return (0..self.commands.len()).collect();
1272        }
1273        self.commands
1274            .iter()
1275            .enumerate()
1276            .filter(|(_, cmd)| {
1277                let label = cmd.label.to_lowercase();
1278                let desc = cmd.description.to_lowercase();
1279                tokens
1280                    .iter()
1281                    .all(|token| label.contains(token.as_str()) || desc.contains(token.as_str()))
1282            })
1283            .map(|(i, _)| i)
1284            .collect()
1285    }
1286
1287    pub(crate) fn selected(&self) -> usize {
1288        self.selected
1289    }
1290
1291    pub(crate) fn set_selected(&mut self, s: usize) {
1292        self.selected = s;
1293    }
1294}
1295
1296/// State for a streaming text display.
1297///
1298/// Accumulates text chunks as they arrive from an LLM stream.
1299/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
1300pub struct StreamingTextState {
1301    /// The accumulated text content.
1302    pub content: String,
1303    /// Whether the stream is still receiving data.
1304    pub streaming: bool,
1305    /// Cursor blink state (for the typing indicator).
1306    pub(crate) cursor_visible: bool,
1307    pub(crate) cursor_tick: u64,
1308}
1309
1310impl StreamingTextState {
1311    /// Create a new empty streaming text state.
1312    pub fn new() -> Self {
1313        Self {
1314            content: String::new(),
1315            streaming: false,
1316            cursor_visible: true,
1317            cursor_tick: 0,
1318        }
1319    }
1320
1321    /// Append a chunk of text (e.g., from an LLM stream delta).
1322    pub fn push(&mut self, chunk: &str) {
1323        self.content.push_str(chunk);
1324    }
1325
1326    /// Mark the stream as complete (hides the typing cursor).
1327    pub fn finish(&mut self) {
1328        self.streaming = false;
1329    }
1330
1331    /// Start a new streaming session, clearing previous content.
1332    pub fn start(&mut self) {
1333        self.content.clear();
1334        self.streaming = true;
1335        self.cursor_visible = true;
1336        self.cursor_tick = 0;
1337    }
1338
1339    /// Clear all content and reset state.
1340    pub fn clear(&mut self) {
1341        self.content.clear();
1342        self.streaming = false;
1343        self.cursor_visible = true;
1344        self.cursor_tick = 0;
1345    }
1346}
1347
1348impl Default for StreamingTextState {
1349    fn default() -> Self {
1350        Self::new()
1351    }
1352}
1353
1354/// Approval state for a tool call.
1355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1356pub enum ApprovalAction {
1357    /// No action taken yet.
1358    Pending,
1359    /// User approved the tool call.
1360    Approved,
1361    /// User rejected the tool call.
1362    Rejected,
1363}
1364
1365/// State for a tool approval widget.
1366///
1367/// Displays a tool call with approve/reject buttons for human-in-the-loop
1368/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
1369/// each frame.
1370pub struct ToolApprovalState {
1371    /// The name of the tool being invoked.
1372    pub tool_name: String,
1373    /// A human-readable description of what the tool will do.
1374    pub description: String,
1375    /// The current approval status.
1376    pub action: ApprovalAction,
1377}
1378
1379impl ToolApprovalState {
1380    /// Create a new tool approval prompt.
1381    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
1382        Self {
1383            tool_name: tool_name.into(),
1384            description: description.into(),
1385            action: ApprovalAction::Pending,
1386        }
1387    }
1388
1389    /// Reset to pending state.
1390    pub fn reset(&mut self) {
1391        self.action = ApprovalAction::Pending;
1392    }
1393}
1394
1395/// Item in a context bar showing active context sources.
1396#[derive(Debug, Clone)]
1397pub struct ContextItem {
1398    /// Display label for this context source.
1399    pub label: String,
1400    /// Token count or size indicator.
1401    pub tokens: usize,
1402}
1403
1404impl ContextItem {
1405    /// Create a new context item with a label and token count.
1406    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
1407        Self {
1408            label: label.into(),
1409            tokens,
1410        }
1411    }
1412}