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