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 unicode_width::UnicodeWidthStr;
8
9type FormValidator = fn(&str) -> Result<(), String>;
10
11/// State for a single-line text input widget.
12///
13/// Pass a mutable reference to `Context::text_input` each frame. The widget
14/// handles all keyboard events when focused.
15///
16/// # Example
17///
18/// ```no_run
19/// # use slt::widgets::TextInputState;
20/// # slt::run(|ui: &mut slt::Context| {
21/// let mut input = TextInputState::with_placeholder("Type here...");
22/// ui.text_input(&mut input);
23/// println!("{}", input.value);
24/// # });
25/// ```
26pub struct TextInputState {
27    /// The current input text.
28    pub value: String,
29    /// Cursor position as a character index into `value`.
30    pub cursor: usize,
31    /// Placeholder text shown when `value` is empty.
32    pub placeholder: String,
33    /// Maximum character count. Input is rejected beyond this limit.
34    pub max_length: Option<usize>,
35    /// The most recent validation error message, if any.
36    pub validation_error: Option<String>,
37}
38
39impl TextInputState {
40    /// Create an empty text input state.
41    pub fn new() -> Self {
42        Self {
43            value: String::new(),
44            cursor: 0,
45            placeholder: String::new(),
46            max_length: None,
47            validation_error: None,
48        }
49    }
50
51    /// Create a text input with placeholder text shown when the value is empty.
52    pub fn with_placeholder(p: impl Into<String>) -> Self {
53        Self {
54            placeholder: p.into(),
55            ..Self::new()
56        }
57    }
58
59    /// Set the maximum allowed character count.
60    pub fn max_length(mut self, len: usize) -> Self {
61        self.max_length = Some(len);
62        self
63    }
64
65    /// Validate the current value and store the latest error message.
66    ///
67    /// Sets [`TextInputState::validation_error`] to `None` when validation
68    /// succeeds, or to `Some(error)` when validation fails.
69    pub fn validate(&mut self, validator: impl Fn(&str) -> Result<(), String>) {
70        self.validation_error = validator(&self.value).err();
71    }
72}
73
74impl Default for TextInputState {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80/// A single form field with label and validation.
81pub struct FormField {
82    /// Field label shown above the input.
83    pub label: String,
84    /// Text input state for this field.
85    pub input: TextInputState,
86    /// Validation error shown below the input when present.
87    pub error: Option<String>,
88}
89
90impl FormField {
91    /// Create a new form field with the given label.
92    pub fn new(label: impl Into<String>) -> Self {
93        Self {
94            label: label.into(),
95            input: TextInputState::new(),
96            error: None,
97        }
98    }
99
100    /// Set placeholder text for this field's input.
101    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
102        self.input.placeholder = p.into();
103        self
104    }
105}
106
107/// State for a form with multiple fields.
108pub struct FormState {
109    /// Ordered list of form fields.
110    pub fields: Vec<FormField>,
111    /// Whether the form has been successfully submitted.
112    pub submitted: bool,
113}
114
115impl FormState {
116    /// Create an empty form state.
117    pub fn new() -> Self {
118        Self {
119            fields: Vec::new(),
120            submitted: false,
121        }
122    }
123
124    /// Add a field and return the updated form for chaining.
125    pub fn field(mut self, field: FormField) -> Self {
126        self.fields.push(field);
127        self
128    }
129
130    /// Validate all fields with the given validators.
131    ///
132    /// Returns `true` when all validations pass.
133    pub fn validate(&mut self, validators: &[FormValidator]) -> bool {
134        let mut all_valid = true;
135        for (i, field) in self.fields.iter_mut().enumerate() {
136            if let Some(validator) = validators.get(i) {
137                match validator(&field.input.value) {
138                    Ok(()) => field.error = None,
139                    Err(msg) => {
140                        field.error = Some(msg);
141                        all_valid = false;
142                    }
143                }
144            }
145        }
146        all_valid
147    }
148
149    /// Get field value by index.
150    pub fn value(&self, index: usize) -> &str {
151        self.fields
152            .get(index)
153            .map(|f| f.input.value.as_str())
154            .unwrap_or("")
155    }
156}
157
158impl Default for FormState {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164/// State for toast notification display.
165///
166/// Add messages with [`ToastState::info`], [`ToastState::success`],
167/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
168/// `Context::toast` each frame. Expired messages are removed automatically.
169pub struct ToastState {
170    /// Active toast messages, ordered oldest-first.
171    pub messages: Vec<ToastMessage>,
172}
173
174/// A single toast notification message.
175pub struct ToastMessage {
176    /// The text content of the notification.
177    pub text: String,
178    /// Severity level, used to choose the display color.
179    pub level: ToastLevel,
180    /// The tick at which this message was created.
181    pub created_tick: u64,
182    /// How many ticks the message remains visible.
183    pub duration_ticks: u64,
184}
185
186/// Severity level for a [`ToastMessage`].
187pub enum ToastLevel {
188    /// Informational message (primary color).
189    Info,
190    /// Success message (success color).
191    Success,
192    /// Warning message (warning color).
193    Warning,
194    /// Error message (error color).
195    Error,
196}
197
198impl ToastState {
199    /// Create an empty toast state with no messages.
200    pub fn new() -> Self {
201        Self {
202            messages: Vec::new(),
203        }
204    }
205
206    /// Push an informational toast visible for 30 ticks.
207    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
208        self.push(text, ToastLevel::Info, tick, 30);
209    }
210
211    /// Push a success toast visible for 30 ticks.
212    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
213        self.push(text, ToastLevel::Success, tick, 30);
214    }
215
216    /// Push a warning toast visible for 50 ticks.
217    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
218        self.push(text, ToastLevel::Warning, tick, 50);
219    }
220
221    /// Push an error toast visible for 80 ticks.
222    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
223        self.push(text, ToastLevel::Error, tick, 80);
224    }
225
226    /// Push a toast with a custom level and duration.
227    pub fn push(
228        &mut self,
229        text: impl Into<String>,
230        level: ToastLevel,
231        tick: u64,
232        duration_ticks: u64,
233    ) {
234        self.messages.push(ToastMessage {
235            text: text.into(),
236            level,
237            created_tick: tick,
238            duration_ticks,
239        });
240    }
241
242    /// Remove all messages whose display duration has elapsed.
243    ///
244    /// Called automatically by `Context::toast` before rendering.
245    pub fn cleanup(&mut self, current_tick: u64) {
246        self.messages.retain(|message| {
247            current_tick < message.created_tick.saturating_add(message.duration_ticks)
248        });
249    }
250}
251
252impl Default for ToastState {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258/// State for a multi-line text area widget.
259///
260/// Pass a mutable reference to `Context::textarea` each frame along with the
261/// number of visible rows. The widget handles all keyboard events when focused.
262pub struct TextareaState {
263    /// The lines of text, one entry per line.
264    pub lines: Vec<String>,
265    /// Row index of the cursor (0-based, logical line).
266    pub cursor_row: usize,
267    /// Column index of the cursor within the current row (character index).
268    pub cursor_col: usize,
269    /// Maximum total character count across all lines.
270    pub max_length: Option<usize>,
271    /// When set, lines longer than this display-column width are soft-wrapped.
272    pub wrap_width: Option<u32>,
273    /// First visible visual line (managed internally by `textarea()`).
274    pub scroll_offset: usize,
275}
276
277impl TextareaState {
278    /// Create an empty text area state with one blank line.
279    pub fn new() -> Self {
280        Self {
281            lines: vec![String::new()],
282            cursor_row: 0,
283            cursor_col: 0,
284            max_length: None,
285            wrap_width: None,
286            scroll_offset: 0,
287        }
288    }
289
290    /// Return all lines joined with newline characters.
291    pub fn value(&self) -> String {
292        self.lines.join("\n")
293    }
294
295    /// Replace the content with the given text, splitting on newlines.
296    ///
297    /// Resets the cursor to the beginning of the first line.
298    pub fn set_value(&mut self, text: impl Into<String>) {
299        let value = text.into();
300        self.lines = value.split('\n').map(str::to_string).collect();
301        if self.lines.is_empty() {
302            self.lines.push(String::new());
303        }
304        self.cursor_row = 0;
305        self.cursor_col = 0;
306        self.scroll_offset = 0;
307    }
308
309    /// Set the maximum allowed total character count.
310    pub fn max_length(mut self, len: usize) -> Self {
311        self.max_length = Some(len);
312        self
313    }
314
315    /// Enable soft word-wrap at the given display-column width.
316    pub fn word_wrap(mut self, width: u32) -> Self {
317        self.wrap_width = Some(width);
318        self
319    }
320}
321
322impl Default for TextareaState {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328/// State for an animated spinner widget.
329///
330/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
331/// `Context::spinner` each frame. The frame advances automatically with the
332/// tick counter.
333pub struct SpinnerState {
334    chars: Vec<char>,
335}
336
337impl SpinnerState {
338    /// Create a dots-style spinner using braille characters.
339    ///
340    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
341    pub fn dots() -> Self {
342        Self {
343            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
344        }
345    }
346
347    /// Create a line-style spinner using ASCII characters.
348    ///
349    /// Cycles through: `| / - \`
350    pub fn line() -> Self {
351        Self {
352            chars: vec!['|', '/', '-', '\\'],
353        }
354    }
355
356    /// Return the spinner character for the given tick.
357    pub fn frame(&self, tick: u64) -> char {
358        if self.chars.is_empty() {
359            return ' ';
360        }
361        self.chars[tick as usize % self.chars.len()]
362    }
363}
364
365impl Default for SpinnerState {
366    fn default() -> Self {
367        Self::dots()
368    }
369}
370
371/// State for a selectable list widget.
372///
373/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
374/// keys (and `k`/`j`) move the selection when the widget is focused.
375pub struct ListState {
376    /// The list items as display strings.
377    pub items: Vec<String>,
378    /// Index of the currently selected item.
379    pub selected: usize,
380}
381
382impl ListState {
383    /// Create a list with the given items. The first item is selected initially.
384    pub fn new(items: Vec<impl Into<String>>) -> Self {
385        Self {
386            items: items.into_iter().map(Into::into).collect(),
387            selected: 0,
388        }
389    }
390
391    /// Get the currently selected item text, or `None` if the list is empty.
392    pub fn selected_item(&self) -> Option<&str> {
393        self.items.get(self.selected).map(String::as_str)
394    }
395}
396
397/// State for a tab navigation widget.
398///
399/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
400/// keys cycle through tabs when the widget is focused.
401pub struct TabsState {
402    /// The tab labels displayed in the bar.
403    pub labels: Vec<String>,
404    /// Index of the currently active tab.
405    pub selected: usize,
406}
407
408impl TabsState {
409    /// Create tabs with the given labels. The first tab is active initially.
410    pub fn new(labels: Vec<impl Into<String>>) -> Self {
411        Self {
412            labels: labels.into_iter().map(Into::into).collect(),
413            selected: 0,
414        }
415    }
416
417    /// Get the currently selected tab label, or `None` if there are no tabs.
418    pub fn selected_label(&self) -> Option<&str> {
419        self.labels.get(self.selected).map(String::as_str)
420    }
421}
422
423/// State for a data table widget.
424///
425/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
426/// keys move the row selection when the widget is focused. Column widths are
427/// computed automatically from header and cell content.
428pub struct TableState {
429    /// Column header labels.
430    pub headers: Vec<String>,
431    /// Table rows, each a `Vec` of cell strings.
432    pub rows: Vec<Vec<String>>,
433    /// Index of the currently selected row.
434    pub selected: usize,
435    column_widths: Vec<u32>,
436    dirty: bool,
437}
438
439impl TableState {
440    /// Create a table with headers and rows. Column widths are computed immediately.
441    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
442        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
443        let rows: Vec<Vec<String>> = rows
444            .into_iter()
445            .map(|r| r.into_iter().map(Into::into).collect())
446            .collect();
447        let mut state = Self {
448            headers,
449            rows,
450            selected: 0,
451            column_widths: Vec::new(),
452            dirty: true,
453        };
454        state.recompute_widths();
455        state
456    }
457
458    /// Replace all rows, preserving the selection index if possible.
459    ///
460    /// If the current selection is beyond the new row count, it is clamped to
461    /// the last row.
462    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
463        self.rows = rows
464            .into_iter()
465            .map(|r| r.into_iter().map(Into::into).collect())
466            .collect();
467        self.dirty = true;
468        self.selected = self.selected.min(self.rows.len().saturating_sub(1));
469    }
470
471    /// Get the currently selected row data, or `None` if the table is empty.
472    pub fn selected_row(&self) -> Option<&[String]> {
473        self.rows.get(self.selected).map(|r| r.as_slice())
474    }
475
476    pub(crate) fn recompute_widths(&mut self) {
477        let col_count = self.headers.len();
478        self.column_widths = vec![0u32; col_count];
479        for (i, header) in self.headers.iter().enumerate() {
480            self.column_widths[i] = UnicodeWidthStr::width(header.as_str()) as u32;
481        }
482        for row in &self.rows {
483            for (i, cell) in row.iter().enumerate() {
484                if i < col_count {
485                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
486                    self.column_widths[i] = self.column_widths[i].max(w);
487                }
488            }
489        }
490        self.dirty = false;
491    }
492
493    pub(crate) fn column_widths(&self) -> &[u32] {
494        &self.column_widths
495    }
496
497    pub(crate) fn is_dirty(&self) -> bool {
498        self.dirty
499    }
500}
501
502/// State for a scrollable container.
503///
504/// Pass a mutable reference to `Context::scrollable` each frame. The context
505/// updates `offset` and the internal bounds automatically based on mouse wheel
506/// and drag events.
507pub struct ScrollState {
508    /// Current vertical scroll offset in rows.
509    pub offset: usize,
510    content_height: u32,
511    viewport_height: u32,
512}
513
514impl ScrollState {
515    /// Create scroll state starting at offset 0.
516    pub fn new() -> Self {
517        Self {
518            offset: 0,
519            content_height: 0,
520            viewport_height: 0,
521        }
522    }
523
524    /// Check if scrolling upward is possible (offset is greater than 0).
525    pub fn can_scroll_up(&self) -> bool {
526        self.offset > 0
527    }
528
529    /// Check if scrolling downward is possible (content extends below the viewport).
530    pub fn can_scroll_down(&self) -> bool {
531        (self.offset as u32) + self.viewport_height < self.content_height
532    }
533
534    /// Scroll up by the given number of rows, clamped to 0.
535    pub fn scroll_up(&mut self, amount: usize) {
536        self.offset = self.offset.saturating_sub(amount);
537    }
538
539    /// Scroll down by the given number of rows, clamped to the maximum offset.
540    pub fn scroll_down(&mut self, amount: usize) {
541        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
542        self.offset = (self.offset + amount).min(max_offset);
543    }
544
545    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
546        self.content_height = content_height;
547        self.viewport_height = viewport_height;
548    }
549}
550
551impl Default for ScrollState {
552    fn default() -> Self {
553        Self::new()
554    }
555}
556
557/// Visual variant for buttons.
558///
559/// Controls the color scheme used when rendering a button. Pass to
560/// [`crate::Context::button_with`] to create styled button variants.
561///
562/// - `Default` — theme text color, primary when focused (same as `button()`)
563/// - `Primary` — primary color background with contrasting text
564/// - `Danger` — error/red color for destructive actions
565/// - `Outline` — bordered appearance without fill
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
567pub enum ButtonVariant {
568    /// Standard button style.
569    #[default]
570    Default,
571    /// Filled button with primary background color.
572    Primary,
573    /// Filled button with error/danger background color.
574    Danger,
575    /// Bordered button without background fill.
576    Outline,
577}