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