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