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}