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}