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}