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 std::time::{SystemTime, UNIX_EPOCH};
11use unicode_width::UnicodeWidthStr;
12
13use crate::Style;
14
15type FormValidator = fn(&str) -> Result<(), String>;
16type TextInputValidator = Box<dyn Fn(&str) -> Result<(), String>>;
17
18/// Accumulated static output lines for [`crate::run_static`].
19///
20/// Use [`println`](Self::println) to append lines above the dynamic inline TUI.
21#[derive(Debug, Clone, Default)]
22pub struct StaticOutput {
23    lines: Vec<String>,
24    new_lines: Vec<String>,
25}
26
27impl StaticOutput {
28    /// Create an empty static output buffer.
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Append one line of static output.
34    pub fn println(&mut self, line: impl Into<String>) {
35        let line = line.into();
36        self.lines.push(line.clone());
37        self.new_lines.push(line);
38    }
39
40    /// Return all accumulated static lines.
41    pub fn lines(&self) -> &[String] {
42        &self.lines
43    }
44
45    /// Drain and return only lines added since the previous drain.
46    pub fn drain_new(&mut self) -> Vec<String> {
47        std::mem::take(&mut self.new_lines)
48    }
49
50    /// Clear all accumulated lines.
51    pub fn clear(&mut self) {
52        self.lines.clear();
53        self.new_lines.clear();
54    }
55}
56
57/// State for a single-line text input widget.
58///
59/// Pass a mutable reference to `Context::text_input` each frame. The widget
60/// handles all keyboard events when focused.
61///
62/// # Example
63///
64/// ```no_run
65/// # use slt::widgets::TextInputState;
66/// # slt::run(|ui: &mut slt::Context| {
67/// let mut input = TextInputState::with_placeholder("Type here...");
68/// ui.text_input(&mut input);
69/// println!("{}", input.value);
70/// # });
71/// ```
72pub struct TextInputState {
73    /// The current input text.
74    pub value: String,
75    /// Cursor position as a character index into `value`.
76    pub cursor: usize,
77    /// Placeholder text shown when `value` is empty.
78    pub placeholder: String,
79    /// Maximum character count. Input is rejected beyond this limit.
80    pub max_length: Option<usize>,
81    /// The most recent validation error message, if any.
82    pub validation_error: Option<String>,
83    /// When `true`, input is displayed as `•` characters (for passwords).
84    pub masked: bool,
85    /// Autocomplete candidates shown below the input.
86    pub suggestions: Vec<String>,
87    /// Highlighted index within the currently shown suggestions.
88    pub suggestion_index: usize,
89    /// Whether the suggestions popup should be rendered.
90    pub show_suggestions: bool,
91    /// Multiple validators that produce their own error messages.
92    validators: Vec<TextInputValidator>,
93    /// All current validation errors from all validators.
94    validation_errors: Vec<String>,
95}
96
97impl std::fmt::Debug for TextInputState {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("TextInputState")
100            .field("value", &self.value)
101            .field("cursor", &self.cursor)
102            .field("placeholder", &self.placeholder)
103            .field("max_length", &self.max_length)
104            .field("validation_error", &self.validation_error)
105            .field("masked", &self.masked)
106            .field("suggestions", &self.suggestions)
107            .field("suggestion_index", &self.suggestion_index)
108            .field("show_suggestions", &self.show_suggestions)
109            .field("validators_len", &self.validators.len())
110            .field("validation_errors", &self.validation_errors)
111            .finish()
112    }
113}
114
115impl Clone for TextInputState {
116    fn clone(&self) -> Self {
117        Self {
118            value: self.value.clone(),
119            cursor: self.cursor,
120            placeholder: self.placeholder.clone(),
121            max_length: self.max_length,
122            validation_error: self.validation_error.clone(),
123            masked: self.masked,
124            suggestions: self.suggestions.clone(),
125            suggestion_index: self.suggestion_index,
126            show_suggestions: self.show_suggestions,
127            validators: Vec::new(),
128            validation_errors: self.validation_errors.clone(),
129        }
130    }
131}
132
133impl TextInputState {
134    /// Create an empty text input state.
135    pub fn new() -> Self {
136        Self {
137            value: String::new(),
138            cursor: 0,
139            placeholder: String::new(),
140            max_length: None,
141            validation_error: None,
142            masked: false,
143            suggestions: Vec::new(),
144            suggestion_index: 0,
145            show_suggestions: false,
146            validators: Vec::new(),
147            validation_errors: Vec::new(),
148        }
149    }
150
151    /// Create a text input with placeholder text shown when the value is empty.
152    pub fn with_placeholder(p: impl Into<String>) -> Self {
153        Self {
154            placeholder: p.into(),
155            ..Self::new()
156        }
157    }
158
159    /// Set the maximum allowed character count.
160    pub fn max_length(mut self, len: usize) -> Self {
161        self.max_length = Some(len);
162        self
163    }
164
165    /// Validate the current value and store the latest error message.
166    ///
167    /// Sets [`TextInputState::validation_error`] to `None` when validation
168    /// succeeds, or to `Some(error)` when validation fails.
169    ///
170    /// This is a backward-compatible shorthand that runs a single validator.
171    /// For multiple validators, use [`add_validator`](Self::add_validator) and [`run_validators`](Self::run_validators).
172    pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
173        self.validation_error = validator(&self.value).err();
174    }
175
176    /// Add a validator function that produces its own error message.
177    ///
178    /// Multiple validators can be added. Call [`run_validators`](Self::run_validators)
179    /// to execute all validators and collect their errors.
180    pub fn add_validator(&mut self, f: impl Fn(&str) -> Result<(), String> + 'static) {
181        self.validators.push(Box::new(f));
182    }
183
184    /// Run all registered validators and collect their error messages.
185    ///
186    /// Updates `validation_errors` with all errors from all validators.
187    /// Also updates `validation_error` to the first error for backward compatibility.
188    pub fn run_validators(&mut self) {
189        self.validation_errors.clear();
190        for validator in &self.validators {
191            if let Err(err) = validator(&self.value) {
192                self.validation_errors.push(err);
193            }
194        }
195        self.validation_error = self.validation_errors.first().cloned();
196    }
197
198    /// Get all current validation errors from all validators.
199    pub fn errors(&self) -> &[String] {
200        &self.validation_errors
201    }
202
203    /// Set autocomplete suggestions and reset popup state.
204    pub fn set_suggestions(&mut self, suggestions: Vec<String>) {
205        self.suggestions = suggestions;
206        self.suggestion_index = 0;
207        self.show_suggestions = !self.suggestions.is_empty();
208    }
209
210    /// Return suggestions that start with the current input (case-insensitive).
211    pub fn matched_suggestions(&self) -> Vec<&str> {
212        if self.value.is_empty() {
213            return Vec::new();
214        }
215        let lower = self.value.to_lowercase();
216        self.suggestions
217            .iter()
218            .filter(|s| s.to_lowercase().starts_with(&lower))
219            .map(|s| s.as_str())
220            .collect()
221    }
222}
223
224impl Default for TextInputState {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230/// A single form field with label and validation.
231#[derive(Debug, Default)]
232pub struct FormField {
233    /// Field label shown above the input.
234    pub label: String,
235    /// Text input state for this field.
236    pub input: TextInputState,
237    /// Validation error shown below the input when present.
238    pub error: Option<String>,
239}
240
241impl FormField {
242    /// Create a new form field with the given label.
243    pub fn new(label: impl Into<String>) -> Self {
244        Self {
245            label: label.into(),
246            input: TextInputState::new(),
247            error: None,
248        }
249    }
250
251    /// Set placeholder text for this field's input.
252    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
253        self.input.placeholder = p.into();
254        self
255    }
256}
257
258/// State for a form with multiple fields.
259#[derive(Debug)]
260pub struct FormState {
261    /// Ordered list of form fields.
262    pub fields: Vec<FormField>,
263    /// Whether the form has been successfully submitted.
264    pub submitted: bool,
265}
266
267impl FormState {
268    /// Create an empty form state.
269    pub fn new() -> Self {
270        Self {
271            fields: Vec::new(),
272            submitted: false,
273        }
274    }
275
276    /// Add a field and return the updated form for chaining.
277    pub fn field(mut self, field: FormField) -> Self {
278        self.fields.push(field);
279        self
280    }
281
282    /// Validate all fields with the given validators.
283    ///
284    /// Returns `true` when all validations pass.
285    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
286        let mut all_valid = true;
287        for (i, field) in self.fields.iter_mut().enumerate() {
288            if let Some(validator) = validators.get(i) {
289                match validator(&field.input.value) {
290                    Ok(()) => field.error = None,
291                    Err(msg) => {
292                        field.error = Some(msg);
293                        all_valid = false;
294                    }
295                }
296            }
297        }
298        all_valid
299    }
300
301    /// Get field value by index.
302    pub fn value(&self, index: usize) -> &str {
303        self.fields
304            .get(index)
305            .map(|f| f.input.value.as_str())
306            .unwrap_or("")
307    }
308}
309
310impl Default for FormState {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316/// State for toast notification display.
317///
318/// Add messages with [`ToastState::info`], [`ToastState::success`],
319/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
320/// `Context::toast` each frame. Expired messages are removed automatically.
321#[derive(Debug, Clone)]
322pub struct ToastState {
323    /// Active toast messages, ordered oldest-first.
324    pub messages: Vec<ToastMessage>,
325}
326
327/// A single toast notification message.
328#[derive(Debug, Clone)]
329pub struct ToastMessage {
330    /// The text content of the notification.
331    pub text: String,
332    /// Severity level, used to choose the display color.
333    pub level: ToastLevel,
334    /// The tick at which this message was created.
335    pub created_tick: u64,
336    /// How many ticks the message remains visible.
337    pub duration_ticks: u64,
338}
339
340impl Default for ToastMessage {
341    fn default() -> Self {
342        Self {
343            text: String::new(),
344            level: ToastLevel::Info,
345            created_tick: 0,
346            duration_ticks: 30,
347        }
348    }
349}
350
351/// Severity level for a [`ToastMessage`].
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum ToastLevel {
354    /// Informational message (primary color).
355    Info,
356    /// Success message (success color).
357    Success,
358    /// Warning message (warning color).
359    Warning,
360    /// Error message (error color).
361    Error,
362}
363
364/// Severity level for alert widgets.
365#[non_exhaustive]
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum AlertLevel {
368    /// Informational alert.
369    Info,
370    /// Success alert.
371    Success,
372    /// Warning alert.
373    Warning,
374    /// Error alert.
375    Error,
376}
377
378impl ToastState {
379    /// Create an empty toast state with no messages.
380    pub fn new() -> Self {
381        Self {
382            messages: Vec::new(),
383        }
384    }
385
386    /// Push an informational toast visible for 30 ticks.
387    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
388        self.push(text, ToastLevel::Info, tick, 30);
389    }
390
391    /// Push a success toast visible for 30 ticks.
392    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
393        self.push(text, ToastLevel::Success, tick, 30);
394    }
395
396    /// Push a warning toast visible for 50 ticks.
397    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
398        self.push(text, ToastLevel::Warning, tick, 50);
399    }
400
401    /// Push an error toast visible for 80 ticks.
402    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
403        self.push(text, ToastLevel::Error, tick, 80);
404    }
405
406    /// Push a toast with a custom level and duration.
407    pub fn push(
408        &mut self,
409        text: impl Into<String>,
410        level: ToastLevel,
411        tick: u64,
412        duration_ticks: u64,
413    ) {
414        self.messages.push(ToastMessage {
415            text: text.into(),
416            level,
417            created_tick: tick,
418            duration_ticks,
419        });
420    }
421
422    /// Remove all messages whose display duration has elapsed.
423    ///
424    /// Called automatically by `Context::toast` before rendering.
425    pub fn cleanup(&mut self, current_tick: u64) {
426        self.messages.retain(|message| {
427            current_tick < message.created_tick.saturating_add(message.duration_ticks)
428        });
429    }
430}
431
432impl Default for ToastState {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438/// State for a multi-line text area widget.
439///
440/// Pass a mutable reference to `Context::textarea` each frame along with the
441/// number of visible rows. The widget handles all keyboard events when focused.
442#[derive(Debug, Clone)]
443pub struct TextareaState {
444    /// The lines of text, one entry per line.
445    pub lines: Vec<String>,
446    /// Row index of the cursor (0-based, logical line).
447    pub cursor_row: usize,
448    /// Column index of the cursor within the current row (character index).
449    pub cursor_col: usize,
450    /// Maximum total character count across all lines.
451    pub max_length: Option<usize>,
452    /// When set, lines longer than this display-column width are soft-wrapped.
453    pub wrap_width: Option<u32>,
454    /// First visible visual line (managed internally by `textarea()`).
455    pub scroll_offset: usize,
456}
457
458impl TextareaState {
459    /// Create an empty text area state with one blank line.
460    pub fn new() -> Self {
461        Self {
462            lines: vec![String::new()],
463            cursor_row: 0,
464            cursor_col: 0,
465            max_length: None,
466            wrap_width: None,
467            scroll_offset: 0,
468        }
469    }
470
471    /// Return all lines joined with newline characters.
472    pub fn value(&self) -> String {
473        self.lines.join("\n")
474    }
475
476    /// Replace the content with the given text, splitting on newlines.
477    ///
478    /// Resets the cursor to the beginning of the first line.
479    pub fn set_value(&mut self, text: impl Into<String>) {
480        let value = text.into();
481        self.lines = value.split('\n').map(str::to_string).collect();
482        if self.lines.is_empty() {
483            self.lines.push(String::new());
484        }
485        self.cursor_row = 0;
486        self.cursor_col = 0;
487        self.scroll_offset = 0;
488    }
489
490    /// Set the maximum allowed total character count.
491    pub fn max_length(mut self, len: usize) -> Self {
492        self.max_length = Some(len);
493        self
494    }
495
496    /// Enable soft word-wrap at the given display-column width.
497    pub fn word_wrap(mut self, width: u32) -> Self {
498        self.wrap_width = Some(width);
499        self
500    }
501}
502
503impl Default for TextareaState {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509/// State for an animated spinner widget.
510///
511/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
512/// `Context::spinner` each frame. The frame advances automatically with the
513/// tick counter.
514#[derive(Debug, Clone)]
515pub struct SpinnerState {
516    chars: Vec<char>,
517}
518
519impl SpinnerState {
520    /// Create a dots-style spinner using braille characters.
521    ///
522    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
523    pub fn dots() -> Self {
524        Self {
525            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
526        }
527    }
528
529    /// Create a line-style spinner using ASCII characters.
530    ///
531    /// Cycles through: `| / - \`
532    pub fn line() -> Self {
533        Self {
534            chars: vec!['|', '/', '-', '\\'],
535        }
536    }
537
538    /// Return the spinner character for the given tick.
539    pub fn frame(&self, tick: u64) -> char {
540        if self.chars.is_empty() {
541            return ' ';
542        }
543        self.chars[tick as usize % self.chars.len()]
544    }
545}
546
547impl Default for SpinnerState {
548    fn default() -> Self {
549        Self::dots()
550    }
551}
552
553/// State for a selectable list widget.
554///
555/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
556/// keys (and `k`/`j`) move the selection when the widget is focused.
557#[derive(Debug, Clone, Default)]
558pub struct ListState {
559    /// The list items as display strings.
560    pub items: Vec<String>,
561    /// Index of the currently selected item.
562    pub selected: usize,
563    /// Case-insensitive substring filter applied to list items.
564    pub filter: String,
565    view_indices: Vec<usize>,
566}
567
568impl ListState {
569    /// Create a list with the given items. The first item is selected initially.
570    pub fn new(items: Vec<impl Into<String>>) -> Self {
571        let len = items.len();
572        Self {
573            items: items.into_iter().map(Into::into).collect(),
574            selected: 0,
575            filter: String::new(),
576            view_indices: (0..len).collect(),
577        }
578    }
579
580    /// Replace the list items and rebuild the view index.
581    ///
582    /// Use this instead of assigning `items` directly to ensure the internal
583    /// filter/view state stays consistent.
584    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
585        self.items = items.into_iter().map(Into::into).collect();
586        self.selected = self.selected.min(self.items.len().saturating_sub(1));
587        self.rebuild_view();
588    }
589
590    /// Set the filter string. Multiple space-separated tokens are AND'd
591    /// together — all tokens must match across any cell in the same row.
592    /// Empty string disables filtering.
593    pub fn set_filter(&mut self, filter: impl Into<String>) {
594        self.filter = filter.into();
595        self.rebuild_view();
596    }
597
598    /// Returns indices of items visible after filtering.
599    pub fn visible_indices(&self) -> &[usize] {
600        &self.view_indices
601    }
602
603    /// Get the currently selected item text, or `None` if the list is empty.
604    pub fn selected_item(&self) -> Option<&str> {
605        let data_idx = *self.view_indices.get(self.selected)?;
606        self.items.get(data_idx).map(String::as_str)
607    }
608
609    fn rebuild_view(&mut self) {
610        let tokens: Vec<String> = self
611            .filter
612            .split_whitespace()
613            .map(|t| t.to_lowercase())
614            .collect();
615        self.view_indices = if tokens.is_empty() {
616            (0..self.items.len()).collect()
617        } else {
618            (0..self.items.len())
619                .filter(|&i| {
620                    tokens
621                        .iter()
622                        .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
623                })
624                .collect()
625        };
626        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
627            self.selected = self.view_indices.len() - 1;
628        }
629    }
630}
631
632/// State for a file picker widget.
633///
634/// Tracks the current directory listing, filtering options, and selected file.
635#[derive(Debug, Clone)]
636pub struct FilePickerState {
637    /// Current directory being browsed.
638    pub current_dir: PathBuf,
639    /// Visible entries in the current directory.
640    pub entries: Vec<FileEntry>,
641    /// Selected entry index in `entries`.
642    pub selected: usize,
643    /// Currently selected file path, if any.
644    pub selected_file: Option<PathBuf>,
645    /// Whether dotfiles are included in the listing.
646    pub show_hidden: bool,
647    /// Allowed file extensions (lowercase, no leading dot).
648    pub extensions: Vec<String>,
649    /// Whether the directory listing needs refresh.
650    pub dirty: bool,
651}
652
653/// A directory entry shown by [`FilePickerState`].
654#[derive(Debug, Clone, Default)]
655pub struct FileEntry {
656    /// File or directory name.
657    pub name: String,
658    /// Full path to the entry.
659    pub path: PathBuf,
660    /// Whether this entry is a directory.
661    pub is_dir: bool,
662    /// File size in bytes (0 for directories).
663    pub size: u64,
664}
665
666impl FilePickerState {
667    /// Create a file picker rooted at `dir`.
668    pub fn new(dir: impl Into<PathBuf>) -> Self {
669        Self {
670            current_dir: dir.into(),
671            entries: Vec::new(),
672            selected: 0,
673            selected_file: None,
674            show_hidden: false,
675            extensions: Vec::new(),
676            dirty: true,
677        }
678    }
679
680    /// Configure whether hidden files should be shown.
681    pub fn show_hidden(mut self, show: bool) -> Self {
682        self.show_hidden = show;
683        self.dirty = true;
684        self
685    }
686
687    /// Restrict visible files to the provided extensions.
688    pub fn extensions(mut self, exts: &[&str]) -> Self {
689        self.extensions = exts
690            .iter()
691            .map(|ext| ext.trim().trim_start_matches('.').to_ascii_lowercase())
692            .filter(|ext| !ext.is_empty())
693            .collect();
694        self.dirty = true;
695        self
696    }
697
698    /// Return the currently selected file path.
699    pub fn selected(&self) -> Option<&PathBuf> {
700        self.selected_file.as_ref()
701    }
702
703    /// Re-scan the current directory and rebuild entries.
704    pub fn refresh(&mut self) {
705        let mut entries = Vec::new();
706
707        if let Ok(read_dir) = fs::read_dir(&self.current_dir) {
708            for dir_entry in read_dir.flatten() {
709                let name = dir_entry.file_name().to_string_lossy().to_string();
710                if !self.show_hidden && name.starts_with('.') {
711                    continue;
712                }
713
714                let Ok(file_type) = dir_entry.file_type() else {
715                    continue;
716                };
717                if file_type.is_symlink() {
718                    continue;
719                }
720
721                let path = dir_entry.path();
722                let is_dir = file_type.is_dir();
723
724                if !is_dir && !self.extensions.is_empty() {
725                    let ext = path
726                        .extension()
727                        .and_then(|e| e.to_str())
728                        .map(|e| e.to_ascii_lowercase());
729                    let Some(ext) = ext else {
730                        continue;
731                    };
732                    if !self.extensions.iter().any(|allowed| allowed == &ext) {
733                        continue;
734                    }
735                }
736
737                let size = if is_dir {
738                    0
739                } else {
740                    fs::symlink_metadata(&path).map(|m| m.len()).unwrap_or(0)
741                };
742
743                entries.push(FileEntry {
744                    name,
745                    path,
746                    is_dir,
747                    size,
748                });
749            }
750        }
751
752        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
753            (true, false) => std::cmp::Ordering::Less,
754            (false, true) => std::cmp::Ordering::Greater,
755            _ => a
756                .name
757                .to_ascii_lowercase()
758                .cmp(&b.name.to_ascii_lowercase())
759                .then_with(|| a.name.cmp(&b.name)),
760        });
761
762        self.entries = entries;
763        if self.entries.is_empty() {
764            self.selected = 0;
765        } else {
766            self.selected = self.selected.min(self.entries.len().saturating_sub(1));
767        }
768        self.dirty = false;
769    }
770}
771
772impl Default for FilePickerState {
773    fn default() -> Self {
774        Self::new(".")
775    }
776}
777
778/// State for a tab navigation widget.
779///
780/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
781/// keys cycle through tabs when the widget is focused.
782#[derive(Debug, Clone, Default)]
783pub struct TabsState {
784    /// The tab labels displayed in the bar.
785    pub labels: Vec<String>,
786    /// Index of the currently active tab.
787    pub selected: usize,
788}
789
790impl TabsState {
791    /// Create tabs with the given labels. The first tab is active initially.
792    pub fn new(labels: Vec<impl Into<String>>) -> Self {
793        Self {
794            labels: labels.into_iter().map(Into::into).collect(),
795            selected: 0,
796        }
797    }
798
799    /// Get the currently selected tab label, or `None` if there are no tabs.
800    pub fn selected_label(&self) -> Option<&str> {
801        self.labels.get(self.selected).map(String::as_str)
802    }
803}
804
805/// State for a data table widget.
806///
807/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
808/// keys move the row selection when the widget is focused. Column widths are
809/// computed automatically from header and cell content.
810#[derive(Debug, Clone)]
811pub struct TableState {
812    /// Column header labels.
813    pub headers: Vec<String>,
814    /// Table rows, each a `Vec` of cell strings.
815    pub rows: Vec<Vec<String>>,
816    /// Index of the currently selected row.
817    pub selected: usize,
818    column_widths: Vec<u32>,
819    widths_dirty: bool,
820    /// Sorted column index (`None` means no sorting).
821    pub sort_column: Option<usize>,
822    /// Sort direction (`true` for ascending).
823    pub sort_ascending: bool,
824    /// Case-insensitive substring filter applied across all cells.
825    pub filter: String,
826    /// Current page (0-based) when pagination is enabled.
827    pub page: usize,
828    /// Rows per page (`0` disables pagination).
829    pub page_size: usize,
830    /// Whether alternating row backgrounds are enabled.
831    pub zebra: bool,
832    view_indices: Vec<usize>,
833    row_search_cache: Vec<String>,
834    filter_tokens: Vec<String>,
835}
836
837impl Default for TableState {
838    fn default() -> Self {
839        Self {
840            headers: Vec::new(),
841            rows: Vec::new(),
842            selected: 0,
843            column_widths: Vec::new(),
844            widths_dirty: true,
845            sort_column: None,
846            sort_ascending: true,
847            filter: String::new(),
848            page: 0,
849            page_size: 0,
850            zebra: false,
851            view_indices: Vec::new(),
852            row_search_cache: Vec::new(),
853            filter_tokens: Vec::new(),
854        }
855    }
856}
857
858impl TableState {
859    /// Create a table with headers and rows. Column widths are computed immediately.
860    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
861        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
862        let rows: Vec<Vec<String>> = rows
863            .into_iter()
864            .map(|r| r.into_iter().map(Into::into).collect())
865            .collect();
866        let mut state = Self {
867            headers,
868            rows,
869            selected: 0,
870            column_widths: Vec::new(),
871            widths_dirty: true,
872            sort_column: None,
873            sort_ascending: true,
874            filter: String::new(),
875            page: 0,
876            page_size: 0,
877            zebra: false,
878            view_indices: Vec::new(),
879            row_search_cache: Vec::new(),
880            filter_tokens: Vec::new(),
881        };
882        state.rebuild_row_search_cache();
883        state.rebuild_view();
884        state.recompute_widths();
885        state
886    }
887
888    /// Replace all rows, preserving the selection index if possible.
889    ///
890    /// If the current selection is beyond the new row count, it is clamped to
891    /// the last row.
892    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
893        self.rows = rows
894            .into_iter()
895            .map(|r| r.into_iter().map(Into::into).collect())
896            .collect();
897        self.rebuild_row_search_cache();
898        self.rebuild_view();
899    }
900
901    /// Sort by a specific column index. If already sorted by this column, toggles direction.
902    pub fn toggle_sort(&mut self, column: usize) {
903        if self.sort_column == Some(column) {
904            self.sort_ascending = !self.sort_ascending;
905        } else {
906            self.sort_column = Some(column);
907            self.sort_ascending = true;
908        }
909        self.rebuild_view();
910    }
911
912    /// Sort by column without toggling (always sets to ascending first).
913    pub fn sort_by(&mut self, column: usize) {
914        if self.sort_column == Some(column) && self.sort_ascending {
915            return;
916        }
917        self.sort_column = Some(column);
918        self.sort_ascending = true;
919        self.rebuild_view();
920    }
921
922    /// Set the filter string. Multiple space-separated tokens are AND'd
923    /// together — all tokens must match across any cell in the same row.
924    /// Empty string disables filtering.
925    pub fn set_filter(&mut self, filter: impl Into<String>) {
926        let filter = filter.into();
927        if self.filter == filter {
928            return;
929        }
930        self.filter = filter;
931        self.filter_tokens = Self::tokenize_filter(&self.filter);
932        self.page = 0;
933        self.rebuild_view();
934    }
935
936    /// Clear sorting.
937    pub fn clear_sort(&mut self) {
938        if self.sort_column.is_none() && self.sort_ascending {
939            return;
940        }
941        self.sort_column = None;
942        self.sort_ascending = true;
943        self.rebuild_view();
944    }
945
946    /// Move to the next page. Does nothing if already on the last page.
947    pub fn next_page(&mut self) {
948        if self.page_size == 0 {
949            return;
950        }
951        let last_page = self.total_pages().saturating_sub(1);
952        self.page = (self.page + 1).min(last_page);
953    }
954
955    /// Move to the previous page. Does nothing if already on page 0.
956    pub fn prev_page(&mut self) {
957        self.page = self.page.saturating_sub(1);
958    }
959
960    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
961    pub fn total_pages(&self) -> usize {
962        if self.page_size == 0 {
963            return 1;
964        }
965
966        let len = self.view_indices.len();
967        if len == 0 {
968            1
969        } else {
970            len.div_ceil(self.page_size)
971        }
972    }
973
974    /// Get the visible row indices after filtering and sorting (used internally by table()).
975    pub fn visible_indices(&self) -> &[usize] {
976        &self.view_indices
977    }
978
979    /// Get the currently selected row data, or `None` if the table is empty.
980    pub fn selected_row(&self) -> Option<&[String]> {
981        if self.view_indices.is_empty() {
982            return None;
983        }
984        let data_idx = self.view_indices.get(self.selected)?;
985        self.rows.get(*data_idx).map(|r| r.as_slice())
986    }
987
988    /// Recompute view_indices based on current sort + filter settings.
989    fn rebuild_view(&mut self) {
990        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
991
992        if !self.filter_tokens.is_empty() {
993            indices.retain(|&idx| {
994                let searchable = match self.row_search_cache.get(idx) {
995                    Some(row) => row,
996                    None => return false,
997                };
998                self.filter_tokens
999                    .iter()
1000                    .all(|token| searchable.contains(token.as_str()))
1001            });
1002        }
1003
1004        if let Some(column) = self.sort_column {
1005            indices.sort_by(|a, b| {
1006                let left = self
1007                    .rows
1008                    .get(*a)
1009                    .and_then(|row| row.get(column))
1010                    .map(String::as_str)
1011                    .unwrap_or("");
1012                let right = self
1013                    .rows
1014                    .get(*b)
1015                    .and_then(|row| row.get(column))
1016                    .map(String::as_str)
1017                    .unwrap_or("");
1018
1019                match (left.parse::<f64>(), right.parse::<f64>()) {
1020                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
1021                    _ => left
1022                        .chars()
1023                        .flat_map(char::to_lowercase)
1024                        .cmp(right.chars().flat_map(char::to_lowercase)),
1025                }
1026            });
1027
1028            if !self.sort_ascending {
1029                indices.reverse();
1030            }
1031        }
1032
1033        self.view_indices = indices;
1034
1035        if self.page_size > 0 {
1036            self.page = self.page.min(self.total_pages().saturating_sub(1));
1037        } else {
1038            self.page = 0;
1039        }
1040
1041        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
1042        self.widths_dirty = true;
1043    }
1044
1045    fn rebuild_row_search_cache(&mut self) {
1046        self.row_search_cache = self
1047            .rows
1048            .iter()
1049            .map(|row| {
1050                let mut searchable = String::new();
1051                for (idx, cell) in row.iter().enumerate() {
1052                    if idx > 0 {
1053                        searchable.push('\n');
1054                    }
1055                    searchable.extend(cell.chars().flat_map(char::to_lowercase));
1056                }
1057                searchable
1058            })
1059            .collect();
1060        self.filter_tokens = Self::tokenize_filter(&self.filter);
1061        self.widths_dirty = true;
1062    }
1063
1064    fn tokenize_filter(filter: &str) -> Vec<String> {
1065        filter
1066            .split_whitespace()
1067            .map(|t| t.to_lowercase())
1068            .collect()
1069    }
1070
1071    pub(crate) fn recompute_widths(&mut self) {
1072        let col_count = self.headers.len();
1073        self.column_widths = vec![0u32; col_count];
1074        for (i, header) in self.headers.iter().enumerate() {
1075            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
1076            if self.sort_column == Some(i) {
1077                width += 2;
1078            }
1079            self.column_widths[i] = width;
1080        }
1081        for row in &self.rows {
1082            for (i, cell) in row.iter().enumerate() {
1083                if i < col_count {
1084                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
1085                    self.column_widths[i] = self.column_widths[i].max(w);
1086                }
1087            }
1088        }
1089        self.widths_dirty = false;
1090    }
1091
1092    pub(crate) fn column_widths(&self) -> &[u32] {
1093        &self.column_widths
1094    }
1095
1096    pub(crate) fn is_dirty(&self) -> bool {
1097        self.widths_dirty
1098    }
1099}
1100
1101/// State for a scrollable container.
1102///
1103/// Pass a mutable reference to `Context::scrollable` each frame. The context
1104/// updates `offset` and the internal bounds automatically based on mouse wheel
1105/// and drag events.
1106#[derive(Debug, Clone)]
1107pub struct ScrollState {
1108    /// Current vertical scroll offset in rows.
1109    pub offset: usize,
1110    content_height: u32,
1111    viewport_height: u32,
1112}
1113
1114impl ScrollState {
1115    /// Create scroll state starting at offset 0.
1116    pub fn new() -> Self {
1117        Self {
1118            offset: 0,
1119            content_height: 0,
1120            viewport_height: 0,
1121        }
1122    }
1123
1124    /// Check if scrolling upward is possible (offset is greater than 0).
1125    pub fn can_scroll_up(&self) -> bool {
1126        self.offset > 0
1127    }
1128
1129    /// Check if scrolling downward is possible (content extends below the viewport).
1130    pub fn can_scroll_down(&self) -> bool {
1131        (self.offset as u32) + self.viewport_height < self.content_height
1132    }
1133
1134    /// Get the total content height in rows.
1135    pub fn content_height(&self) -> u32 {
1136        self.content_height
1137    }
1138
1139    /// Get the viewport height in rows.
1140    pub fn viewport_height(&self) -> u32 {
1141        self.viewport_height
1142    }
1143
1144    /// Get the scroll progress as a ratio in [0.0, 1.0].
1145    pub fn progress(&self) -> f32 {
1146        let max = self.content_height.saturating_sub(self.viewport_height);
1147        if max == 0 {
1148            0.0
1149        } else {
1150            self.offset as f32 / max as f32
1151        }
1152    }
1153
1154    /// Scroll up by the given number of rows, clamped to 0.
1155    pub fn scroll_up(&mut self, amount: usize) {
1156        self.offset = self.offset.saturating_sub(amount);
1157    }
1158
1159    /// Scroll down by the given number of rows, clamped to the maximum offset.
1160    pub fn scroll_down(&mut self, amount: usize) {
1161        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
1162        self.offset = (self.offset + amount).min(max_offset);
1163    }
1164
1165    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
1166        self.content_height = content_height;
1167        self.viewport_height = viewport_height;
1168    }
1169}
1170
1171impl Default for ScrollState {
1172    fn default() -> Self {
1173        Self::new()
1174    }
1175}
1176
1177/// State for the rich log viewer widget.
1178#[derive(Debug, Clone)]
1179pub struct RichLogState {
1180    /// Log entries to display.
1181    pub entries: Vec<RichLogEntry>,
1182    /// Scroll offset (0 = top).
1183    pub(crate) scroll_offset: usize,
1184    /// Whether to auto-scroll to bottom when new entries are added.
1185    pub auto_scroll: bool,
1186    /// Maximum number of entries to keep (None = unlimited).
1187    pub max_entries: Option<usize>,
1188}
1189
1190/// A single entry in a RichLog.
1191#[derive(Debug, Clone)]
1192pub struct RichLogEntry {
1193    /// Styled text segments for this entry.
1194    pub segments: Vec<(String, Style)>,
1195}
1196
1197impl RichLogState {
1198    /// Create an empty rich log state.
1199    pub fn new() -> Self {
1200        Self {
1201            entries: Vec::new(),
1202            scroll_offset: 0,
1203            auto_scroll: true,
1204            max_entries: None,
1205        }
1206    }
1207
1208    /// Add a single-style entry to the log.
1209    pub fn push(&mut self, text: impl Into<String>, style: Style) {
1210        self.push_segments(vec![(text.into(), style)]);
1211    }
1212
1213    /// Add a plain text entry using default style.
1214    pub fn push_plain(&mut self, text: impl Into<String>) {
1215        self.push(text, Style::new());
1216    }
1217
1218    /// Add a multi-segment styled entry to the log.
1219    pub fn push_segments(&mut self, segments: Vec<(String, Style)>) {
1220        self.entries.push(RichLogEntry { segments });
1221
1222        if let Some(max_entries) = self.max_entries {
1223            if self.entries.len() > max_entries {
1224                let remove_count = self.entries.len() - max_entries;
1225                self.entries.drain(0..remove_count);
1226                self.scroll_offset = self.scroll_offset.saturating_sub(remove_count);
1227            }
1228        }
1229
1230        if self.auto_scroll {
1231            self.scroll_offset = usize::MAX;
1232        }
1233    }
1234
1235    /// Clear all entries and reset scroll position.
1236    pub fn clear(&mut self) {
1237        self.entries.clear();
1238        self.scroll_offset = 0;
1239    }
1240
1241    /// Return number of entries in the log.
1242    pub fn len(&self) -> usize {
1243        self.entries.len()
1244    }
1245
1246    /// Return true when no entries are present.
1247    pub fn is_empty(&self) -> bool {
1248        self.entries.is_empty()
1249    }
1250}
1251
1252impl Default for RichLogState {
1253    fn default() -> Self {
1254        Self::new()
1255    }
1256}
1257
1258/// State for the calendar date picker widget.
1259#[derive(Debug, Clone)]
1260pub struct CalendarState {
1261    /// Current display year.
1262    pub year: i32,
1263    /// Current display month (1–12).
1264    pub month: u32,
1265    /// Currently selected day, if any.
1266    pub selected_day: Option<u32>,
1267    pub(crate) cursor_day: u32,
1268}
1269
1270impl CalendarState {
1271    /// Create a new `CalendarState` initialized to the current month.
1272    pub fn new() -> Self {
1273        let (year, month) = Self::current_year_month();
1274        Self::from_ym(year, month)
1275    }
1276
1277    /// Create a `CalendarState` for a specific year and month.
1278    pub fn from_ym(year: i32, month: u32) -> Self {
1279        let month = month.clamp(1, 12);
1280        Self {
1281            year,
1282            month,
1283            selected_day: None,
1284            cursor_day: 1,
1285        }
1286    }
1287
1288    /// Returns the selected date as `(year, month, day)`, if any.
1289    pub fn selected_date(&self) -> Option<(i32, u32, u32)> {
1290        self.selected_day.map(|day| (self.year, self.month, day))
1291    }
1292
1293    /// Navigate to the previous month.
1294    pub fn prev_month(&mut self) {
1295        if self.month == 1 {
1296            self.month = 12;
1297            self.year -= 1;
1298        } else {
1299            self.month -= 1;
1300        }
1301        self.clamp_days();
1302    }
1303
1304    /// Navigate to the next month.
1305    pub fn next_month(&mut self) {
1306        if self.month == 12 {
1307            self.month = 1;
1308            self.year += 1;
1309        } else {
1310            self.month += 1;
1311        }
1312        self.clamp_days();
1313    }
1314
1315    pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
1316        match month {
1317            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1318            4 | 6 | 9 | 11 => 30,
1319            2 => {
1320                if Self::is_leap_year(year) {
1321                    29
1322                } else {
1323                    28
1324                }
1325            }
1326            _ => 30,
1327        }
1328    }
1329
1330    pub(crate) fn first_weekday(year: i32, month: u32) -> u32 {
1331        let month = month.clamp(1, 12);
1332        let offsets = [0_i32, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
1333        let mut y = year;
1334        if month < 3 {
1335            y -= 1;
1336        }
1337        let sunday_based = (y + y / 4 - y / 100 + y / 400 + offsets[(month - 1) as usize] + 1) % 7;
1338        ((sunday_based + 6) % 7) as u32
1339    }
1340
1341    fn clamp_days(&mut self) {
1342        let max_day = Self::days_in_month(self.year, self.month);
1343        self.cursor_day = self.cursor_day.clamp(1, max_day);
1344        if let Some(day) = self.selected_day {
1345            self.selected_day = Some(day.min(max_day));
1346        }
1347    }
1348
1349    fn is_leap_year(year: i32) -> bool {
1350        (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1351    }
1352
1353    fn current_year_month() -> (i32, u32) {
1354        let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1355            return (1970, 1);
1356        };
1357        let days_since_epoch = (duration.as_secs() / 86_400) as i64;
1358        let (year, month, _) = Self::civil_from_days(days_since_epoch);
1359        (year, month)
1360    }
1361
1362    fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
1363        let z = days_since_epoch + 719_468;
1364        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1365        let doe = z - era * 146_097;
1366        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1367        let mut year = (yoe as i32) + (era as i32) * 400;
1368        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1369        let mp = (5 * doy + 2) / 153;
1370        let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
1371        let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
1372        if month <= 2 {
1373            year += 1;
1374        }
1375        (year, month, day)
1376    }
1377}
1378
1379impl Default for CalendarState {
1380    fn default() -> Self {
1381        Self::new()
1382    }
1383}
1384
1385/// Visual variant for buttons.
1386///
1387/// Controls the color scheme used when rendering a button. Pass to
1388/// [`crate::Context::button_with`] to create styled button variants.
1389///
1390/// - `Default` — theme text color, primary when focused (same as `button()`)
1391/// - `Primary` — primary color background with contrasting text
1392/// - `Danger` — error/red color for destructive actions
1393/// - `Outline` — bordered appearance without fill
1394#[non_exhaustive]
1395#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1396pub enum ButtonVariant {
1397    /// Standard button style.
1398    #[default]
1399    Default,
1400    /// Filled button with primary background color.
1401    Primary,
1402    /// Filled button with error/danger background color.
1403    Danger,
1404    /// Bordered button without background fill.
1405    Outline,
1406}
1407
1408/// Direction indicator for stat widgets.
1409#[non_exhaustive]
1410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1411pub enum Trend {
1412    /// Positive movement.
1413    Up,
1414    /// Negative movement.
1415    Down,
1416}
1417
1418// ── Select / Dropdown ─────────────────────────────────────────────────
1419
1420/// State for a dropdown select widget.
1421///
1422/// Renders as a single-line button showing the selected option. When activated,
1423/// expands into a vertical list overlay for picking an option.
1424#[derive(Debug, Clone, Default)]
1425pub struct SelectState {
1426    /// Selectable option labels.
1427    pub items: Vec<String>,
1428    /// Selected option index.
1429    pub selected: usize,
1430    /// Whether the dropdown list is currently open.
1431    pub open: bool,
1432    /// Placeholder text shown when `items` is empty.
1433    pub placeholder: String,
1434    cursor: usize,
1435}
1436
1437impl SelectState {
1438    /// Create select state with the provided options.
1439    pub fn new(items: Vec<impl Into<String>>) -> Self {
1440        Self {
1441            items: items.into_iter().map(Into::into).collect(),
1442            selected: 0,
1443            open: false,
1444            placeholder: String::new(),
1445            cursor: 0,
1446        }
1447    }
1448
1449    /// Set placeholder text shown when no item can be displayed.
1450    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
1451        self.placeholder = p.into();
1452        self
1453    }
1454
1455    /// Returns the currently selected item label, or `None` if empty.
1456    pub fn selected_item(&self) -> Option<&str> {
1457        self.items.get(self.selected).map(String::as_str)
1458    }
1459
1460    pub(crate) fn cursor(&self) -> usize {
1461        self.cursor
1462    }
1463
1464    pub(crate) fn set_cursor(&mut self, c: usize) {
1465        self.cursor = c;
1466    }
1467}
1468
1469// ── Radio ─────────────────────────────────────────────────────────────
1470
1471/// State for a radio button group.
1472///
1473/// Renders a vertical list of mutually-exclusive options with `●`/`○` markers.
1474#[derive(Debug, Clone, Default)]
1475pub struct RadioState {
1476    /// Radio option labels.
1477    pub items: Vec<String>,
1478    /// Selected option index.
1479    pub selected: usize,
1480}
1481
1482impl RadioState {
1483    /// Create radio state with the provided options.
1484    pub fn new(items: Vec<impl Into<String>>) -> Self {
1485        Self {
1486            items: items.into_iter().map(Into::into).collect(),
1487            selected: 0,
1488        }
1489    }
1490
1491    /// Returns the currently selected option label, or `None` if empty.
1492    pub fn selected_item(&self) -> Option<&str> {
1493        self.items.get(self.selected).map(String::as_str)
1494    }
1495}
1496
1497// ── Multi-Select ──────────────────────────────────────────────────────
1498
1499/// State for a multi-select list.
1500///
1501/// Like [`ListState`] but allows toggling multiple items with Space.
1502#[derive(Debug, Clone)]
1503pub struct MultiSelectState {
1504    /// Multi-select option labels.
1505    pub items: Vec<String>,
1506    /// Focused option index used for keyboard navigation.
1507    pub cursor: usize,
1508    /// Set of selected option indices.
1509    pub selected: HashSet<usize>,
1510}
1511
1512impl MultiSelectState {
1513    /// Create multi-select state with the provided options.
1514    pub fn new(items: Vec<impl Into<String>>) -> Self {
1515        Self {
1516            items: items.into_iter().map(Into::into).collect(),
1517            cursor: 0,
1518            selected: HashSet::new(),
1519        }
1520    }
1521
1522    /// Return selected item labels in ascending index order.
1523    pub fn selected_items(&self) -> Vec<&str> {
1524        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
1525        indices.sort();
1526        indices
1527            .iter()
1528            .filter_map(|&i| self.items.get(i).map(String::as_str))
1529            .collect()
1530    }
1531
1532    /// Toggle selection state for `index`.
1533    pub fn toggle(&mut self, index: usize) {
1534        if self.selected.contains(&index) {
1535            self.selected.remove(&index);
1536        } else {
1537            self.selected.insert(index);
1538        }
1539    }
1540}
1541
1542// ── Tree ──────────────────────────────────────────────────────────────
1543
1544/// A node in a tree view.
1545#[derive(Debug, Clone)]
1546pub struct TreeNode {
1547    /// Display label for this node.
1548    pub label: String,
1549    /// Child nodes.
1550    pub children: Vec<TreeNode>,
1551    /// Whether the node is expanded in the tree view.
1552    pub expanded: bool,
1553}
1554
1555impl TreeNode {
1556    /// Create a collapsed tree node with no children.
1557    pub fn new(label: impl Into<String>) -> Self {
1558        Self {
1559            label: label.into(),
1560            children: Vec::new(),
1561            expanded: false,
1562        }
1563    }
1564
1565    /// Mark this node as expanded.
1566    pub fn expanded(mut self) -> Self {
1567        self.expanded = true;
1568        self
1569    }
1570
1571    /// Set child nodes for this node.
1572    pub fn children(mut self, children: Vec<TreeNode>) -> Self {
1573        self.children = children;
1574        self
1575    }
1576
1577    /// Returns `true` when this node has no children.
1578    pub fn is_leaf(&self) -> bool {
1579        self.children.is_empty()
1580    }
1581
1582    fn flatten(&self, depth: usize, out: &mut Vec<FlatTreeEntry>) {
1583        out.push(FlatTreeEntry {
1584            depth,
1585            label: self.label.clone(),
1586            is_leaf: self.is_leaf(),
1587            expanded: self.expanded,
1588        });
1589        if self.expanded {
1590            for child in &self.children {
1591                child.flatten(depth + 1, out);
1592            }
1593        }
1594    }
1595}
1596
1597pub(crate) struct FlatTreeEntry {
1598    pub depth: usize,
1599    pub label: String,
1600    pub is_leaf: bool,
1601    pub expanded: bool,
1602}
1603
1604/// State for a hierarchical tree view widget.
1605#[derive(Debug, Clone)]
1606pub struct TreeState {
1607    /// Root nodes of the tree.
1608    pub nodes: Vec<TreeNode>,
1609    /// Selected row index in the flattened visible tree.
1610    pub selected: usize,
1611}
1612
1613impl TreeState {
1614    /// Create tree state from root nodes.
1615    pub fn new(nodes: Vec<TreeNode>) -> Self {
1616        Self { nodes, selected: 0 }
1617    }
1618
1619    pub(crate) fn flatten(&self) -> Vec<FlatTreeEntry> {
1620        let mut entries = Vec::new();
1621        for node in &self.nodes {
1622            node.flatten(0, &mut entries);
1623        }
1624        entries
1625    }
1626
1627    pub(crate) fn toggle_at(&mut self, flat_index: usize) {
1628        let mut counter = 0usize;
1629        Self::toggle_recursive(&mut self.nodes, flat_index, &mut counter);
1630    }
1631
1632    fn toggle_recursive(nodes: &mut [TreeNode], target: usize, counter: &mut usize) -> bool {
1633        for node in nodes.iter_mut() {
1634            if *counter == target {
1635                if !node.is_leaf() {
1636                    node.expanded = !node.expanded;
1637                }
1638                return true;
1639            }
1640            *counter += 1;
1641            if node.expanded && Self::toggle_recursive(&mut node.children, target, counter) {
1642                return true;
1643            }
1644        }
1645        false
1646    }
1647}
1648
1649/// State for the directory tree widget.
1650#[derive(Debug, Clone)]
1651pub struct DirectoryTreeState {
1652    /// The underlying tree state (reuses existing TreeState).
1653    pub tree: TreeState,
1654    /// Whether to show file/folder icons.
1655    pub show_icons: bool,
1656}
1657
1658impl DirectoryTreeState {
1659    /// Create directory tree state from root nodes.
1660    pub fn new(nodes: Vec<TreeNode>) -> Self {
1661        Self {
1662            tree: TreeState::new(nodes),
1663            show_icons: true,
1664        }
1665    }
1666
1667    /// Build a directory tree from slash-delimited paths.
1668    pub fn from_paths(paths: &[&str]) -> Self {
1669        let mut roots: Vec<TreeNode> = Vec::new();
1670
1671        for raw_path in paths {
1672            let parts: Vec<&str> = raw_path
1673                .split('/')
1674                .filter(|part| !part.is_empty())
1675                .collect();
1676            if parts.is_empty() {
1677                continue;
1678            }
1679            insert_path(&mut roots, &parts, 0);
1680        }
1681
1682        Self::new(roots)
1683    }
1684
1685    /// Return selected node label if a node is selected.
1686    pub fn selected_label(&self) -> Option<&str> {
1687        let mut cursor = 0usize;
1688        selected_label_in_nodes(&self.tree.nodes, self.tree.selected, &mut cursor)
1689    }
1690}
1691
1692impl Default for DirectoryTreeState {
1693    fn default() -> Self {
1694        Self::new(Vec::<TreeNode>::new())
1695    }
1696}
1697
1698fn insert_path(nodes: &mut Vec<TreeNode>, parts: &[&str], depth: usize) {
1699    let Some(label) = parts.get(depth) else {
1700        return;
1701    };
1702
1703    let is_last = depth + 1 == parts.len();
1704    let idx = nodes
1705        .iter()
1706        .position(|node| node.label == *label)
1707        .unwrap_or_else(|| {
1708            let mut node = TreeNode::new(*label);
1709            if !is_last {
1710                node.expanded = true;
1711            }
1712            nodes.push(node);
1713            nodes.len() - 1
1714        });
1715
1716    if is_last {
1717        return;
1718    }
1719
1720    nodes[idx].expanded = true;
1721    insert_path(&mut nodes[idx].children, parts, depth + 1);
1722}
1723
1724fn selected_label_in_nodes<'a>(
1725    nodes: &'a [TreeNode],
1726    target: usize,
1727    cursor: &mut usize,
1728) -> Option<&'a str> {
1729    for node in nodes {
1730        if *cursor == target {
1731            return Some(node.label.as_str());
1732        }
1733        *cursor += 1;
1734        if node.expanded {
1735            if let Some(found) = selected_label_in_nodes(&node.children, target, cursor) {
1736                return Some(found);
1737            }
1738        }
1739    }
1740    None
1741}
1742
1743// ── Command Palette ───────────────────────────────────────────────────
1744
1745/// A single command entry in the palette.
1746#[derive(Debug, Clone)]
1747pub struct PaletteCommand {
1748    /// Primary command label.
1749    pub label: String,
1750    /// Supplemental command description.
1751    pub description: String,
1752    /// Optional keyboard shortcut hint.
1753    pub shortcut: Option<String>,
1754}
1755
1756impl PaletteCommand {
1757    /// Create a new palette command.
1758    pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
1759        Self {
1760            label: label.into(),
1761            description: description.into(),
1762            shortcut: None,
1763        }
1764    }
1765
1766    /// Set a shortcut hint displayed alongside the command.
1767    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
1768        self.shortcut = Some(s.into());
1769        self
1770    }
1771}
1772
1773/// State for a command palette overlay.
1774///
1775/// Renders as a modal with a search input and filtered command list.
1776#[derive(Debug, Clone)]
1777pub struct CommandPaletteState {
1778    /// Available commands.
1779    pub commands: Vec<PaletteCommand>,
1780    /// Current search query.
1781    pub input: String,
1782    /// Cursor index within `input`.
1783    pub cursor: usize,
1784    /// Whether the palette modal is open.
1785    pub open: bool,
1786    /// The last selected command index, set when the user confirms a selection.
1787    /// Check this after `response.changed` is true.
1788    pub last_selected: Option<usize>,
1789    selected: usize,
1790}
1791
1792impl CommandPaletteState {
1793    /// Create command palette state from a command list.
1794    pub fn new(commands: Vec<PaletteCommand>) -> Self {
1795        Self {
1796            commands,
1797            input: String::new(),
1798            cursor: 0,
1799            open: false,
1800            last_selected: None,
1801            selected: 0,
1802        }
1803    }
1804
1805    /// Toggle open/closed state and reset input when opening.
1806    pub fn toggle(&mut self) {
1807        self.open = !self.open;
1808        if self.open {
1809            self.input.clear();
1810            self.cursor = 0;
1811            self.selected = 0;
1812        }
1813    }
1814
1815    fn fuzzy_score(pattern: &str, text: &str) -> Option<i32> {
1816        let pattern = pattern.trim();
1817        if pattern.is_empty() {
1818            return Some(0);
1819        }
1820
1821        let text_chars: Vec<char> = text.chars().collect();
1822        let mut score = 0;
1823        let mut search_start = 0usize;
1824        let mut prev_match: Option<usize> = None;
1825
1826        for p in pattern.chars() {
1827            let mut found = None;
1828            for (idx, ch) in text_chars.iter().enumerate().skip(search_start) {
1829                if ch.eq_ignore_ascii_case(&p) {
1830                    found = Some(idx);
1831                    break;
1832                }
1833            }
1834
1835            let idx = found?;
1836            if prev_match.is_some_and(|prev| idx == prev + 1) {
1837                score += 3;
1838            } else {
1839                score += 1;
1840            }
1841
1842            if idx == 0 {
1843                score += 2;
1844            } else {
1845                let prev = text_chars[idx - 1];
1846                let curr = text_chars[idx];
1847                if matches!(prev, ' ' | '_' | '-') || prev.is_uppercase() || curr.is_uppercase() {
1848                    score += 2;
1849                }
1850            }
1851
1852            prev_match = Some(idx);
1853            search_start = idx + 1;
1854        }
1855
1856        Some(score)
1857    }
1858
1859    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
1860        let query = self.input.trim();
1861        if query.is_empty() {
1862            return (0..self.commands.len()).collect();
1863        }
1864
1865        let mut scored: Vec<(usize, i32)> = self
1866            .commands
1867            .iter()
1868            .enumerate()
1869            .filter_map(|(i, cmd)| {
1870                let mut haystack =
1871                    String::with_capacity(cmd.label.len() + cmd.description.len() + 1);
1872                haystack.push_str(&cmd.label);
1873                haystack.push(' ');
1874                haystack.push_str(&cmd.description);
1875                Self::fuzzy_score(query, &haystack).map(|score| (i, score))
1876            })
1877            .collect();
1878
1879        if scored.is_empty() {
1880            let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
1881            return self
1882                .commands
1883                .iter()
1884                .enumerate()
1885                .filter(|(_, cmd)| {
1886                    let label = cmd.label.to_lowercase();
1887                    let desc = cmd.description.to_lowercase();
1888                    tokens.iter().all(|token| {
1889                        label.contains(token.as_str()) || desc.contains(token.as_str())
1890                    })
1891                })
1892                .map(|(i, _)| i)
1893                .collect();
1894        }
1895
1896        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1897        scored.into_iter().map(|(idx, _)| idx).collect()
1898    }
1899
1900    pub(crate) fn selected(&self) -> usize {
1901        self.selected
1902    }
1903
1904    pub(crate) fn set_selected(&mut self, s: usize) {
1905        self.selected = s;
1906    }
1907}
1908
1909/// State for a streaming text display.
1910///
1911/// Accumulates text chunks as they arrive from an LLM stream.
1912/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
1913#[derive(Debug, Clone)]
1914pub struct StreamingTextState {
1915    /// The accumulated text content.
1916    pub content: String,
1917    /// Whether the stream is still receiving data.
1918    pub streaming: bool,
1919    /// Cursor blink state (for the typing indicator).
1920    pub(crate) cursor_visible: bool,
1921    pub(crate) cursor_tick: u64,
1922}
1923
1924impl StreamingTextState {
1925    /// Create a new empty streaming text state.
1926    pub fn new() -> Self {
1927        Self {
1928            content: String::new(),
1929            streaming: false,
1930            cursor_visible: true,
1931            cursor_tick: 0,
1932        }
1933    }
1934
1935    /// Append a chunk of text (e.g., from an LLM stream delta).
1936    pub fn push(&mut self, chunk: &str) {
1937        self.content.push_str(chunk);
1938    }
1939
1940    /// Mark the stream as complete (hides the typing cursor).
1941    pub fn finish(&mut self) {
1942        self.streaming = false;
1943    }
1944
1945    /// Start a new streaming session, clearing previous content.
1946    pub fn start(&mut self) {
1947        self.content.clear();
1948        self.streaming = true;
1949        self.cursor_visible = true;
1950        self.cursor_tick = 0;
1951    }
1952
1953    /// Clear all content and reset state.
1954    pub fn clear(&mut self) {
1955        self.content.clear();
1956        self.streaming = false;
1957        self.cursor_visible = true;
1958        self.cursor_tick = 0;
1959    }
1960}
1961
1962impl Default for StreamingTextState {
1963    fn default() -> Self {
1964        Self::new()
1965    }
1966}
1967
1968/// State for a streaming markdown display.
1969///
1970/// Accumulates markdown chunks as they arrive from an LLM stream.
1971/// Pass to [`Context::streaming_markdown`](crate::Context::streaming_markdown) each frame.
1972#[derive(Debug, Clone)]
1973pub struct StreamingMarkdownState {
1974    /// The accumulated markdown content.
1975    pub content: String,
1976    /// Whether the stream is still receiving data.
1977    pub streaming: bool,
1978    /// Cursor blink state (for the typing indicator).
1979    pub cursor_visible: bool,
1980    /// Cursor animation tick counter.
1981    pub cursor_tick: u64,
1982    /// Whether the parser is currently inside a fenced code block.
1983    pub in_code_block: bool,
1984    /// Language label of the active fenced code block.
1985    pub code_block_lang: String,
1986}
1987
1988impl StreamingMarkdownState {
1989    /// Create a new empty streaming markdown state.
1990    pub fn new() -> Self {
1991        Self {
1992            content: String::new(),
1993            streaming: false,
1994            cursor_visible: true,
1995            cursor_tick: 0,
1996            in_code_block: false,
1997            code_block_lang: String::new(),
1998        }
1999    }
2000
2001    /// Append a markdown chunk (e.g., from an LLM stream delta).
2002    pub fn push(&mut self, chunk: &str) {
2003        self.content.push_str(chunk);
2004    }
2005
2006    /// Start a new streaming session, clearing previous content.
2007    pub fn start(&mut self) {
2008        self.content.clear();
2009        self.streaming = true;
2010        self.cursor_visible = true;
2011        self.cursor_tick = 0;
2012        self.in_code_block = false;
2013        self.code_block_lang.clear();
2014    }
2015
2016    /// Mark the stream as complete (hides the typing cursor).
2017    pub fn finish(&mut self) {
2018        self.streaming = false;
2019    }
2020
2021    /// Clear all content and reset state.
2022    pub fn clear(&mut self) {
2023        self.content.clear();
2024        self.streaming = false;
2025        self.cursor_visible = true;
2026        self.cursor_tick = 0;
2027        self.in_code_block = false;
2028        self.code_block_lang.clear();
2029    }
2030}
2031
2032impl Default for StreamingMarkdownState {
2033    fn default() -> Self {
2034        Self::new()
2035    }
2036}
2037
2038/// Navigation stack state for multi-screen apps.
2039///
2040/// Tracks screen names in a push/pop stack while preserving the root screen.
2041/// Pass this state through your render closure and branch on [`ScreenState::current`].
2042#[derive(Debug, Clone)]
2043pub struct ScreenState {
2044    stack: Vec<String>,
2045}
2046
2047impl ScreenState {
2048    /// Create a screen stack with an initial root screen.
2049    pub fn new(initial: impl Into<String>) -> Self {
2050        Self {
2051            stack: vec![initial.into()],
2052        }
2053    }
2054
2055    /// Return the current screen name (top of the stack).
2056    pub fn current(&self) -> &str {
2057        self.stack
2058            .last()
2059            .expect("ScreenState always contains at least one screen")
2060            .as_str()
2061    }
2062
2063    /// Push a new screen onto the stack.
2064    pub fn push(&mut self, name: impl Into<String>) {
2065        self.stack.push(name.into());
2066    }
2067
2068    /// Pop the current screen, preserving the root screen.
2069    pub fn pop(&mut self) {
2070        if self.can_pop() {
2071            self.stack.pop();
2072        }
2073    }
2074
2075    /// Return current stack depth.
2076    pub fn depth(&self) -> usize {
2077        self.stack.len()
2078    }
2079
2080    /// Return `true` if popping is allowed.
2081    pub fn can_pop(&self) -> bool {
2082        self.stack.len() > 1
2083    }
2084
2085    /// Reset to only the root screen.
2086    pub fn reset(&mut self) {
2087        self.stack.truncate(1);
2088    }
2089}
2090
2091/// Approval state for a tool call.
2092#[non_exhaustive]
2093#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2094pub enum ApprovalAction {
2095    /// No action taken yet.
2096    Pending,
2097    /// User approved the tool call.
2098    Approved,
2099    /// User rejected the tool call.
2100    Rejected,
2101}
2102
2103/// State for a tool approval widget.
2104///
2105/// Displays a tool call with approve/reject buttons for human-in-the-loop
2106/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
2107/// each frame.
2108#[derive(Debug, Clone)]
2109pub struct ToolApprovalState {
2110    /// The name of the tool being invoked.
2111    pub tool_name: String,
2112    /// A human-readable description of what the tool will do.
2113    pub description: String,
2114    /// The current approval status.
2115    pub action: ApprovalAction,
2116}
2117
2118impl ToolApprovalState {
2119    /// Create a new tool approval prompt.
2120    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
2121        Self {
2122            tool_name: tool_name.into(),
2123            description: description.into(),
2124            action: ApprovalAction::Pending,
2125        }
2126    }
2127
2128    /// Reset to pending state.
2129    pub fn reset(&mut self) {
2130        self.action = ApprovalAction::Pending;
2131    }
2132}
2133
2134/// Item in a context bar showing active context sources.
2135#[derive(Debug, Clone)]
2136pub struct ContextItem {
2137    /// Display label for this context source.
2138    pub label: String,
2139    /// Token count or size indicator.
2140    pub tokens: usize,
2141}
2142
2143impl ContextItem {
2144    /// Create a new context item with a label and token count.
2145    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
2146        Self {
2147            label: label.into(),
2148            tokens,
2149        }
2150    }
2151}
2152
2153#[cfg(test)]
2154mod tests {
2155    use super::*;
2156
2157    #[test]
2158    fn static_output_accumulates_and_drains_new_lines() {
2159        let mut output = StaticOutput::new();
2160        output.println("Building crate...");
2161        output.println("Compiling foo v0.1.0");
2162
2163        assert_eq!(
2164            output.lines(),
2165            &[
2166                "Building crate...".to_string(),
2167                "Compiling foo v0.1.0".to_string()
2168            ]
2169        );
2170
2171        let first = output.drain_new();
2172        assert_eq!(
2173            first,
2174            vec![
2175                "Building crate...".to_string(),
2176                "Compiling foo v0.1.0".to_string()
2177            ]
2178        );
2179        assert!(output.drain_new().is_empty());
2180
2181        output.println("Finished");
2182        assert_eq!(output.drain_new(), vec!["Finished".to_string()]);
2183    }
2184
2185    #[test]
2186    fn static_output_clear_resets_all_buffers() {
2187        let mut output = StaticOutput::new();
2188        output.println("line");
2189        output.clear();
2190
2191        assert!(output.lines().is_empty());
2192        assert!(output.drain_new().is_empty());
2193    }
2194
2195    #[test]
2196    fn form_field_default_values() {
2197        let field = FormField::default();
2198        assert_eq!(field.label, "");
2199        assert_eq!(field.input.value, "");
2200        assert_eq!(field.input.cursor, 0);
2201        assert_eq!(field.error, None);
2202    }
2203
2204    #[test]
2205    fn toast_message_default_values() {
2206        let msg = ToastMessage::default();
2207        assert_eq!(msg.text, "");
2208        assert!(matches!(msg.level, ToastLevel::Info));
2209        assert_eq!(msg.created_tick, 0);
2210        assert_eq!(msg.duration_ticks, 30);
2211    }
2212
2213    #[test]
2214    fn list_state_default_values() {
2215        let state = ListState::default();
2216        assert!(state.items.is_empty());
2217        assert_eq!(state.selected, 0);
2218        assert_eq!(state.filter, "");
2219        assert!(state.visible_indices().is_empty());
2220        assert_eq!(state.selected_item(), None);
2221    }
2222
2223    #[test]
2224    fn file_entry_default_values() {
2225        let entry = FileEntry::default();
2226        assert_eq!(entry.name, "");
2227        assert_eq!(entry.path, PathBuf::new());
2228        assert!(!entry.is_dir);
2229        assert_eq!(entry.size, 0);
2230    }
2231
2232    #[test]
2233    fn tabs_state_default_values() {
2234        let state = TabsState::default();
2235        assert!(state.labels.is_empty());
2236        assert_eq!(state.selected, 0);
2237        assert_eq!(state.selected_label(), None);
2238    }
2239
2240    #[test]
2241    fn table_state_default_values() {
2242        let state = TableState::default();
2243        assert!(state.headers.is_empty());
2244        assert!(state.rows.is_empty());
2245        assert_eq!(state.selected, 0);
2246        assert_eq!(state.sort_column, None);
2247        assert!(state.sort_ascending);
2248        assert_eq!(state.filter, "");
2249        assert_eq!(state.page, 0);
2250        assert_eq!(state.page_size, 0);
2251        assert!(!state.zebra);
2252        assert!(state.visible_indices().is_empty());
2253        assert!(state.row_search_cache.is_empty());
2254        assert!(state.filter_tokens.is_empty());
2255    }
2256
2257    #[test]
2258    fn table_state_builds_lowercase_search_cache() {
2259        let state = TableState::new(vec!["Name"], vec![vec!["Alice Smith"], vec!["Bob"]]);
2260
2261        assert_eq!(state.row_search_cache.len(), 2);
2262        assert_eq!(state.row_search_cache[0], "alice smith");
2263        assert_eq!(state.row_search_cache[1], "bob");
2264    }
2265
2266    #[test]
2267    fn table_filter_tokens_are_cached_and_reused() {
2268        let mut state = TableState::new(
2269            vec!["Name", "Role"],
2270            vec![vec!["Alice", "Admin"], vec!["Bob", "Viewer"]],
2271        );
2272
2273        state.set_filter("AL admin");
2274        assert_eq!(
2275            state.filter_tokens,
2276            vec!["al".to_string(), "admin".to_string()]
2277        );
2278        assert_eq!(state.visible_indices(), &[0]);
2279
2280        state.set_filter("AL admin");
2281        assert_eq!(
2282            state.filter_tokens,
2283            vec!["al".to_string(), "admin".to_string()]
2284        );
2285        assert_eq!(state.visible_indices(), &[0]);
2286    }
2287
2288    #[test]
2289    fn select_state_default_values() {
2290        let state = SelectState::default();
2291        assert!(state.items.is_empty());
2292        assert_eq!(state.selected, 0);
2293        assert!(!state.open);
2294        assert_eq!(state.placeholder, "");
2295        assert_eq!(state.selected_item(), None);
2296        assert_eq!(state.cursor(), 0);
2297    }
2298
2299    #[test]
2300    fn radio_state_default_values() {
2301        let state = RadioState::default();
2302        assert!(state.items.is_empty());
2303        assert_eq!(state.selected, 0);
2304        assert_eq!(state.selected_item(), None);
2305    }
2306
2307    #[test]
2308    fn text_input_state_default_uses_new() {
2309        let state = TextInputState::default();
2310        assert_eq!(state.value, "");
2311        assert_eq!(state.cursor, 0);
2312        assert_eq!(state.placeholder, "");
2313        assert_eq!(state.max_length, None);
2314        assert_eq!(state.validation_error, None);
2315        assert!(!state.masked);
2316    }
2317
2318    #[test]
2319    fn tabs_state_new_sets_labels() {
2320        let state = TabsState::new(vec!["a", "b"]);
2321        assert_eq!(state.labels, vec!["a".to_string(), "b".to_string()]);
2322        assert_eq!(state.selected, 0);
2323        assert_eq!(state.selected_label(), Some("a"));
2324    }
2325
2326    #[test]
2327    fn list_state_new_selected_item_points_to_first_item() {
2328        let state = ListState::new(vec!["alpha", "beta"]);
2329        assert_eq!(state.items, vec!["alpha".to_string(), "beta".to_string()]);
2330        assert_eq!(state.selected, 0);
2331        assert_eq!(state.visible_indices(), &[0, 1]);
2332        assert_eq!(state.selected_item(), Some("alpha"));
2333    }
2334
2335    #[test]
2336    fn select_state_placeholder_builder_sets_value() {
2337        let state = SelectState::new(vec!["one", "two"]).placeholder("Pick one");
2338        assert_eq!(state.items, vec!["one".to_string(), "two".to_string()]);
2339        assert_eq!(state.placeholder, "Pick one");
2340        assert_eq!(state.selected_item(), Some("one"));
2341    }
2342
2343    #[test]
2344    fn radio_state_new_sets_items_and_selection() {
2345        let state = RadioState::new(vec!["red", "green"]);
2346        assert_eq!(state.items, vec!["red".to_string(), "green".to_string()]);
2347        assert_eq!(state.selected, 0);
2348        assert_eq!(state.selected_item(), Some("red"));
2349    }
2350
2351    #[test]
2352    fn table_state_new_sets_sort_ascending_true() {
2353        let state = TableState::new(vec!["Name"], vec![vec!["Alice"], vec!["Bob"]]);
2354        assert_eq!(state.headers, vec!["Name".to_string()]);
2355        assert_eq!(state.rows.len(), 2);
2356        assert!(state.sort_ascending);
2357        assert_eq!(state.sort_column, None);
2358        assert!(!state.zebra);
2359        assert_eq!(state.visible_indices(), &[0, 1]);
2360    }
2361
2362    #[test]
2363    fn command_palette_fuzzy_score_matches_gapped_pattern() {
2364        assert!(CommandPaletteState::fuzzy_score("sf", "Save File").is_some());
2365        assert!(CommandPaletteState::fuzzy_score("cmd", "Command Palette").is_some());
2366        assert_eq!(CommandPaletteState::fuzzy_score("xyz", "Save File"), None);
2367    }
2368
2369    #[test]
2370    fn command_palette_filtered_indices_uses_fuzzy_and_sorts() {
2371        let mut state = CommandPaletteState::new(vec![
2372            PaletteCommand::new("Save File", "Write buffer"),
2373            PaletteCommand::new("Search Files", "Find in workspace"),
2374            PaletteCommand::new("Quit", "Exit app"),
2375        ]);
2376
2377        state.input = "sf".to_string();
2378        let filtered = state.filtered_indices();
2379        assert_eq!(filtered, vec![0, 1]);
2380
2381        state.input = "buffer".to_string();
2382        let filtered = state.filtered_indices();
2383        assert_eq!(filtered, vec![0]);
2384    }
2385
2386    #[test]
2387    fn screen_state_push_pop_tracks_current_screen() {
2388        let mut screens = ScreenState::new("home");
2389        assert_eq!(screens.current(), "home");
2390        assert_eq!(screens.depth(), 1);
2391        assert!(!screens.can_pop());
2392
2393        screens.push("settings");
2394        assert_eq!(screens.current(), "settings");
2395        assert_eq!(screens.depth(), 2);
2396        assert!(screens.can_pop());
2397
2398        screens.push("profile");
2399        assert_eq!(screens.current(), "profile");
2400        assert_eq!(screens.depth(), 3);
2401
2402        screens.pop();
2403        assert_eq!(screens.current(), "settings");
2404        assert_eq!(screens.depth(), 2);
2405    }
2406
2407    #[test]
2408    fn screen_state_pop_never_removes_root() {
2409        let mut screens = ScreenState::new("home");
2410        screens.push("settings");
2411        screens.pop();
2412        screens.pop();
2413
2414        assert_eq!(screens.current(), "home");
2415        assert_eq!(screens.depth(), 1);
2416        assert!(!screens.can_pop());
2417    }
2418
2419    #[test]
2420    fn screen_state_reset_keeps_only_root() {
2421        let mut screens = ScreenState::new("home");
2422        screens.push("settings");
2423        screens.push("profile");
2424        assert_eq!(screens.current(), "profile");
2425
2426        screens.reset();
2427        assert_eq!(screens.current(), "home");
2428        assert_eq!(screens.depth(), 1);
2429        assert!(!screens.can_pop());
2430    }
2431
2432    #[test]
2433    fn calendar_days_in_month_handles_leap_years() {
2434        assert_eq!(CalendarState::days_in_month(2024, 2), 29);
2435        assert_eq!(CalendarState::days_in_month(2023, 2), 28);
2436        assert_eq!(CalendarState::days_in_month(2024, 1), 31);
2437        assert_eq!(CalendarState::days_in_month(2024, 4), 30);
2438    }
2439
2440    #[test]
2441    fn calendar_first_weekday_known_dates() {
2442        assert_eq!(CalendarState::first_weekday(2024, 1), 0);
2443        assert_eq!(CalendarState::first_weekday(2023, 10), 6);
2444    }
2445
2446    #[test]
2447    fn calendar_prev_next_month_handles_year_boundary() {
2448        let mut state = CalendarState::from_ym(2024, 12);
2449        state.prev_month();
2450        assert_eq!((state.year, state.month), (2024, 11));
2451
2452        let mut state = CalendarState::from_ym(2024, 1);
2453        state.prev_month();
2454        assert_eq!((state.year, state.month), (2023, 12));
2455
2456        state.next_month();
2457        assert_eq!((state.year, state.month), (2024, 1));
2458    }
2459}