Skip to main content

slt/
widgets.rs

1use unicode_width::UnicodeWidthStr;
2
3/// State for a single-line text input widget.
4///
5/// Pass a mutable reference to `Context::text_input` each frame. The widget
6/// handles all keyboard events when focused.
7///
8/// # Example
9///
10/// ```no_run
11/// # use slt::widgets::TextInputState;
12/// # slt::run(|ui: &mut slt::Context| {
13/// let mut input = TextInputState::with_placeholder("Type here...");
14/// ui.text_input(&mut input);
15/// println!("{}", input.value);
16/// # });
17/// ```
18pub struct TextInputState {
19    /// The current input text.
20    pub value: String,
21    /// Cursor position as a character index into `value`.
22    pub cursor: usize,
23    /// Placeholder text shown when `value` is empty.
24    pub placeholder: String,
25    pub max_length: Option<usize>,
26}
27
28impl TextInputState {
29    /// Create an empty text input state.
30    pub fn new() -> Self {
31        Self {
32            value: String::new(),
33            cursor: 0,
34            placeholder: String::new(),
35            max_length: None,
36        }
37    }
38
39    /// Create a text input with placeholder text shown when the value is empty.
40    pub fn with_placeholder(p: impl Into<String>) -> Self {
41        Self {
42            placeholder: p.into(),
43            ..Self::new()
44        }
45    }
46
47    pub fn max_length(mut self, len: usize) -> Self {
48        self.max_length = Some(len);
49        self
50    }
51}
52
53impl Default for TextInputState {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59/// State for toast notification display.
60///
61/// Add messages with [`ToastState::info`], [`ToastState::success`],
62/// [`ToastState::warning`], or [`ToastState::error`], then pass the state to
63/// `Context::toast` each frame. Expired messages are removed automatically.
64pub struct ToastState {
65    /// Active toast messages, ordered oldest-first.
66    pub messages: Vec<ToastMessage>,
67}
68
69/// A single toast notification message.
70pub struct ToastMessage {
71    /// The text content of the notification.
72    pub text: String,
73    /// Severity level, used to choose the display color.
74    pub level: ToastLevel,
75    /// The tick at which this message was created.
76    pub created_tick: u64,
77    /// How many ticks the message remains visible.
78    pub duration_ticks: u64,
79}
80
81/// Severity level for a [`ToastMessage`].
82pub enum ToastLevel {
83    /// Informational message (primary color).
84    Info,
85    /// Success message (success color).
86    Success,
87    /// Warning message (warning color).
88    Warning,
89    /// Error message (error color).
90    Error,
91}
92
93impl ToastState {
94    /// Create an empty toast state with no messages.
95    pub fn new() -> Self {
96        Self {
97            messages: Vec::new(),
98        }
99    }
100
101    /// Push an informational toast visible for 30 ticks.
102    pub fn info(&mut self, text: impl Into<String>, tick: u64) {
103        self.push(text, ToastLevel::Info, tick, 30);
104    }
105
106    /// Push a success toast visible for 30 ticks.
107    pub fn success(&mut self, text: impl Into<String>, tick: u64) {
108        self.push(text, ToastLevel::Success, tick, 30);
109    }
110
111    /// Push a warning toast visible for 50 ticks.
112    pub fn warning(&mut self, text: impl Into<String>, tick: u64) {
113        self.push(text, ToastLevel::Warning, tick, 50);
114    }
115
116    /// Push an error toast visible for 80 ticks.
117    pub fn error(&mut self, text: impl Into<String>, tick: u64) {
118        self.push(text, ToastLevel::Error, tick, 80);
119    }
120
121    /// Push a toast with a custom level and duration.
122    pub fn push(
123        &mut self,
124        text: impl Into<String>,
125        level: ToastLevel,
126        tick: u64,
127        duration_ticks: u64,
128    ) {
129        self.messages.push(ToastMessage {
130            text: text.into(),
131            level,
132            created_tick: tick,
133            duration_ticks,
134        });
135    }
136
137    /// Remove all messages whose display duration has elapsed.
138    ///
139    /// Called automatically by `Context::toast` before rendering.
140    pub fn cleanup(&mut self, current_tick: u64) {
141        self.messages.retain(|message| {
142            current_tick < message.created_tick.saturating_add(message.duration_ticks)
143        });
144    }
145}
146
147impl Default for ToastState {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153/// State for a multi-line text area widget.
154///
155/// Pass a mutable reference to `Context::textarea` each frame along with the
156/// number of visible rows. The widget handles all keyboard events when focused.
157pub struct TextareaState {
158    /// The lines of text, one entry per line.
159    pub lines: Vec<String>,
160    /// Row index of the cursor (0-based).
161    pub cursor_row: usize,
162    /// Column index of the cursor within the current row (character index).
163    pub cursor_col: usize,
164    pub max_length: Option<usize>,
165}
166
167impl TextareaState {
168    /// Create an empty text area state with one blank line.
169    pub fn new() -> Self {
170        Self {
171            lines: vec![String::new()],
172            cursor_row: 0,
173            cursor_col: 0,
174            max_length: None,
175        }
176    }
177
178    /// Return all lines joined with newline characters.
179    pub fn value(&self) -> String {
180        self.lines.join("\n")
181    }
182
183    /// Replace the content with the given text, splitting on newlines.
184    ///
185    /// Resets the cursor to the beginning of the first line.
186    pub fn set_value(&mut self, text: impl Into<String>) {
187        let value = text.into();
188        self.lines = value.split('\n').map(str::to_string).collect();
189        if self.lines.is_empty() {
190            self.lines.push(String::new());
191        }
192        self.cursor_row = 0;
193        self.cursor_col = 0;
194    }
195
196    pub fn max_length(mut self, len: usize) -> Self {
197        self.max_length = Some(len);
198        self
199    }
200}
201
202impl Default for TextareaState {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208/// State for an animated spinner widget.
209///
210/// Create with [`SpinnerState::dots`] or [`SpinnerState::line`], then pass to
211/// `Context::spinner` each frame. The frame advances automatically with the
212/// tick counter.
213pub struct SpinnerState {
214    chars: Vec<char>,
215}
216
217impl SpinnerState {
218    /// Create a dots-style spinner using braille characters.
219    ///
220    /// Cycles through: `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
221    pub fn dots() -> Self {
222        Self {
223            chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
224        }
225    }
226
227    /// Create a line-style spinner using ASCII characters.
228    ///
229    /// Cycles through: `| / - \`
230    pub fn line() -> Self {
231        Self {
232            chars: vec!['|', '/', '-', '\\'],
233        }
234    }
235
236    /// Return the spinner character for the given tick.
237    pub fn frame(&self, tick: u64) -> char {
238        if self.chars.is_empty() {
239            return ' ';
240        }
241        self.chars[tick as usize % self.chars.len()]
242    }
243}
244
245impl Default for SpinnerState {
246    fn default() -> Self {
247        Self::dots()
248    }
249}
250
251/// State for a selectable list widget.
252///
253/// Pass a mutable reference to `Context::list` each frame. Up/Down arrow
254/// keys (and `k`/`j`) move the selection when the widget is focused.
255pub struct ListState {
256    /// The list items as display strings.
257    pub items: Vec<String>,
258    /// Index of the currently selected item.
259    pub selected: usize,
260}
261
262impl ListState {
263    /// Create a list with the given items. The first item is selected initially.
264    pub fn new(items: Vec<impl Into<String>>) -> Self {
265        Self {
266            items: items.into_iter().map(Into::into).collect(),
267            selected: 0,
268        }
269    }
270
271    /// Get the currently selected item text, or `None` if the list is empty.
272    pub fn selected_item(&self) -> Option<&str> {
273        self.items.get(self.selected).map(String::as_str)
274    }
275}
276
277/// State for a tab navigation widget.
278///
279/// Pass a mutable reference to `Context::tabs` each frame. Left/Right arrow
280/// keys cycle through tabs when the widget is focused.
281pub struct TabsState {
282    /// The tab labels displayed in the bar.
283    pub labels: Vec<String>,
284    /// Index of the currently active tab.
285    pub selected: usize,
286}
287
288impl TabsState {
289    /// Create tabs with the given labels. The first tab is active initially.
290    pub fn new(labels: Vec<impl Into<String>>) -> Self {
291        Self {
292            labels: labels.into_iter().map(Into::into).collect(),
293            selected: 0,
294        }
295    }
296
297    /// Get the currently selected tab label, or `None` if there are no tabs.
298    pub fn selected_label(&self) -> Option<&str> {
299        self.labels.get(self.selected).map(String::as_str)
300    }
301}
302
303/// State for a data table widget.
304///
305/// Pass a mutable reference to `Context::table` each frame. Up/Down arrow
306/// keys move the row selection when the widget is focused. Column widths are
307/// computed automatically from header and cell content.
308pub struct TableState {
309    /// Column header labels.
310    pub headers: Vec<String>,
311    /// Table rows, each a `Vec` of cell strings.
312    pub rows: Vec<Vec<String>>,
313    /// Index of the currently selected row.
314    pub selected: usize,
315    column_widths: Vec<u32>,
316    dirty: bool,
317}
318
319impl TableState {
320    /// Create a table with headers and rows. Column widths are computed immediately.
321    pub fn new(headers: Vec<impl Into<String>>, rows: Vec<Vec<impl Into<String>>>) -> Self {
322        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
323        let rows: Vec<Vec<String>> = rows
324            .into_iter()
325            .map(|r| r.into_iter().map(Into::into).collect())
326            .collect();
327        let mut state = Self {
328            headers,
329            rows,
330            selected: 0,
331            column_widths: Vec::new(),
332            dirty: true,
333        };
334        state.recompute_widths();
335        state
336    }
337
338    /// Replace all rows, preserving the selection index if possible.
339    ///
340    /// If the current selection is beyond the new row count, it is clamped to
341    /// the last row.
342    pub fn set_rows(&mut self, rows: Vec<Vec<impl Into<String>>>) {
343        self.rows = rows
344            .into_iter()
345            .map(|r| r.into_iter().map(Into::into).collect())
346            .collect();
347        self.dirty = true;
348        self.selected = self.selected.min(self.rows.len().saturating_sub(1));
349    }
350
351    /// Get the currently selected row data, or `None` if the table is empty.
352    pub fn selected_row(&self) -> Option<&[String]> {
353        self.rows.get(self.selected).map(|r| r.as_slice())
354    }
355
356    pub(crate) fn recompute_widths(&mut self) {
357        let col_count = self.headers.len();
358        self.column_widths = vec![0u32; col_count];
359        for (i, header) in self.headers.iter().enumerate() {
360            self.column_widths[i] = UnicodeWidthStr::width(header.as_str()) as u32;
361        }
362        for row in &self.rows {
363            for (i, cell) in row.iter().enumerate() {
364                if i < col_count {
365                    let w = UnicodeWidthStr::width(cell.as_str()) as u32;
366                    self.column_widths[i] = self.column_widths[i].max(w);
367                }
368            }
369        }
370        self.dirty = false;
371    }
372
373    pub(crate) fn column_widths(&self) -> &[u32] {
374        &self.column_widths
375    }
376
377    pub(crate) fn is_dirty(&self) -> bool {
378        self.dirty
379    }
380}
381
382/// State for a scrollable container.
383///
384/// Pass a mutable reference to `Context::scrollable` each frame. The context
385/// updates `offset` and the internal bounds automatically based on mouse wheel
386/// and drag events.
387pub struct ScrollState {
388    /// Current vertical scroll offset in rows.
389    pub offset: usize,
390    content_height: u32,
391    viewport_height: u32,
392}
393
394impl ScrollState {
395    /// Create scroll state starting at offset 0.
396    pub fn new() -> Self {
397        Self {
398            offset: 0,
399            content_height: 0,
400            viewport_height: 0,
401        }
402    }
403
404    /// Check if scrolling upward is possible (offset is greater than 0).
405    pub fn can_scroll_up(&self) -> bool {
406        self.offset > 0
407    }
408
409    /// Check if scrolling downward is possible (content extends below the viewport).
410    pub fn can_scroll_down(&self) -> bool {
411        (self.offset as u32) + self.viewport_height < self.content_height
412    }
413
414    /// Scroll up by the given number of rows, clamped to 0.
415    pub fn scroll_up(&mut self, amount: usize) {
416        self.offset = self.offset.saturating_sub(amount);
417    }
418
419    /// Scroll down by the given number of rows, clamped to the maximum offset.
420    pub fn scroll_down(&mut self, amount: usize) {
421        let max_offset = self.content_height.saturating_sub(self.viewport_height) as usize;
422        self.offset = (self.offset + amount).min(max_offset);
423    }
424
425    pub(crate) fn set_bounds(&mut self, content_height: u32, viewport_height: u32) {
426        self.content_height = content_height;
427        self.viewport_height = viewport_height;
428    }
429}
430
431impl Default for ScrollState {
432    fn default() -> Self {
433        Self::new()
434    }
435}