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