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` with all errors from all validators.
106    /// Also updates `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.
148#[derive(Default)]
149pub struct FormField {
150    /// Field label shown above the input.
151    pub label: String,
152    /// Text input state for this field.
153    pub input: TextInputState,
154    /// Validation error shown below the input when present.
155    pub error: Option<String>,
156}
157
158impl FormField {
159    /// Create a new form field with the given label.
160    pub fn new(label: impl Into<String>) -> Self {
161        Self {
162            label: label.into(),
163            input: TextInputState::new(),
164            error: None,
165        }
166    }
167
168    /// Set placeholder text for this field's input.
169    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
170        self.input.placeholder = p.into();
171        self
172    }
173}
174
175/// State for a form with multiple fields.
176pub struct FormState {
177    /// Ordered list of form fields.
178    pub fields: Vec<FormField>,
179    /// Whether the form has been successfully submitted.
180    pub submitted: bool,
181}
182
183impl FormState {
184    /// Create an empty form state.
185    pub fn new() -> Self {
186        Self {
187            fields: Vec::new(),
188            submitted: false,
189        }
190    }
191
192    /// Add a field and return the updated form for chaining.
193    pub fn field(mut self, field: FormField) -> Self {
194        self.fields.push(field);
195        self
196    }
197
198    /// Validate all fields with the given validators.
199    ///
200    /// Returns `true` when all validations pass.
201    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
202        let mut all_valid = true;
203        for (i, field) in self.fields.iter_mut().enumerate() {
204            if let Some(validator) = validators.get(i) {
205                match validator(&field.input.value) {
206                    Ok(()) => field.error = None,
207                    Err(msg) => {
208                        field.error = Some(msg);
209                        all_valid = false;
210                    }
211                }
212            }
213        }
214        all_valid
215    }
216
217    /// Get field value by index.
218    pub fn value(&self, index: usize) -> &str {
219        self.fields
220            .get(index)
221            .map(|f| f.input.value.as_str())
222            .unwrap_or("")
223    }
224}
225
226impl Default for FormState {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232/// State for toast notification display.
233///
234/// Add messages with [`ToastState::info`], [`ToastState::success`],
235/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
236/// `Context::toast` each frame. Expired messages are removed automatically.
237pub struct ToastState {
238    /// Active toast messages, ordered oldest-first.
239    pub messages: Vec<ToastMessage>,
240}
241
242/// A single toast notification message.
243pub struct ToastMessage {
244    /// The text content of the notification.
245    pub text: String,
246    /// Severity level, used to choose the display color.
247    pub level: ToastLevel,
248    /// The tick at which this message was created.
249    pub created_tick: u64,
250    /// How many ticks the message remains visible.
251    pub duration_ticks: u64,
252}
253
254impl Default for ToastMessage {
255    fn default() -> Self {
256        Self {
257            text: String::new(),
258            level: ToastLevel::Info,
259            created_tick: 0,
260            duration_ticks: 30,
261        }
262    }
263}
264
265/// Severity level for a [`ToastMessage`].
266pub enum ToastLevel {
267    /// Informational message (primary color).
268    Info,
269    /// Success message (success color).
270    Success,
271    /// Warning message (warning color).
272    Warning,
273    /// Error message (error color).
274    Error,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum AlertLevel {
279    Info,
280    Success,
281    Warning,
282    Error,
283}
284
285impl ToastState {
286    /// Create an empty toast state with no messages.
287    pub fn new() -> Self {
288        Self {
289            messages: Vec::new(),
290        }
291    }
292
293    /// Push an informational toast visible for 30 ticks.
294    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
295        self.push(text, ToastLevel::Info, tick, 30);
296    }
297
298    /// Push a success toast visible for 30 ticks.
299    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
300        self.push(text, ToastLevel::Success, tick, 30);
301    }
302
303    /// Push a warning toast visible for 50 ticks.
304    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
305        self.push(text, ToastLevel::Warning, tick, 50);
306    }
307
308    /// Push an error toast visible for 80 ticks.
309    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
310        self.push(text, ToastLevel::Error, tick, 80);
311    }
312
313    /// Push a toast with a custom level and duration.
314    pub fn push(
315        &mut self,
316        text: impl Into<String>,
317        level: ToastLevel,
318        tick: u64,
319        duration_ticks: u64,
320    ) {
321        self.messages.push(ToastMessage {
322            text: text.into(),
323            level,
324            created_tick: tick,
325            duration_ticks,
326        });
327    }
328
329    /// Remove all messages whose display duration has elapsed.
330    ///
331    /// Called automatically by `Context::toast` before rendering.
332    pub fn cleanup(&mut self, current_tick: u64) {
333        self.messages.retain(|message| {
334            current_tick < message.created_tick.saturating_add(message.duration_ticks)
335        });
336    }
337}
338
339impl Default for ToastState {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345/// State for a multi-line text area widget.
346///
347/// Pass a mutable reference to `Context::textarea` each frame along with the
348/// number of visible rows. The widget handles all keyboard events when focused.
349pub struct TextareaState {
350    /// The lines of text, one entry per line.
351    pub lines: Vec<String>,
352    /// Row index of the cursor (0-based, logical line).
353    pub cursor_row: usize,
354    /// Column index of the cursor within the current row (character index).
355    pub cursor_col: usize,
356    /// Maximum total character count across all lines.
357    pub max_length: Option<usize>,
358    /// When set, lines longer than this display-column width are soft-wrapped.
359    pub wrap_width: Option<u32>,
360    /// First visible visual line (managed internally by `textarea()`).
361    pub scroll_offset: usize,
362}
363
364impl TextareaState {
365    /// Create an empty text area state with one blank line.
366    pub fn new() -> Self {
367        Self {
368            lines: vec![String::new()],
369            cursor_row: 0,
370            cursor_col: 0,
371            max_length: None,
372            wrap_width: None,
373            scroll_offset: 0,
374        }
375    }
376
377    /// Return all lines joined with newline characters.
378    pub fn value(&self) -> String {
379        self.lines.join("\n")
380    }
381
382    /// Replace the content with the given text, splitting on newlines.
383    ///
384    /// Resets the cursor to the beginning of the first line.
385    pub fn set_value(&mut self, text: impl Into<String>) {
386        let value = text.into();
387        self.lines = value.split('\n').map(str::to_string).collect();
388        if self.lines.is_empty() {
389            self.lines.push(String::new());
390        }
391        self.cursor_row = 0;
392        self.cursor_col = 0;
393        self.scroll_offset = 0;
394    }
395
396    /// Set the maximum allowed total character count.
397    pub fn max_length(mut self, len: usize) -> Self {
398        self.max_length = Some(len);
399        self
400    }
401
402    /// Enable soft word-wrap at the given display-column width.
403    pub fn word_wrap(mut self, width: u32) -> Self {
404        self.wrap_width = Some(width);
405        self
406    }
407}
408
409impl Default for TextareaState {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415/// State for an animated spinner widget.
416///
417/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
418/// `Context::spinner` each frame. The frame advances automatically with the
419/// tick counter.
420pub struct SpinnerState {
421    chars: Vec<char>,
422}
423
424impl SpinnerState {
425    /// Create a dots-style spinner using braille characters.
426    ///
427    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
428    pub fn dots() -> Self {
429        Self {
430            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
431        }
432    }
433
434    /// Create a line-style spinner using ASCII characters.
435    ///
436    /// Cycles through: `| / - \`
437    pub fn line() -> Self {
438        Self {
439            chars: vec!['|', '/', '-', '\\'],
440        }
441    }
442
443    /// Return the spinner character for the given tick.
444    pub fn frame(&self, tick: u64) -> char {
445        if self.chars.is_empty() {
446            return ' ';
447        }
448        self.chars[tick as usize % self.chars.len()]
449    }
450}
451
452impl Default for SpinnerState {
453    fn default() -> Self {
454        Self::dots()
455    }
456}
457
458/// State for a selectable list widget.
459///
460/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
461/// keys (and `k`/`j`) move the selection when the widget is focused.
462#[derive(Default)]
463pub struct ListState {
464    /// The list items as display strings.
465    pub items: Vec<String>,
466    /// Index of the currently selected item.
467    pub selected: usize,
468    /// Case-insensitive substring filter applied to list items.
469    pub filter: String,
470    view_indices: Vec<usize>,
471}
472
473impl ListState {
474    /// Create a list with the given items. The first item is selected initially.
475    pub fn new(items: Vec<impl Into<String>>) -> Self {
476        let len = items.len();
477        Self {
478            items: items.into_iter().map(Into::into).collect(),
479            selected: 0,
480            filter: String::new(),
481            view_indices: (0..len).collect(),
482        }
483    }
484
485    /// Replace the list items and rebuild the view index.
486    ///
487    /// Use this instead of assigning `items` directly to ensure the internal
488    /// filter/view state stays consistent.
489    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
490        self.items = items.into_iter().map(Into::into).collect();
491        self.selected = self.selected.min(self.items.len().saturating_sub(1));
492        self.rebuild_view();
493    }
494
495    /// Set the filter string. Multiple space-separated tokens are AND'd
496    /// together — all tokens must match across any cell in the same row.
497    /// Empty string disables filtering.
498    pub fn set_filter(&mut self, filter: impl Into<String>) {
499        self.filter = filter.into();
500        self.rebuild_view();
501    }
502
503    /// Returns indices of items visible after filtering.
504    pub fn visible_indices(&self) -> &[usize] {
505        &self.view_indices
506    }
507
508    /// Get the currently selected item text, or `None` if the list is empty.
509    pub fn selected_item(&self) -> Option<&str> {
510        let data_idx = *self.view_indices.get(self.selected)?;
511        self.items.get(data_idx).map(String::as_str)
512    }
513
514    fn rebuild_view(&mut self) {
515        let tokens: Vec<String> = self
516            .filter
517            .split_whitespace()
518            .map(|t| t.to_lowercase())
519            .collect();
520        self.view_indices = if tokens.is_empty() {
521            (0..self.items.len()).collect()
522        } else {
523            (0..self.items.len())
524                .filter(|&i| {
525                    tokens
526                        .iter()
527                        .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
528                })
529                .collect()
530        };
531        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
532            self.selected = self.view_indices.len() - 1;
533        }
534    }
535}
536
537#[derive(Debug, Clone)]
538pub struct FilePickerState {
539    pub current_dir: PathBuf,
540    pub entries: Vec<FileEntry>,
541    pub selected: usize,
542    pub selected_file: Option<PathBuf>,
543    pub show_hidden: bool,
544    pub extensions: Vec<String>,
545    pub dirty: bool,
546}
547
548#[derive(Debug, Clone, Default)]
549pub struct FileEntry {
550    pub name: String,
551    pub path: PathBuf,
552    pub is_dir: bool,
553    pub size: u64,
554}
555
556impl FilePickerState {
557    pub fn new(dir: impl Into<PathBuf>) -> Self {
558        Self {
559            current_dir: dir.into(),
560            entries: Vec::new(),
561            selected: 0,
562            selected_file: None,
563            show_hidden: false,
564            extensions: Vec::new(),
565            dirty: true,
566        }
567    }
568
569    pub fn show_hidden(mut self, show: bool) -> Self {
570        self.show_hidden = show;
571        self.dirty = true;
572        self
573    }
574
575    pub fn extensions(mut self, exts: &[&str]) -> Self {
576        self.extensions = exts
577            .iter()
578            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
579            .filter(|ext| !ext.is_empty())
580            .collect();
581        self.dirty = true;
582        self
583    }
584
585    pub fn selected(&self) -> Option<&PathBuf> {
586        self.selected_file.as_ref()
587    }
588
589    pub fn refresh(&mut self) {
590        let mut entries = Vec::new();
591
592        if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
593            for dir_entry in read_dir.flatten() {
594                let name = dir_entry.file_name().to_string_lossy().to_string();
595                if !self.show_hidden && name.starts_with('.') {
596                    continue;
597                }
598
599                let Ok(file_type) = dir_entry.file_type() else {
600                    continue;
601                };
602                if file_type.is_symlink() {
603                    continue;
604                }
605
606                let path = dir_entry.path();
607                let is_dir = file_type.is_dir();
608
609                if !is_dir && !self.extensions.is_empty() {
610                    let ext = path
611                        .extension()
612                        .and_then(|e| e.to_str())
613                        .map(|e| e.to_ascii_lowercase());
614                    let Some(ext) = ext else {
615                        continue;
616                    };
617                    if !self.extensions.iter().any(|allowed| allowed == &ext) {
618                        continue;
619                    }
620                }
621
622                let size = if is_dir {
623                    0
624                } else {
625                    fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
626                };
627
628                entries.push(FileEntry {
629                    name,
630                    path,
631                    is_dir,
632                    size,
633                });
634            }
635        }
636
637        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
638            (true, false) => std::cmp::Ordering::Less,
639            (false, true) => std::cmp::Ordering::Greater,
640            _ => a
641                .name
642                .to_ascii_lowercase()
643                .cmp(&b.name.to_ascii_lowercase())
644                .then_with(|| a.name.cmp(&b.name)),
645        });
646
647        self.entries = entries;
648        if self.entries.is_empty() {
649            self.selected = 0;
650        } else {
651            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
652        }
653        self.dirty = false;
654    }
655}
656
657impl Default for FilePickerState {
658    fn default() -> Self {
659        Self::new(".")
660    }
661}
662
663/// State for a tab navigation widget.
664///
665/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
666/// keys cycle through tabs when the widget is focused.
667#[derive(Default)]
668pub struct TabsState {
669    /// The tab labels displayed in the bar.
670    pub labels: Vec<String>,
671    /// Index of the currently active tab.
672    pub selected: usize,
673}
674
675impl TabsState {
676    /// Create tabs with the given labels. The first tab is active initially.
677    pub fn new(labels: Vec<impl Into<String>>) -> Self {
678        Self {
679            labels: labels.into_iter().map(Into::into).collect(),
680            selected: 0,
681        }
682    }
683
684    /// Get the currently selected tab label, or `None` if there are no tabs.
685    pub fn selected_label(&self) -> Option<&str> {
686        self.labels.get(self.selected).map(String::as_str)
687    }
688}
689
690/// State for a data table widget.
691///
692/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
693/// keys move the row selection when the widget is focused. Column widths are
694/// computed automatically from header and cell content.
695pub struct TableState {
696    /// Column header labels.
697    pub headers: Vec<String>,
698    /// Table rows, each a `Vec` of cell strings.
699    pub rows: Vec<Vec<String>>,
700    /// Index of the currently selected row.
701    pub selected: usize,
702    column_widths: Vec<u32>,
703    dirty: bool,
704    /// Sorted column index (`None` means no sorting).
705    pub sort_column: Option<usize>,
706    /// Sort direction (`true` for ascending).
707    pub sort_ascending: bool,
708    /// Case-insensitive substring filter applied across all cells.
709    pub filter: String,
710    /// Current page (0-based) when pagination is enabled.
711    pub page: usize,
712    /// Rows per page (`0` disables pagination).
713    pub page_size: usize,
714    view_indices: Vec<usize>,
715}
716
717impl Default for TableState {
718    fn default() -> Self {
719        Self {
720            headers: Vec::new(),
721            rows: Vec::new(),
722            selected: 0,
723            column_widths: Vec::new(),
724            dirty: true,
725            sort_column: None,
726            sort_ascending: true,
727            filter: String::new(),
728            page: 0,
729            page_size: 0,
730            view_indices: Vec::new(),
731        }
732    }
733}
734
735impl TableState {
736    /// Create a table with headers and rows. Column widths are computed immediately.
737    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
738        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
739        let rows: Vec<Vec<String>> = rows
740            .into_iter()
741            .map(|r| r.into_iter().map(Into::into).collect())
742            .collect();
743        let mut state = Self {
744            headers,
745            rows,
746            selected: 0,
747            column_widths: Vec::new(),
748            dirty: true,
749            sort_column: None,
750            sort_ascending: true,
751            filter: String::new(),
752            page: 0,
753            page_size: 0,
754            view_indices: Vec::new(),
755        };
756        state.rebuild_view();
757        state.recompute_widths();
758        state
759    }
760
761    /// Replace all rows, preserving the selection index if possible.
762    ///
763    /// If the current selection is beyond the new row count, it is clamped to
764    /// the last row.
765    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
766        self.rows = rows
767            .into_iter()
768            .map(|r| r.into_iter().map(Into::into).collect())
769            .collect();
770        self.rebuild_view();
771    }
772
773    /// Sort by a specific column index. If already sorted by this column, toggles direction.
774    pub fn toggle_sort(&mut self, column: usize) {
775        if self.sort_column == Some(column) {
776            self.sort_ascending = !self.sort_ascending;
777        } else {
778            self.sort_column = Some(column);
779            self.sort_ascending = true;
780        }
781        self.rebuild_view();
782    }
783
784    /// Sort by column without toggling (always sets to ascending first).
785    pub fn sort_by(&mut self, column: usize) {
786        self.sort_column = Some(column);
787        self.sort_ascending = true;
788        self.rebuild_view();
789    }
790
791    /// Set the filter string. Multiple space-separated tokens are AND'd
792    /// together — all tokens must match across any cell in the same row.
793    /// Empty string disables filtering.
794    pub fn set_filter(&mut self, filter: impl Into<String>) {
795        self.filter = filter.into();
796        self.page = 0;
797        self.rebuild_view();
798    }
799
800    /// Clear sorting.
801    pub fn clear_sort(&mut self) {
802        self.sort_column = None;
803        self.sort_ascending = true;
804        self.rebuild_view();
805    }
806
807    /// Move to the next page. Does nothing if already on the last page.
808    pub fn next_page(&mut self) {
809        if self.page_size == 0 {
810            return;
811        }
812        let last_page = self.total_pages().saturating_sub(1);
813        self.page = (self.page + 1).min(last_page);
814    }
815
816    /// Move to the previous page. Does nothing if already on page 0.
817    pub fn prev_page(&mut self) {
818        self.page = self.page.saturating_sub(1);
819    }
820
821    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
822    pub fn total_pages(&self) -> usize {
823        if self.page_size == 0 {
824            return 1;
825        }
826
827        let len = self.view_indices.len();
828        if len == 0 {
829            1
830        } else {
831            len.div_ceil(self.page_size)
832        }
833    }
834
835    /// Get the visible row indices after filtering and sorting (used internally by table()).
836    pub fn visible_indices(&self) -> &[usize] {
837        &self.view_indices
838    }
839
840    /// Get the currently selected row data, or `None` if the table is empty.
841    pub fn selected_row(&self) -> Option<&[String]> {
842        if self.view_indices.is_empty() {
843            return None;
844        }
845        let data_idx = self.view_indices.get(self.selected)?;
846        self.rows.get(*data_idx).map(|r| r.as_slice())
847    }
848
849    /// Recompute view_indices based on current sort + filter settings.
850    fn rebuild_view(&mut self) {
851        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
852
853        let tokens: Vec<String> = self
854            .filter
855            .split_whitespace()
856            .map(|t| t.to_lowercase())
857            .collect();
858        if !tokens.is_empty() {
859            indices.retain(|&idx| {
860                let row = match self.rows.get(idx) {
861                    Some(r) => r,
862                    None => return false,
863                };
864                tokens.iter().all(|token| {
865                    row.iter()
866                        .any(|cell| cell.to_lowercase().contains(token.as_str()))
867                })
868            });
869        }
870
871        if let Some(column) = self.sort_column {
872            indices.sort_by(|a, b| {
873                let left = self
874                    .rows
875                    .get(*a)
876                    .and_then(|row| row.get(column))
877                    .map(String::as_str)
878                    .unwrap_or("");
879                let right = self
880                    .rows
881                    .get(*b)
882                    .and_then(|row| row.get(column))
883                    .map(String::as_str)
884                    .unwrap_or("");
885
886                match (left.parse::<f64>(), right.parse::<f64>()) {
887                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
888                    _ => left.to_lowercase().cmp(&right.to_lowercase()),
889                }
890            });
891
892            if !self.sort_ascending {
893                indices.reverse();
894            }
895        }
896
897        self.view_indices = indices;
898
899        if self.page_size > 0 {
900            self.page = self.page.min(self.total_pages().saturating_sub(1));
901        } else {
902            self.page = 0;
903        }
904
905        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
906        self.dirty = true;
907    }
908
909    pub(crate) fn recompute_widths(&mut self) {
910        let col_count = self.headers.len();
911        self.column_widths = vec![0u32; col_count];
912        for (i, header) in self.headers.iter().enumerate() {
913            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
914            if self.sort_column == Some(i) {
915                width += 2;
916            }
917            self.column_widths[i] = width;
918        }
919        for row in &self.rows {
920            for (i, cell) in row.iter().enumerate() {
921                if i < col_count {
922                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
923                    self.column_widths[i] = self.column_widths[i].max(w);
924                }
925            }
926        }
927        self.dirty = false;
928    }
929
930    pub(crate) fn column_widths(&self) -> &[u32] {
931        &self.column_widths
932    }
933
934    pub(crate) fn is_dirty(&self) -> bool {
935        self.dirty
936    }
937}
938
939/// State for a scrollable container.
940///
941/// Pass a mutable reference to `Context::scrollable` each frame. The context
942/// updates `offset` and the internal bounds automatically based on mouse wheel
943/// and drag events.
944pub struct ScrollState {
945    /// Current vertical scroll offset in rows.
946    pub offset: usize,
947    content_height: u32,
948    viewport_height: u32,
949}
950
951impl ScrollState {
952    /// Create scroll state starting at offset 0.
953    pub fn new() -> Self {
954        Self {
955            offset: 0,
956            content_height: 0,
957            viewport_height: 0,
958        }
959    }
960
961    /// Check if scrolling upward is possible (offset is greater than 0).
962    pub fn can_scroll_up(&self) -> bool {
963        self.offset > 0
964    }
965
966    /// Check if scrolling downward is possible (content extends below the viewport).
967    pub fn can_scroll_down(&self) -> bool {
968        (self.offset as u32) + self.viewport_height < self.content_height
969    }
970
971    /// Get the total content height in rows.
972    pub fn content_height(&self) -> u32 {
973        self.content_height
974    }
975
976    /// Get the viewport height in rows.
977    pub fn viewport_height(&self) -> u32 {
978        self.viewport_height
979    }
980
981    /// Get the scroll progress as a ratio in [0.0, 1.0].
982    pub fn progress(&self) -> f32 {
983        let max = self.content_height.saturating_sub(self.viewport_height);
984        if max == 0 {
985            0.0
986        } else {
987            self.offset as f32 / max as f32
988        }
989    }
990
991    /// Scroll up by the given number of rows, clamped to 0.
992    pub fn scroll_up(&mut self, amount: usize) {
993        self.offset = self.offset.saturating_sub(amount);
994    }
995
996    /// Scroll down by the given number of rows, clamped to the maximum offset.
997    pub fn scroll_down(&mut self, amount: usize) {
998        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
999        self.offset = (self.offset + amount).min(max_offset);
1000    }
1001
1002    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
1003        self.content_height = content_height;
1004        self.viewport_height = viewport_height;
1005    }
1006}
1007
1008impl Default for ScrollState {
1009    fn default() -> Self {
1010        Self::new()
1011    }
1012}
1013
1014/// Visual variant for buttons.
1015///
1016/// Controls the color scheme used when rendering a button. Pass to
1017/// [`crate::Context::button_with`] to create styled button variants.
1018///
1019/// - `Default` — theme text color, primary when focused (same as `button()`)
1020/// - `Primary` — primary color background with contrasting text
1021/// - `Danger` — error/red color for destructive actions
1022/// - `Outline` — bordered appearance without fill
1023#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1024pub enum ButtonVariant {
1025    /// Standard button style.
1026    #[default]
1027    Default,
1028    /// Filled button with primary background color.
1029    Primary,
1030    /// Filled button with error/danger background color.
1031    Danger,
1032    /// Bordered button without background fill.
1033    Outline,
1034}
1035
1036#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1037pub enum Trend {
1038    Up,
1039    Down,
1040}
1041
1042// ── Select / Dropdown ─────────────────────────────────────────────────
1043
1044/// State for a dropdown select widget.
1045///
1046/// Renders as a single-line button showing the selected option. When activated,
1047/// expands into a vertical list overlay for picking an option.
1048#[derive(Default)]
1049pub struct SelectState {
1050    pub items: Vec<String>,
1051    pub selected: usize,
1052    pub open: bool,
1053    pub placeholder: String,
1054    cursor: usize,
1055}
1056
1057impl SelectState {
1058    pub fn new(items: Vec<impl Into<String>>) -> Self {
1059        Self {
1060            items: items.into_iter().map(Into::into).collect(),
1061            selected: 0,
1062            open: false,
1063            placeholder: String::new(),
1064            cursor: 0,
1065        }
1066    }
1067
1068    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
1069        self.placeholder = p.into();
1070        self
1071    }
1072
1073    pub fn selected_item(&self) -> Option<&str> {
1074        self.items.get(self.selected).map(String::as_str)
1075    }
1076
1077    pub(crate) fn cursor(&self) -> usize {
1078        self.cursor
1079    }
1080
1081    pub(crate) fn set_cursor(&mut self, c: usize) {
1082        self.cursor = c;
1083    }
1084}
1085
1086// ── Radio ─────────────────────────────────────────────────────────────
1087
1088/// State for a radio button group.
1089///
1090/// Renders a vertical list of mutually-exclusive options with `●`/`○` markers.
1091#[derive(Default)]
1092pub struct RadioState {
1093    pub items: Vec<String>,
1094    pub selected: usize,
1095}
1096
1097impl RadioState {
1098    pub fn new(items: Vec<impl Into<String>>) -> Self {
1099        Self {
1100            items: items.into_iter().map(Into::into).collect(),
1101            selected: 0,
1102        }
1103    }
1104
1105    pub fn selected_item(&self) -> Option<&str> {
1106        self.items.get(self.selected).map(String::as_str)
1107    }
1108}
1109
1110// ── Multi-Select ──────────────────────────────────────────────────────
1111
1112/// State for a multi-select list.
1113///
1114/// Like [`ListState`] but allows toggling multiple items with Space.
1115pub struct MultiSelectState {
1116    pub items: Vec<String>,
1117    pub cursor: usize,
1118    pub selected: HashSet<usize>,
1119}
1120
1121impl MultiSelectState {
1122    pub fn new(items: Vec<impl Into<String>>) -> Self {
1123        Self {
1124            items: items.into_iter().map(Into::into).collect(),
1125            cursor: 0,
1126            selected: HashSet::new(),
1127        }
1128    }
1129
1130    pub fn selected_items(&self) -> Vec<&str> {
1131        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1132        indices.sort();
1133        indices
1134            .iter()
1135            .filter_map(|&i| self.items.get(i).map(String::as_str))
1136            .collect()
1137    }
1138
1139    pub fn toggle(&mut self, index: usize) {
1140        if self.selected.contains(&index) {
1141            self.selected.remove(&index);
1142        } else {
1143            self.selected.insert(index);
1144        }
1145    }
1146}
1147
1148// ── Tree ──────────────────────────────────────────────────────────────
1149
1150/// A node in a tree view.
1151pub struct TreeNode {
1152    pub label: String,
1153    pub children: Vec<TreeNode>,
1154    pub expanded: bool,
1155}
1156
1157impl TreeNode {
1158    pub fn new(label: impl Into<String>) -> Self {
1159        Self {
1160            label: label.into(),
1161            children: Vec::new(),
1162            expanded: false,
1163        }
1164    }
1165
1166    pub fn expanded(mut self) -> Self {
1167        self.expanded = true;
1168        self
1169    }
1170
1171    pub fn children(mut self, children: Vec<TreeNode>) -> Self {
1172        self.children = children;
1173        self
1174    }
1175
1176    pub fn is_leaf(&self) -> bool {
1177        self.children.is_empty()
1178    }
1179
1180    fn flatten(&self, depth: usize, out: &mut Vec<FlatTreeEntry>) {
1181        out.push(FlatTreeEntry {
1182            depth,
1183            label: self.label.clone(),
1184            is_leaf: self.is_leaf(),
1185            expanded: self.expanded,
1186        });
1187        if self.expanded {
1188            for child in &self.children {
1189                child.flatten(depth + 1, out);
1190            }
1191        }
1192    }
1193}
1194
1195pub(crate) struct FlatTreeEntry {
1196    pub depth: usize,
1197    pub label: String,
1198    pub is_leaf: bool,
1199    pub expanded: bool,
1200}
1201
1202/// State for a hierarchical tree view widget.
1203pub struct TreeState {
1204    pub nodes: Vec<TreeNode>,
1205    pub selected: usize,
1206}
1207
1208impl TreeState {
1209    pub fn new(nodes: Vec<TreeNode>) -> Self {
1210        Self { nodes, selected: 0 }
1211    }
1212
1213    pub(crate) fn flatten(&self) -> Vec<FlatTreeEntry> {
1214        let mut entries = Vec::new();
1215        for node in &self.nodes {
1216            node.flatten(0, &mut entries);
1217        }
1218        entries
1219    }
1220
1221    pub(crate) fn toggle_at(&mut self, flat_index: usize) {
1222        let mut counter = 0usize;
1223        Self::toggle_recursive(&mut self.nodes, flat_index, &mut counter);
1224    }
1225
1226    fn toggle_recursive(nodes: &mut [TreeNode], target: usize, counter: &mut usize) -> bool {
1227        for node in nodes.iter_mut() {
1228            if *counter == target {
1229                if !node.is_leaf() {
1230                    node.expanded = !node.expanded;
1231                }
1232                return true;
1233            }
1234            *counter += 1;
1235            if node.expanded && Self::toggle_recursive(&mut node.children, target, counter) {
1236                return true;
1237            }
1238        }
1239        false
1240    }
1241}
1242
1243// ── Command Palette ───────────────────────────────────────────────────
1244
1245/// A single command entry in the palette.
1246pub struct PaletteCommand {
1247    pub label: String,
1248    pub description: String,
1249    pub shortcut: Option<String>,
1250}
1251
1252impl PaletteCommand {
1253    pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
1254        Self {
1255            label: label.into(),
1256            description: description.into(),
1257            shortcut: None,
1258        }
1259    }
1260
1261    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
1262        self.shortcut = Some(s.into());
1263        self
1264    }
1265}
1266
1267/// State for a command palette overlay.
1268///
1269/// Renders as a modal with a search input and filtered command list.
1270pub struct CommandPaletteState {
1271    pub commands: Vec<PaletteCommand>,
1272    pub input: String,
1273    pub cursor: usize,
1274    pub open: bool,
1275    selected: usize,
1276}
1277
1278impl CommandPaletteState {
1279    pub fn new(commands: Vec<PaletteCommand>) -> Self {
1280        Self {
1281            commands,
1282            input: String::new(),
1283            cursor: 0,
1284            open: false,
1285            selected: 0,
1286        }
1287    }
1288
1289    pub fn toggle(&mut self) {
1290        self.open = !self.open;
1291        if self.open {
1292            self.input.clear();
1293            self.cursor = 0;
1294            self.selected = 0;
1295        }
1296    }
1297
1298    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
1299        let tokens: Vec<String> = self
1300            .input
1301            .split_whitespace()
1302            .map(|t| t.to_lowercase())
1303            .collect();
1304        if tokens.is_empty() {
1305            return (0..self.commands.len()).collect();
1306        }
1307        self.commands
1308            .iter()
1309            .enumerate()
1310            .filter(|(_, cmd)| {
1311                let label = cmd.label.to_lowercase();
1312                let desc = cmd.description.to_lowercase();
1313                tokens
1314                    .iter()
1315                    .all(|token| label.contains(token.as_str()) || desc.contains(token.as_str()))
1316            })
1317            .map(|(i, _)| i)
1318            .collect()
1319    }
1320
1321    pub(crate) fn selected(&self) -> usize {
1322        self.selected
1323    }
1324
1325    pub(crate) fn set_selected(&mut self, s: usize) {
1326        self.selected = s;
1327    }
1328}
1329
1330/// State for a streaming text display.
1331///
1332/// Accumulates text chunks as they arrive from an LLM stream.
1333/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
1334pub struct StreamingTextState {
1335    /// The accumulated text content.
1336    pub content: String,
1337    /// Whether the stream is still receiving data.
1338    pub streaming: bool,
1339    /// Cursor blink state (for the typing indicator).
1340    pub(crate) cursor_visible: bool,
1341    pub(crate) cursor_tick: u64,
1342}
1343
1344impl StreamingTextState {
1345    /// Create a new empty streaming text state.
1346    pub fn new() -> Self {
1347        Self {
1348            content: String::new(),
1349            streaming: false,
1350            cursor_visible: true,
1351            cursor_tick: 0,
1352        }
1353    }
1354
1355    /// Append a chunk of text (e.g., from an LLM stream delta).
1356    pub fn push(&mut self, chunk: &str) {
1357        self.content.push_str(chunk);
1358    }
1359
1360    /// Mark the stream as complete (hides the typing cursor).
1361    pub fn finish(&mut self) {
1362        self.streaming = false;
1363    }
1364
1365    /// Start a new streaming session, clearing previous content.
1366    pub fn start(&mut self) {
1367        self.content.clear();
1368        self.streaming = true;
1369        self.cursor_visible = true;
1370        self.cursor_tick = 0;
1371    }
1372
1373    /// Clear all content and reset state.
1374    pub fn clear(&mut self) {
1375        self.content.clear();
1376        self.streaming = false;
1377        self.cursor_visible = true;
1378        self.cursor_tick = 0;
1379    }
1380}
1381
1382impl Default for StreamingTextState {
1383    fn default() -> Self {
1384        Self::new()
1385    }
1386}
1387
1388/// State for a streaming markdown display.
1389///
1390/// Accumulates markdown chunks as they arrive from an LLM stream.
1391/// Pass to [`Context::streaming_markdown`](crate::Context::streaming_markdown) each frame.
1392pub struct StreamingMarkdownState {
1393    /// The accumulated markdown content.
1394    pub content: String,
1395    /// Whether the stream is still receiving data.
1396    pub streaming: bool,
1397    /// Cursor blink state (for the typing indicator).
1398    pub cursor_visible: bool,
1399    pub cursor_tick: u64,
1400    pub in_code_block: bool,
1401    pub code_block_lang: String,
1402}
1403
1404impl StreamingMarkdownState {
1405    /// Create a new empty streaming markdown state.
1406    pub fn new() -> Self {
1407        Self {
1408            content: String::new(),
1409            streaming: false,
1410            cursor_visible: true,
1411            cursor_tick: 0,
1412            in_code_block: false,
1413            code_block_lang: String::new(),
1414        }
1415    }
1416
1417    /// Append a markdown chunk (e.g., from an LLM stream delta).
1418    pub fn push(&mut self, chunk: &str) {
1419        self.content.push_str(chunk);
1420    }
1421
1422    /// Start a new streaming session, clearing previous content.
1423    pub fn start(&mut self) {
1424        self.content.clear();
1425        self.streaming = true;
1426        self.cursor_visible = true;
1427        self.cursor_tick = 0;
1428        self.in_code_block = false;
1429        self.code_block_lang.clear();
1430    }
1431
1432    /// Mark the stream as complete (hides the typing cursor).
1433    pub fn finish(&mut self) {
1434        self.streaming = false;
1435    }
1436
1437    /// Clear all content and reset state.
1438    pub fn clear(&mut self) {
1439        self.content.clear();
1440        self.streaming = false;
1441        self.cursor_visible = true;
1442        self.cursor_tick = 0;
1443        self.in_code_block = false;
1444        self.code_block_lang.clear();
1445    }
1446}
1447
1448impl Default for StreamingMarkdownState {
1449    fn default() -> Self {
1450        Self::new()
1451    }
1452}
1453
1454/// Approval state for a tool call.
1455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1456pub enum ApprovalAction {
1457    /// No action taken yet.
1458    Pending,
1459    /// User approved the tool call.
1460    Approved,
1461    /// User rejected the tool call.
1462    Rejected,
1463}
1464
1465/// State for a tool approval widget.
1466///
1467/// Displays a tool call with approve/reject buttons for human-in-the-loop
1468/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
1469/// each frame.
1470pub struct ToolApprovalState {
1471    /// The name of the tool being invoked.
1472    pub tool_name: String,
1473    /// A human-readable description of what the tool will do.
1474    pub description: String,
1475    /// The current approval status.
1476    pub action: ApprovalAction,
1477}
1478
1479impl ToolApprovalState {
1480    /// Create a new tool approval prompt.
1481    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
1482        Self {
1483            tool_name: tool_name.into(),
1484            description: description.into(),
1485            action: ApprovalAction::Pending,
1486        }
1487    }
1488
1489    /// Reset to pending state.
1490    pub fn reset(&mut self) {
1491        self.action = ApprovalAction::Pending;
1492    }
1493}
1494
1495/// Item in a context bar showing active context sources.
1496#[derive(Debug, Clone)]
1497pub struct ContextItem {
1498    /// Display label for this context source.
1499    pub label: String,
1500    /// Token count or size indicator.
1501    pub tokens: usize,
1502}
1503
1504impl ContextItem {
1505    /// Create a new context item with a label and token count.
1506    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
1507        Self {
1508            label: label.into(),
1509            tokens,
1510        }
1511    }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517
1518    #[test]
1519    fn form_field_default_values() {
1520        let field = FormField::default();
1521        assert_eq!(field.label, "");
1522        assert_eq!(field.input.value, "");
1523        assert_eq!(field.input.cursor, 0);
1524        assert_eq!(field.error, None);
1525    }
1526
1527    #[test]
1528    fn toast_message_default_values() {
1529        let msg = ToastMessage::default();
1530        assert_eq!(msg.text, "");
1531        assert!(matches!(msg.level, ToastLevel::Info));
1532        assert_eq!(msg.created_tick, 0);
1533        assert_eq!(msg.duration_ticks, 30);
1534    }
1535
1536    #[test]
1537    fn list_state_default_values() {
1538        let state = ListState::default();
1539        assert!(state.items.is_empty());
1540        assert_eq!(state.selected, 0);
1541        assert_eq!(state.filter, "");
1542        assert_eq!(state.visible_indices(), &[]);
1543        assert_eq!(state.selected_item(), None);
1544    }
1545
1546    #[test]
1547    fn file_entry_default_values() {
1548        let entry = FileEntry::default();
1549        assert_eq!(entry.name, "");
1550        assert_eq!(entry.path, PathBuf::new());
1551        assert!(!entry.is_dir);
1552        assert_eq!(entry.size, 0);
1553    }
1554
1555    #[test]
1556    fn tabs_state_default_values() {
1557        let state = TabsState::default();
1558        assert!(state.labels.is_empty());
1559        assert_eq!(state.selected, 0);
1560        assert_eq!(state.selected_label(), None);
1561    }
1562
1563    #[test]
1564    fn table_state_default_values() {
1565        let state = TableState::default();
1566        assert!(state.headers.is_empty());
1567        assert!(state.rows.is_empty());
1568        assert_eq!(state.selected, 0);
1569        assert_eq!(state.sort_column, None);
1570        assert!(state.sort_ascending);
1571        assert_eq!(state.filter, "");
1572        assert_eq!(state.page, 0);
1573        assert_eq!(state.page_size, 0);
1574        assert_eq!(state.visible_indices(), &[]);
1575    }
1576
1577    #[test]
1578    fn select_state_default_values() {
1579        let state = SelectState::default();
1580        assert!(state.items.is_empty());
1581        assert_eq!(state.selected, 0);
1582        assert!(!state.open);
1583        assert_eq!(state.placeholder, "");
1584        assert_eq!(state.selected_item(), None);
1585        assert_eq!(state.cursor(), 0);
1586    }
1587
1588    #[test]
1589    fn radio_state_default_values() {
1590        let state = RadioState::default();
1591        assert!(state.items.is_empty());
1592        assert_eq!(state.selected, 0);
1593        assert_eq!(state.selected_item(), None);
1594    }
1595
1596    #[test]
1597    fn text_input_state_default_uses_new() {
1598        let state = TextInputState::default();
1599        assert_eq!(state.value, "");
1600        assert_eq!(state.cursor, 0);
1601        assert_eq!(state.placeholder, "");
1602        assert_eq!(state.max_length, None);
1603        assert_eq!(state.validation_error, None);
1604        assert!(!state.masked);
1605    }
1606
1607    #[test]
1608    fn tabs_state_new_sets_labels() {
1609        let state = TabsState::new(vec!["a", "b"]);
1610        assert_eq!(state.labels, vec!["a".to_string(), "b".to_string()]);
1611        assert_eq!(state.selected, 0);
1612        assert_eq!(state.selected_label(), Some("a"));
1613    }
1614
1615    #[test]
1616    fn list_state_new_selected_item_points_to_first_item() {
1617        let state = ListState::new(vec!["alpha", "beta"]);
1618        assert_eq!(state.items, vec!["alpha".to_string(), "beta".to_string()]);
1619        assert_eq!(state.selected, 0);
1620        assert_eq!(state.visible_indices(), &[0, 1]);
1621        assert_eq!(state.selected_item(), Some("alpha"));
1622    }
1623
1624    #[test]
1625    fn select_state_placeholder_builder_sets_value() {
1626        let state = SelectState::new(vec!["one", "two"]).placeholder("Pick one");
1627        assert_eq!(state.items, vec!["one".to_string(), "two".to_string()]);
1628        assert_eq!(state.placeholder, "Pick one");
1629        assert_eq!(state.selected_item(), Some("one"));
1630    }
1631
1632    #[test]
1633    fn radio_state_new_sets_items_and_selection() {
1634        let state = RadioState::new(vec!["red", "green"]);
1635        assert_eq!(state.items, vec!["red".to_string(), "green".to_string()]);
1636        assert_eq!(state.selected, 0);
1637        assert_eq!(state.selected_item(), Some("red"));
1638    }
1639
1640    #[test]
1641    fn table_state_new_sets_sort_ascending_true() {
1642        let state = TableState::new(vec!["Name"], vec![vec!["Alice"], vec!["Bob"]]);
1643        assert_eq!(state.headers, vec!["Name".to_string()]);
1644        assert_eq!(state.rows.len(), 2);
1645        assert!(state.sort_ascending);
1646        assert_eq!(state.sort_column, None);
1647        assert_eq!(state.visible_indices(), &[0, 1]);
1648    }
1649}