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