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