Skip to main content

slt/
widgets.rs

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