Skip to main content

slt/
widgets.rs

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