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 unicode_width::UnicodeWidthStr;
9
10type FormValidator = fn(&str) -> Result<(), String>;
11
12/// State for a single-line text input widget.
13///
14/// Pass a mutable reference to `Context::text_input` each frame. The widget
15/// handles all keyboard events when focused.
16///
17/// # Example
18///
19/// ```no_run
20/// # use slt::widgets::TextInputState;
21/// # slt::run(|ui: &mut slt::Context| {
22/// let mut input = TextInputState::with_placeholder("Type here...");
23/// ui.text_input(&mut input);
24/// println!("{}", input.value);
25/// # });
26/// ```
27pub struct TextInputState {
28    /// The current input text.
29    pub value: String,
30    /// Cursor position as a character index into `value`.
31    pub cursor: usize,
32    /// Placeholder text shown when `value` is empty.
33    pub placeholder: String,
34    /// Maximum character count. Input is rejected beyond this limit.
35    pub max_length: Option<usize>,
36    /// The most recent validation error message, if any.
37    pub validation_error: Option<String>,
38    /// When `true`, input is displayed as `•` characters (for passwords).
39    pub masked: bool,
40}
41
42impl TextInputState {
43    /// Create an empty text input state.
44    pub fn new() -> Self {
45        Self {
46            value: String::new(),
47            cursor: 0,
48            placeholder: String::new(),
49            max_length: None,
50            validation_error: None,
51            masked: false,
52        }
53    }
54
55    /// Create a text input with placeholder text shown when the value is empty.
56    pub fn with_placeholder(p: impl Into<String>) -> Self {
57        Self {
58            placeholder: p.into(),
59            ..Self::new()
60        }
61    }
62
63    /// Set the maximum allowed character count.
64    pub fn max_length(mut self, len: usize) -> Self {
65        self.max_length = Some(len);
66        self
67    }
68
69    /// Validate the current value and store the latest error message.
70    ///
71    /// Sets [`TextInputState::validation_error`] to `None` when validation
72    /// succeeds, or to `Some(error)` when validation fails.
73    pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
74        self.validation_error = validator(&self.value).err();
75    }
76}
77
78impl Default for TextInputState {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84/// A single form field with label and validation.
85pub struct FormField {
86    /// Field label shown above the input.
87    pub label: String,
88    /// Text input state for this field.
89    pub input: TextInputState,
90    /// Validation error shown below the input when present.
91    pub error: Option<String>,
92}
93
94impl FormField {
95    /// Create a new form field with the given label.
96    pub fn new(label: impl Into<String>) -> Self {
97        Self {
98            label: label.into(),
99            input: TextInputState::new(),
100            error: None,
101        }
102    }
103
104    /// Set placeholder text for this field's input.
105    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
106        self.input.placeholder = p.into();
107        self
108    }
109}
110
111/// State for a form with multiple fields.
112pub struct FormState {
113    /// Ordered list of form fields.
114    pub fields: Vec<FormField>,
115    /// Whether the form has been successfully submitted.
116    pub submitted: bool,
117}
118
119impl FormState {
120    /// Create an empty form state.
121    pub fn new() -> Self {
122        Self {
123            fields: Vec::new(),
124            submitted: false,
125        }
126    }
127
128    /// Add a field and return the updated form for chaining.
129    pub fn field(mut self, field: FormField) -> Self {
130        self.fields.push(field);
131        self
132    }
133
134    /// Validate all fields with the given validators.
135    ///
136    /// Returns `true` when all validations pass.
137    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
138        let mut all_valid = true;
139        for (i, field) in self.fields.iter_mut().enumerate() {
140            if let Some(validator) = validators.get(i) {
141                match validator(&field.input.value) {
142                    Ok(()) => field.error = None,
143                    Err(msg) => {
144                        field.error = Some(msg);
145                        all_valid = false;
146                    }
147                }
148            }
149        }
150        all_valid
151    }
152
153    /// Get field value by index.
154    pub fn value(&self, index: usize) -> &str {
155        self.fields
156            .get(index)
157            .map(|f| f.input.value.as_str())
158            .unwrap_or("")
159    }
160}
161
162impl Default for FormState {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168/// State for toast notification display.
169///
170/// Add messages with [`ToastState::info`], [`ToastState::success`],
171/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
172/// `Context::toast` each frame. Expired messages are removed automatically.
173pub struct ToastState {
174    /// Active toast messages, ordered oldest-first.
175    pub messages: Vec<ToastMessage>,
176}
177
178/// A single toast notification message.
179pub struct ToastMessage {
180    /// The text content of the notification.
181    pub text: String,
182    /// Severity level, used to choose the display color.
183    pub level: ToastLevel,
184    /// The tick at which this message was created.
185    pub created_tick: u64,
186    /// How many ticks the message remains visible.
187    pub duration_ticks: u64,
188}
189
190/// Severity level for a [`ToastMessage`].
191pub enum ToastLevel {
192    /// Informational message (primary color).
193    Info,
194    /// Success message (success color).
195    Success,
196    /// Warning message (warning color).
197    Warning,
198    /// Error message (error color).
199    Error,
200}
201
202impl ToastState {
203    /// Create an empty toast state with no messages.
204    pub fn new() -> Self {
205        Self {
206            messages: Vec::new(),
207        }
208    }
209
210    /// Push an informational toast visible for 30 ticks.
211    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
212        self.push(text, ToastLevel::Info, tick, 30);
213    }
214
215    /// Push a success toast visible for 30 ticks.
216    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
217        self.push(text, ToastLevel::Success, tick, 30);
218    }
219
220    /// Push a warning toast visible for 50 ticks.
221    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
222        self.push(text, ToastLevel::Warning, tick, 50);
223    }
224
225    /// Push an error toast visible for 80 ticks.
226    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
227        self.push(text, ToastLevel::Error, tick, 80);
228    }
229
230    /// Push a toast with a custom level and duration.
231    pub fn push(
232        &mut self,
233        text: impl Into<String>,
234        level: ToastLevel,
235        tick: u64,
236        duration_ticks: u64,
237    ) {
238        self.messages.push(ToastMessage {
239            text: text.into(),
240            level,
241            created_tick: tick,
242            duration_ticks,
243        });
244    }
245
246    /// Remove all messages whose display duration has elapsed.
247    ///
248    /// Called automatically by `Context::toast` before rendering.
249    pub fn cleanup(&mut self, current_tick: u64) {
250        self.messages.retain(|message| {
251            current_tick < message.created_tick.saturating_add(message.duration_ticks)
252        });
253    }
254}
255
256impl Default for ToastState {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262/// State for a multi-line text area widget.
263///
264/// Pass a mutable reference to `Context::textarea` each frame along with the
265/// number of visible rows. The widget handles all keyboard events when focused.
266pub struct TextareaState {
267    /// The lines of text, one entry per line.
268    pub lines: Vec<String>,
269    /// Row index of the cursor (0-based, logical line).
270    pub cursor_row: usize,
271    /// Column index of the cursor within the current row (character index).
272    pub cursor_col: usize,
273    /// Maximum total character count across all lines.
274    pub max_length: Option<usize>,
275    /// When set, lines longer than this display-column width are soft-wrapped.
276    pub wrap_width: Option<u32>,
277    /// First visible visual line (managed internally by `textarea()`).
278    pub scroll_offset: usize,
279}
280
281impl TextareaState {
282    /// Create an empty text area state with one blank line.
283    pub fn new() -> Self {
284        Self {
285            lines: vec![String::new()],
286            cursor_row: 0,
287            cursor_col: 0,
288            max_length: None,
289            wrap_width: None,
290            scroll_offset: 0,
291        }
292    }
293
294    /// Return all lines joined with newline characters.
295    pub fn value(&self) -> String {
296        self.lines.join("\n")
297    }
298
299    /// Replace the content with the given text, splitting on newlines.
300    ///
301    /// Resets the cursor to the beginning of the first line.
302    pub fn set_value(&mut self, text: impl Into<String>) {
303        let value = text.into();
304        self.lines = value.split('\n').map(str::to_string).collect();
305        if self.lines.is_empty() {
306            self.lines.push(String::new());
307        }
308        self.cursor_row = 0;
309        self.cursor_col = 0;
310        self.scroll_offset = 0;
311    }
312
313    /// Set the maximum allowed total character count.
314    pub fn max_length(mut self, len: usize) -> Self {
315        self.max_length = Some(len);
316        self
317    }
318
319    /// Enable soft word-wrap at the given display-column width.
320    pub fn word_wrap(mut self, width: u32) -> Self {
321        self.wrap_width = Some(width);
322        self
323    }
324}
325
326impl Default for TextareaState {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332/// State for an animated spinner widget.
333///
334/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
335/// `Context::spinner` each frame. The frame advances automatically with the
336/// tick counter.
337pub struct SpinnerState {
338    chars: Vec<char>,
339}
340
341impl SpinnerState {
342    /// Create a dots-style spinner using braille characters.
343    ///
344    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
345    pub fn dots() -> Self {
346        Self {
347            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
348        }
349    }
350
351    /// Create a line-style spinner using ASCII characters.
352    ///
353    /// Cycles through: `| / - \`
354    pub fn line() -> Self {
355        Self {
356            chars: vec!['|', '/', '-', '\\'],
357        }
358    }
359
360    /// Return the spinner character for the given tick.
361    pub fn frame(&self, tick: u64) -> char {
362        if self.chars.is_empty() {
363            return ' ';
364        }
365        self.chars[tick as usize % self.chars.len()]
366    }
367}
368
369impl Default for SpinnerState {
370    fn default() -> Self {
371        Self::dots()
372    }
373}
374
375/// State for a selectable list widget.
376///
377/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
378/// keys (and `k`/`j`) move the selection when the widget is focused.
379pub struct ListState {
380    /// The list items as display strings.
381    pub items: Vec<String>,
382    /// Index of the currently selected item.
383    pub selected: usize,
384    /// Case-insensitive substring filter applied to list items.
385    pub filter: String,
386    view_indices: Vec<usize>,
387}
388
389impl ListState {
390    /// Create a list with the given items. The first item is selected initially.
391    pub fn new(items: Vec<impl Into<String>>) -> Self {
392        let len = items.len();
393        Self {
394            items: items.into_iter().map(Into::into).collect(),
395            selected: 0,
396            filter: String::new(),
397            view_indices: (0..len).collect(),
398        }
399    }
400
401    /// Set the filter string. Multiple space-separated tokens are AND'd
402    /// together — all tokens must match across any cell in the same row.
403    /// Empty string disables filtering.
404    pub fn set_filter(&mut self, filter: impl Into<String>) {
405        self.filter = filter.into();
406        self.rebuild_view();
407    }
408
409    /// Returns indices of items visible after filtering.
410    pub fn visible_indices(&self) -> &[usize] {
411        &self.view_indices
412    }
413
414    /// Get the currently selected item text, or `None` if the list is empty.
415    pub fn selected_item(&self) -> Option<&str> {
416        let data_idx = *self.view_indices.get(self.selected)?;
417        self.items.get(data_idx).map(String::as_str)
418    }
419
420    fn rebuild_view(&mut self) {
421        let tokens: Vec<String> = self
422            .filter
423            .split_whitespace()
424            .map(|t| t.to_lowercase())
425            .collect();
426        self.view_indices = if tokens.is_empty() {
427            (0..self.items.len()).collect()
428        } else {
429            (0..self.items.len())
430                .filter(|&i| {
431                    tokens
432                        .iter()
433                        .all(|token| self.items[i].to_lowercase().contains(token.as_str()))
434                })
435                .collect()
436        };
437        if !self.view_indices.is_empty() && self.selected >= self.view_indices.len() {
438            self.selected = self.view_indices.len() - 1;
439        }
440    }
441}
442
443/// State for a tab navigation widget.
444///
445/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
446/// keys cycle through tabs when the widget is focused.
447pub struct TabsState {
448    /// The tab labels displayed in the bar.
449    pub labels: Vec<String>,
450    /// Index of the currently active tab.
451    pub selected: usize,
452}
453
454impl TabsState {
455    /// Create tabs with the given labels. The first tab is active initially.
456    pub fn new(labels: Vec<impl Into<String>>) -> Self {
457        Self {
458            labels: labels.into_iter().map(Into::into).collect(),
459            selected: 0,
460        }
461    }
462
463    /// Get the currently selected tab label, or `None` if there are no tabs.
464    pub fn selected_label(&self) -> Option<&str> {
465        self.labels.get(self.selected).map(String::as_str)
466    }
467}
468
469/// State for a data table widget.
470///
471/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
472/// keys move the row selection when the widget is focused. Column widths are
473/// computed automatically from header and cell content.
474pub struct TableState {
475    /// Column header labels.
476    pub headers: Vec<String>,
477    /// Table rows, each a `Vec` of cell strings.
478    pub rows: Vec<Vec<String>>,
479    /// Index of the currently selected row.
480    pub selected: usize,
481    column_widths: Vec<u32>,
482    dirty: bool,
483    /// Sorted column index (`None` means no sorting).
484    pub sort_column: Option<usize>,
485    /// Sort direction (`true` for ascending).
486    pub sort_ascending: bool,
487    /// Case-insensitive substring filter applied across all cells.
488    pub filter: String,
489    /// Current page (0-based) when pagination is enabled.
490    pub page: usize,
491    /// Rows per page (`0` disables pagination).
492    pub page_size: usize,
493    view_indices: Vec<usize>,
494}
495
496impl TableState {
497    /// Create a table with headers and rows. Column widths are computed immediately.
498    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
499        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
500        let rows: Vec<Vec<String>> = rows
501            .into_iter()
502            .map(|r| r.into_iter().map(Into::into).collect())
503            .collect();
504        let mut state = Self {
505            headers,
506            rows,
507            selected: 0,
508            column_widths: Vec::new(),
509            dirty: true,
510            sort_column: None,
511            sort_ascending: true,
512            filter: String::new(),
513            page: 0,
514            page_size: 0,
515            view_indices: Vec::new(),
516        };
517        state.rebuild_view();
518        state.recompute_widths();
519        state
520    }
521
522    /// Replace all rows, preserving the selection index if possible.
523    ///
524    /// If the current selection is beyond the new row count, it is clamped to
525    /// the last row.
526    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
527        self.rows = rows
528            .into_iter()
529            .map(|r| r.into_iter().map(Into::into).collect())
530            .collect();
531        self.rebuild_view();
532    }
533
534    /// Sort by a specific column index. If already sorted by this column, toggles direction.
535    pub fn toggle_sort(&mut self, column: usize) {
536        if self.sort_column == Some(column) {
537            self.sort_ascending = !self.sort_ascending;
538        } else {
539            self.sort_column = Some(column);
540            self.sort_ascending = true;
541        }
542        self.rebuild_view();
543    }
544
545    /// Sort by column without toggling (always sets to ascending first).
546    pub fn sort_by(&mut self, column: usize) {
547        self.sort_column = Some(column);
548        self.sort_ascending = true;
549        self.rebuild_view();
550    }
551
552    /// Set the filter string. Multiple space-separated tokens are AND'd
553    /// together — all tokens must match across any cell in the same row.
554    /// Empty string disables filtering.
555    pub fn set_filter(&mut self, filter: impl Into<String>) {
556        self.filter = filter.into();
557        self.page = 0;
558        self.rebuild_view();
559    }
560
561    /// Clear sorting.
562    pub fn clear_sort(&mut self) {
563        self.sort_column = None;
564        self.sort_ascending = true;
565        self.rebuild_view();
566    }
567
568    /// Move to the next page. Does nothing if already on the last page.
569    pub fn next_page(&mut self) {
570        if self.page_size == 0 {
571            return;
572        }
573        let last_page = self.total_pages().saturating_sub(1);
574        self.page = (self.page + 1).min(last_page);
575    }
576
577    /// Move to the previous page. Does nothing if already on page 0.
578    pub fn prev_page(&mut self) {
579        self.page = self.page.saturating_sub(1);
580    }
581
582    /// Total number of pages based on filtered rows and page_size. Returns 1 if page_size is 0.
583    pub fn total_pages(&self) -> usize {
584        if self.page_size == 0 {
585            return 1;
586        }
587
588        let len = self.view_indices.len();
589        if len == 0 {
590            1
591        } else {
592            len.div_ceil(self.page_size)
593        }
594    }
595
596    /// Get the visible row indices after filtering and sorting (used internally by table()).
597    pub fn visible_indices(&self) -> &[usize] {
598        &self.view_indices
599    }
600
601    /// Get the currently selected row data, or `None` if the table is empty.
602    pub fn selected_row(&self) -> Option<&[String]> {
603        if self.view_indices.is_empty() {
604            return None;
605        }
606        let data_idx = self.view_indices.get(self.selected)?;
607        self.rows.get(*data_idx).map(|r| r.as_slice())
608    }
609
610    /// Recompute view_indices based on current sort + filter settings.
611    fn rebuild_view(&mut self) {
612        let mut indices: Vec<usize> = (0..self.rows.len()).collect();
613
614        let tokens: Vec<String> = self
615            .filter
616            .split_whitespace()
617            .map(|t| t.to_lowercase())
618            .collect();
619        if !tokens.is_empty() {
620            indices.retain(|&idx| {
621                let row = match self.rows.get(idx) {
622                    Some(r) => r,
623                    None => return false,
624                };
625                tokens.iter().all(|token| {
626                    row.iter()
627                        .any(|cell| cell.to_lowercase().contains(token.as_str()))
628                })
629            });
630        }
631
632        if let Some(column) = self.sort_column {
633            indices.sort_by(|a, b| {
634                let left = self
635                    .rows
636                    .get(*a)
637                    .and_then(|row| row.get(column))
638                    .map(String::as_str)
639                    .unwrap_or("");
640                let right = self
641                    .rows
642                    .get(*b)
643                    .and_then(|row| row.get(column))
644                    .map(String::as_str)
645                    .unwrap_or("");
646
647                match (left.parse::<f64>(), right.parse::<f64>()) {
648                    (Ok(l), Ok(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
649                    _ => left.to_lowercase().cmp(&right.to_lowercase()),
650                }
651            });
652
653            if !self.sort_ascending {
654                indices.reverse();
655            }
656        }
657
658        self.view_indices = indices;
659
660        if self.page_size > 0 {
661            self.page = self.page.min(self.total_pages().saturating_sub(1));
662        } else {
663            self.page = 0;
664        }
665
666        self.selected = self.selected.min(self.view_indices.len().saturating_sub(1));
667        self.dirty = true;
668    }
669
670    pub(crate) fn recompute_widths(&mut self) {
671        let col_count = self.headers.len();
672        self.column_widths = vec![0u32; col_count];
673        for (i, header) in self.headers.iter().enumerate() {
674            let mut width = UnicodeWidthStr::width(header.as_str()) as u32;
675            if self.sort_column == Some(i) {
676                width += 2;
677            }
678            self.column_widths[i] = width;
679        }
680        for row in &self.rows {
681            for (i, cell) in row.iter().enumerate() {
682                if i < col_count {
683                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
684                    self.column_widths[i] = self.column_widths[i].max(w);
685                }
686            }
687        }
688        self.dirty = false;
689    }
690
691    pub(crate) fn column_widths(&self) -> &[u32] {
692        &self.column_widths
693    }
694
695    pub(crate) fn is_dirty(&self) -> bool {
696        self.dirty
697    }
698}
699
700/// State for a scrollable container.
701///
702/// Pass a mutable reference to `Context::scrollable` each frame. The context
703/// updates `offset` and the internal bounds automatically based on mouse wheel
704/// and drag events.
705pub struct ScrollState {
706    /// Current vertical scroll offset in rows.
707    pub offset: usize,
708    content_height: u32,
709    viewport_height: u32,
710}
711
712impl ScrollState {
713    /// Create scroll state starting at offset 0.
714    pub fn new() -> Self {
715        Self {
716            offset: 0,
717            content_height: 0,
718            viewport_height: 0,
719        }
720    }
721
722    /// Check if scrolling upward is possible (offset is greater than 0).
723    pub fn can_scroll_up(&self) -> bool {
724        self.offset > 0
725    }
726
727    /// Check if scrolling downward is possible (content extends below the viewport).
728    pub fn can_scroll_down(&self) -> bool {
729        (self.offset as u32) + self.viewport_height < self.content_height
730    }
731
732    /// Get the total content height in rows.
733    pub fn content_height(&self) -> u32 {
734        self.content_height
735    }
736
737    /// Get the viewport height in rows.
738    pub fn viewport_height(&self) -> u32 {
739        self.viewport_height
740    }
741
742    /// Get the scroll progress as a ratio in [0.0, 1.0].
743    pub fn progress(&self) -> f32 {
744        let max = self.content_height.saturating_sub(self.viewport_height);
745        if max == 0 {
746            0.0
747        } else {
748            self.offset as f32 / max as f32
749        }
750    }
751
752    /// Scroll up by the given number of rows, clamped to 0.
753    pub fn scroll_up(&mut self, amount: usize) {
754        self.offset = self.offset.saturating_sub(amount);
755    }
756
757    /// Scroll down by the given number of rows, clamped to the maximum offset.
758    pub fn scroll_down(&mut self, amount: usize) {
759        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
760        self.offset = (self.offset + amount).min(max_offset);
761    }
762
763    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
764        self.content_height = content_height;
765        self.viewport_height = viewport_height;
766    }
767}
768
769impl Default for ScrollState {
770    fn default() -> Self {
771        Self::new()
772    }
773}
774
775/// Visual variant for buttons.
776///
777/// Controls the color scheme used when rendering a button. Pass to
778/// [`crate::Context::button_with`] to create styled button variants.
779///
780/// - `Default` — theme text color, primary when focused (same as `button()`)
781/// - `Primary` — primary color background with contrasting text
782/// - `Danger` — error/red color for destructive actions
783/// - `Outline` — bordered appearance without fill
784#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
785pub enum ButtonVariant {
786    /// Standard button style.
787    #[default]
788    Default,
789    /// Filled button with primary background color.
790    Primary,
791    /// Filled button with error/danger background color.
792    Danger,
793    /// Bordered button without background fill.
794    Outline,
795}
796
797// ── Select / Dropdown ─────────────────────────────────────────────────
798
799/// State for a dropdown select widget.
800///
801/// Renders as a single-line button showing the selected option. When activated,
802/// expands into a vertical list overlay for picking an option.
803pub struct SelectState {
804    pub items: Vec<String>,
805    pub selected: usize,
806    pub open: bool,
807    pub placeholder: String,
808    cursor: usize,
809}
810
811impl SelectState {
812    pub fn new(items: Vec<impl Into<String>>) -> Self {
813        Self {
814            items: items.into_iter().map(Into::into).collect(),
815            selected: 0,
816            open: false,
817            placeholder: String::new(),
818            cursor: 0,
819        }
820    }
821
822    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
823        self.placeholder = p.into();
824        self
825    }
826
827    pub fn selected_item(&self) -> Option<&str> {
828        self.items.get(self.selected).map(String::as_str)
829    }
830
831    pub(crate) fn cursor(&self) -> usize {
832        self.cursor
833    }
834
835    pub(crate) fn set_cursor(&mut self, c: usize) {
836        self.cursor = c;
837    }
838}
839
840// ── Radio ─────────────────────────────────────────────────────────────
841
842/// State for a radio button group.
843///
844/// Renders a vertical list of mutually-exclusive options with `●`/`○` markers.
845pub struct RadioState {
846    pub items: Vec<String>,
847    pub selected: usize,
848}
849
850impl RadioState {
851    pub fn new(items: Vec<impl Into<String>>) -> Self {
852        Self {
853            items: items.into_iter().map(Into::into).collect(),
854            selected: 0,
855        }
856    }
857
858    pub fn selected_item(&self) -> Option<&str> {
859        self.items.get(self.selected).map(String::as_str)
860    }
861}
862
863// ── Multi-Select ──────────────────────────────────────────────────────
864
865/// State for a multi-select list.
866///
867/// Like [`ListState`] but allows toggling multiple items with Space.
868pub struct MultiSelectState {
869    pub items: Vec<String>,
870    pub cursor: usize,
871    pub selected: HashSet<usize>,
872}
873
874impl MultiSelectState {
875    pub fn new(items: Vec<impl Into<String>>) -> Self {
876        Self {
877            items: items.into_iter().map(Into::into).collect(),
878            cursor: 0,
879            selected: HashSet::new(),
880        }
881    }
882
883    pub fn selected_items(&self) -> Vec<&str> {
884        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
885        indices.sort();
886        indices
887            .iter()
888            .filter_map(|&i| self.items.get(i).map(String::as_str))
889            .collect()
890    }
891
892    pub fn toggle(&mut self, index: usize) {
893        if self.selected.contains(&index) {
894            self.selected.remove(&index);
895        } else {
896            self.selected.insert(index);
897        }
898    }
899}
900
901// ── Tree ──────────────────────────────────────────────────────────────
902
903/// A node in a tree view.
904pub struct TreeNode {
905    pub label: String,
906    pub children: Vec<TreeNode>,
907    pub expanded: bool,
908}
909
910impl TreeNode {
911    pub fn new(label: impl Into<String>) -> Self {
912        Self {
913            label: label.into(),
914            children: Vec::new(),
915            expanded: false,
916        }
917    }
918
919    pub fn expanded(mut self) -> Self {
920        self.expanded = true;
921        self
922    }
923
924    pub fn children(mut self, children: Vec<TreeNode>) -> Self {
925        self.children = children;
926        self
927    }
928
929    pub fn is_leaf(&self) -> bool {
930        self.children.is_empty()
931    }
932
933    fn flatten(&self, depth: usize, out: &mut Vec<FlatTreeEntry>) {
934        out.push(FlatTreeEntry {
935            depth,
936            label: self.label.clone(),
937            is_leaf: self.is_leaf(),
938            expanded: self.expanded,
939        });
940        if self.expanded {
941            for child in &self.children {
942                child.flatten(depth + 1, out);
943            }
944        }
945    }
946}
947
948pub(crate) struct FlatTreeEntry {
949    pub depth: usize,
950    pub label: String,
951    pub is_leaf: bool,
952    pub expanded: bool,
953}
954
955/// State for a hierarchical tree view widget.
956pub struct TreeState {
957    pub nodes: Vec<TreeNode>,
958    pub selected: usize,
959}
960
961impl TreeState {
962    pub fn new(nodes: Vec<TreeNode>) -> Self {
963        Self { nodes, selected: 0 }
964    }
965
966    pub(crate) fn flatten(&self) -> Vec<FlatTreeEntry> {
967        let mut entries = Vec::new();
968        for node in &self.nodes {
969            node.flatten(0, &mut entries);
970        }
971        entries
972    }
973
974    pub(crate) fn toggle_at(&mut self, flat_index: usize) {
975        let mut counter = 0usize;
976        Self::toggle_recursive(&mut self.nodes, flat_index, &mut counter);
977    }
978
979    fn toggle_recursive(nodes: &mut [TreeNode], target: usize, counter: &mut usize) -> bool {
980        for node in nodes.iter_mut() {
981            if *counter == target {
982                if !node.is_leaf() {
983                    node.expanded = !node.expanded;
984                }
985                return true;
986            }
987            *counter += 1;
988            if node.expanded && Self::toggle_recursive(&mut node.children, target, counter) {
989                return true;
990            }
991        }
992        false
993    }
994}
995
996// ── Command Palette ───────────────────────────────────────────────────
997
998/// A single command entry in the palette.
999pub struct PaletteCommand {
1000    pub label: String,
1001    pub description: String,
1002    pub shortcut: Option<String>,
1003}
1004
1005impl PaletteCommand {
1006    pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
1007        Self {
1008            label: label.into(),
1009            description: description.into(),
1010            shortcut: None,
1011        }
1012    }
1013
1014    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
1015        self.shortcut = Some(s.into());
1016        self
1017    }
1018}
1019
1020/// State for a command palette overlay.
1021///
1022/// Renders as a modal with a search input and filtered command list.
1023pub struct CommandPaletteState {
1024    pub commands: Vec<PaletteCommand>,
1025    pub input: String,
1026    pub cursor: usize,
1027    pub open: bool,
1028    selected: usize,
1029}
1030
1031impl CommandPaletteState {
1032    pub fn new(commands: Vec<PaletteCommand>) -> Self {
1033        Self {
1034            commands,
1035            input: String::new(),
1036            cursor: 0,
1037            open: false,
1038            selected: 0,
1039        }
1040    }
1041
1042    pub fn toggle(&mut self) {
1043        self.open = !self.open;
1044        if self.open {
1045            self.input.clear();
1046            self.cursor = 0;
1047            self.selected = 0;
1048        }
1049    }
1050
1051    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
1052        let tokens: Vec<String> = self
1053            .input
1054            .split_whitespace()
1055            .map(|t| t.to_lowercase())
1056            .collect();
1057        if tokens.is_empty() {
1058            return (0..self.commands.len()).collect();
1059        }
1060        self.commands
1061            .iter()
1062            .enumerate()
1063            .filter(|(_, cmd)| {
1064                let label = cmd.label.to_lowercase();
1065                let desc = cmd.description.to_lowercase();
1066                tokens
1067                    .iter()
1068                    .all(|token| label.contains(token.as_str()) || desc.contains(token.as_str()))
1069            })
1070            .map(|(i, _)| i)
1071            .collect()
1072    }
1073
1074    pub(crate) fn selected(&self) -> usize {
1075        self.selected
1076    }
1077
1078    pub(crate) fn set_selected(&mut self, s: usize) {
1079        self.selected = s;
1080    }
1081}
1082
1083/// State for a streaming text display.
1084///
1085/// Accumulates text chunks as they arrive from an LLM stream.
1086/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
1087pub struct StreamingTextState {
1088    /// The accumulated text content.
1089    pub content: String,
1090    /// Whether the stream is still receiving data.
1091    pub streaming: bool,
1092    /// Cursor blink state (for the typing indicator).
1093    pub(crate) cursor_visible: bool,
1094    pub(crate) cursor_tick: u64,
1095}
1096
1097impl StreamingTextState {
1098    /// Create a new empty streaming text state.
1099    pub fn new() -> Self {
1100        Self {
1101            content: String::new(),
1102            streaming: false,
1103            cursor_visible: true,
1104            cursor_tick: 0,
1105        }
1106    }
1107
1108    /// Append a chunk of text (e.g., from an LLM stream delta).
1109    pub fn push(&mut self, chunk: &str) {
1110        self.content.push_str(chunk);
1111    }
1112
1113    /// Mark the stream as complete (hides the typing cursor).
1114    pub fn finish(&mut self) {
1115        self.streaming = false;
1116    }
1117
1118    /// Start a new streaming session, clearing previous content.
1119    pub fn start(&mut self) {
1120        self.content.clear();
1121        self.streaming = true;
1122        self.cursor_visible = true;
1123        self.cursor_tick = 0;
1124    }
1125
1126    /// Clear all content and reset state.
1127    pub fn clear(&mut self) {
1128        self.content.clear();
1129        self.streaming = false;
1130        self.cursor_visible = true;
1131        self.cursor_tick = 0;
1132    }
1133}
1134
1135impl Default for StreamingTextState {
1136    fn default() -> Self {
1137        Self::new()
1138    }
1139}
1140
1141/// Approval state for a tool call.
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143pub enum ApprovalAction {
1144    /// No action taken yet.
1145    Pending,
1146    /// User approved the tool call.
1147    Approved,
1148    /// User rejected the tool call.
1149    Rejected,
1150}
1151
1152/// State for a tool approval widget.
1153///
1154/// Displays a tool call with approve/reject buttons for human-in-the-loop
1155/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
1156/// each frame.
1157pub struct ToolApprovalState {
1158    /// The name of the tool being invoked.
1159    pub tool_name: String,
1160    /// A human-readable description of what the tool will do.
1161    pub description: String,
1162    /// The current approval status.
1163    pub action: ApprovalAction,
1164}
1165
1166impl ToolApprovalState {
1167    /// Create a new tool approval prompt.
1168    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
1169        Self {
1170            tool_name: tool_name.into(),
1171            description: description.into(),
1172            action: ApprovalAction::Pending,
1173        }
1174    }
1175
1176    /// Reset to pending state.
1177    pub fn reset(&mut self) {
1178        self.action = ApprovalAction::Pending;
1179    }
1180}
1181
1182/// Item in a context bar showing active context sources.
1183#[derive(Debug, Clone)]
1184pub struct ContextItem {
1185    /// Display label for this context source.
1186    pub label: String,
1187    /// Token count or size indicator.
1188    pub tokens: usize,
1189}
1190
1191impl ContextItem {
1192    /// Create a new context item with a label and token count.
1193    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
1194        Self {
1195            label: label.into(),
1196            tokens,
1197        }
1198    }
1199}