Skip to main content

slt/context/widgets_input/
feedback.rs

1use super::*;
2
3impl Context {
4    /// Render an animated spinner.
5    ///
6    /// The spinner advances one frame per tick. Use [`SpinnerState::dots`] or
7    /// [`SpinnerState::line`] to create the state.
8    ///
9    /// Returns a [`Response`] with `hovered` populated correctly so callers
10    /// can attach tooltips or react to mouse interaction. Prior to v0.20.0
11    /// this returned `&mut Self`; existing code that ignores the return value
12    /// keeps compiling, though the `#[must_use]` attribute on `Response`
13    /// surfaces a warning that nudges callers to handle interaction state.
14    pub fn spinner(&mut self, state: &SpinnerState) -> Response {
15        let response = self.interaction();
16        self.styled(
17            state.frame(self.tick).to_string(),
18            Style::new().fg(self.theme.primary),
19        );
20        response
21    }
22
23    /// Render toast notifications. Calls `state.cleanup(tick)` automatically.
24    ///
25    /// Expired messages are removed before rendering. If there are no active
26    /// messages, nothing is rendered and `self` is returned unchanged.
27    pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
28        state.cleanup(self.tick);
29        if state.messages.is_empty() {
30            return self;
31        }
32
33        self.skip_interaction_slot();
34        self.commands
35            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
36                direction: Direction::Column,
37                gap: 0,
38                align: Align::Start,
39                align_self: None,
40                justify: Justify::Start,
41                border: None,
42                border_sides: BorderSides::all(),
43                border_style: Style::new().fg(self.theme.border),
44                bg_color: None,
45                padding: Padding::default(),
46                margin: Margin::default(),
47                constraints: Constraints::default(),
48                title: None,
49                grow: 0,
50                group_name: None,
51            })));
52        for message in state.messages.iter().rev() {
53            let color = match message.level {
54                ToastLevel::Info => self.theme.primary,
55                ToastLevel::Success => self.theme.success,
56                ToastLevel::Warning => self.theme.warning,
57                ToastLevel::Error => self.theme.error,
58            };
59            let mut line = String::with_capacity(4 + message.text.len());
60            line.push_str("  ● ");
61            line.push_str(&message.text);
62            self.styled(line, Style::new().fg(color));
63        }
64        self.commands.push(Command::EndContainer);
65        self.rollback.last_text_idx = None;
66
67        self
68    }
69
70    /// Horizontal slider for numeric values.
71    ///
72    /// Step defaults to `span / 20.0`. Use [`Context::slider_with_step`] for an
73    /// explicit step (e.g. integer volume controls).
74    ///
75    /// # Examples
76    /// ```
77    /// # use slt::*;
78    /// # TestBackend::new(80, 24).render(|ui| {
79    /// let mut volume = 75.0_f64;
80    /// let r = ui.slider("Volume", &mut volume, 0.0..=100.0);
81    /// if r.changed { /* volume was adjusted */ }
82    /// # });
83    /// ```
84    pub fn slider(
85        &mut self,
86        label: &str,
87        value: &mut f64,
88        range: std::ops::RangeInclusive<f64>,
89    ) -> Response {
90        let span = (*range.end() - *range.start()).max(0.0);
91        let step = if span > 0.0 { span / 20.0 } else { 0.0 };
92        self.slider_inner(label, value, range, step)
93    }
94
95    /// Horizontal slider with an explicit step size.
96    ///
97    /// Each Left/Right (or `h`/`l`) advances `value` by `step`. Use this when
98    /// the default step (`span / 20`) is too coarse or too fine — for example
99    /// integer counters need `step = 1.0`, fine controls need `step = 0.1`.
100    ///
101    /// # Examples
102    /// ```
103    /// # use slt::*;
104    /// # TestBackend::new(80, 24).render(|ui| {
105    /// let mut volume = 50.0_f64;
106    /// ui.slider_with_step("Volume", &mut volume, 0.0..=100.0, 1.0);
107    /// # });
108    /// ```
109    pub fn slider_with_step(
110        &mut self,
111        label: &str,
112        value: &mut f64,
113        range: std::ops::RangeInclusive<f64>,
114        step: f64,
115    ) -> Response {
116        self.slider_inner(label, value, range, step.max(0.0))
117    }
118
119    fn slider_inner(
120        &mut self,
121        label: &str,
122        value: &mut f64,
123        range: std::ops::RangeInclusive<f64>,
124        step: f64,
125    ) -> Response {
126        let focused = self.register_focusable();
127        let mut changed = false;
128
129        let start = *range.start();
130        let end = *range.end();
131        let span = (end - start).max(0.0);
132
133        *value = (*value).clamp(start, end);
134
135        if focused {
136            let mut consumed_indices = Vec::new();
137            for (i, key) in self.available_key_presses() {
138                match key.code {
139                    KeyCode::Left | KeyCode::Char('h') => {
140                        if step > 0.0 {
141                            let next = (*value - step).max(start);
142                            if (next - *value).abs() > f64::EPSILON {
143                                *value = next;
144                                changed = true;
145                            }
146                        }
147                        consumed_indices.push(i);
148                    }
149                    KeyCode::Right | KeyCode::Char('l') => {
150                        if step > 0.0 {
151                            let next = (*value + step).min(end);
152                            if (next - *value).abs() > f64::EPSILON {
153                                *value = next;
154                                changed = true;
155                            }
156                        }
157                        consumed_indices.push(i);
158                    }
159                    _ => {}
160                }
161            }
162            self.consume_indices(consumed_indices);
163        }
164
165        let ratio = if span <= f64::EPSILON {
166            0.0
167        } else {
168            ((*value - start) / span).clamp(0.0, 1.0)
169        };
170
171        let value_text = format_compact_number(*value);
172        let label_width = UnicodeWidthStr::width(label) as u32;
173        let value_width = UnicodeWidthStr::width(value_text.as_str()) as u32;
174        let track_width = self
175            .area_width
176            .saturating_sub(label_width + value_width + 8)
177            .max(10) as usize;
178        let thumb_idx = if track_width <= 1 {
179            0
180        } else {
181            (ratio * (track_width as f64 - 1.0)).round() as usize
182        };
183
184        let mut track = String::with_capacity(track_width);
185        for i in 0..track_width {
186            if i == thumb_idx {
187                track.push('○');
188            } else if i < thumb_idx {
189                track.push('█');
190            } else {
191                track.push('━');
192            }
193        }
194
195        let text_color = self.theme.text;
196        let border_color = self.theme.border;
197        let primary_color = self.theme.primary;
198        let dim_color = self.theme.text_dim;
199        let mut response = self.container().row(|ui| {
200            ui.text(label).fg(text_color);
201            ui.text("[").fg(border_color);
202            ui.text(track).grow(1).fg(primary_color);
203            ui.text("]").fg(border_color);
204            if focused {
205                ui.text(value_text.as_str()).bold().fg(primary_color);
206            } else {
207                ui.text(value_text.as_str()).fg(dim_color);
208            }
209        });
210        response.focused = focused;
211        response.changed = changed;
212        response
213    }
214
215    /// Numeric stepper field: Up/Down (or `k`/`j`) and scroll-wheel adjust by
216    /// `step`, or type a value directly and press `Enter`. The committed value
217    /// is always clamped to `[min, max]` (and rounded in integer mode).
218    ///
219    /// Unlike [`slider`](Context::slider) — a bar-and-thumb control keyed by
220    /// Left/Right — this renders the raw value as a `▾ 42 ▴` field that accepts
221    /// direct typing. Config lives on [`NumberInputState`]. Up/`k` increments,
222    /// Down/`j` decrements; `Enter` commits a typed buffer, `Esc` discards it,
223    /// `Backspace` edits it. Left/Right are intentionally unused (reserved).
224    ///
225    /// `Response.focused` reflects focus and `Response.changed` is `true` iff
226    /// the committed value changed this frame. All handled key and scroll
227    /// events are consumed so they do not leak to other widgets or the global
228    /// quit handler.
229    ///
230    /// Available since `0.21.0`.
231    ///
232    /// # Example
233    ///
234    /// ```
235    /// # use slt::*;
236    /// # use slt::widgets::NumberInputState;
237    /// # TestBackend::new(80, 24).render(|ui| {
238    /// let mut qty = NumberInputState::integer(3, 0, 10).step(1.0);
239    /// let r = ui.number_input(&mut qty);
240    /// if r.changed { /* qty.value updated */ }
241    /// # });
242    /// ```
243    pub fn number_input(&mut self, state: &mut NumberInputState) -> Response {
244        let focused = self.register_focusable();
245
246        // Normalize the committed value before processing input so the
247        // pre-frame baseline used for `changed` is itself in-range.
248        state.value = state.clamped();
249        let old = state.value;
250        let step = state.step.max(0.0);
251
252        let adjust = |state: &mut NumberInputState, delta: f64| {
253            if delta == 0.0 {
254                return;
255            }
256            // Adjusting commits any in-progress buffer (discarding it) and
257            // clears a prior parse error.
258            state.editing = None;
259            state.parse_error = None;
260            state.value = (state.value + delta).clamp(state.min, state.max);
261            if state.integer {
262                state.value = state.value.round();
263            }
264        };
265
266        if focused {
267            let mut consumed_indices = Vec::new();
268            for (i, key) in self.available_key_presses() {
269                match key.code {
270                    KeyCode::Up | KeyCode::Char('k') => {
271                        adjust(state, step);
272                        consumed_indices.push(i);
273                    }
274                    KeyCode::Down | KeyCode::Char('j') => {
275                        adjust(state, -step);
276                        consumed_indices.push(i);
277                    }
278                    KeyCode::Char(ch) if is_number_char(ch, state) => {
279                        let buf = state.editing.get_or_insert_with(String::new);
280                        buf.push(ch);
281                        state.parse_error = None;
282                        consumed_indices.push(i);
283                    }
284                    KeyCode::Backspace => {
285                        if let Some(buf) = state.editing.as_mut() {
286                            buf.pop();
287                            state.parse_error = None;
288                            consumed_indices.push(i);
289                        }
290                    }
291                    KeyCode::Enter => {
292                        if let Some(buf) = state.editing.take() {
293                            let trimmed = buf.trim();
294                            match trimmed.parse::<f64>() {
295                                Ok(parsed) if parsed.is_finite() => {
296                                    state.value = parsed.clamp(state.min, state.max);
297                                    if state.integer {
298                                        state.value = state.value.round();
299                                    }
300                                    state.parse_error = None;
301                                }
302                                _ => {
303                                    state.parse_error = Some(format!("invalid number: {trimmed}"));
304                                }
305                            }
306                            consumed_indices.push(i);
307                        }
308                    }
309                    KeyCode::Esc if state.editing.is_some() => {
310                        state.editing = None;
311                        state.parse_error = None;
312                        consumed_indices.push(i);
313                    }
314                    _ => {}
315                }
316            }
317            self.consume_indices(consumed_indices);
318        }
319
320        // Clamp again after key handling so the rendered value is in-range.
321        state.value = state.clamped();
322
323        let display = if let Some(buf) = state.editing.as_ref() {
324            buf.clone()
325        } else if state.integer {
326            format!("{:.0}", state.value)
327        } else {
328            format_compact_number(state.value)
329        };
330
331        let primary_color = self.theme.primary;
332        let dim_color = self.theme.text_dim;
333        let error_color = self.theme.error;
334        let value_color = if focused { primary_color } else { dim_color };
335        let arrow_color = if focused { primary_color } else { dim_color };
336        let parse_error = state.parse_error.clone();
337        let editing = state.editing.is_some();
338
339        let mut response = self.container().row(|ui| {
340            ui.text("▾").fg(arrow_color);
341            ui.text(" ");
342            if focused {
343                ui.text(display.as_str()).bold().fg(value_color);
344            } else {
345                ui.text(display.as_str()).fg(value_color);
346            }
347            ui.text(" ");
348            ui.text("▴").fg(arrow_color);
349            if editing {
350                ui.text(" ✎").fg(dim_color);
351            }
352            if let Some(err) = parse_error.as_ref() {
353                let mut indicator = String::with_capacity(2 + err.len());
354                indicator.push_str("  ⚠ ");
355                indicator.push_str(err);
356                ui.text(indicator).dim().fg(error_color);
357            }
358        });
359
360        // Scroll-wheel adjustment over the rendered field's rect. The row's
361        // `Response.rect` comes from the previous frame's hit map (the standard
362        // `prev_hit_map` pattern, mirroring `rich_log`), so a scroll tick takes
363        // effect on the next frame. `ScrollUp` increments, `ScrollDown`
364        // decrements, both clamped to `[min, max]`.
365        if response.rect.width > 0 && response.rect.height > 0 {
366            let rect = response.rect;
367            let mut consumed = Vec::new();
368            for (i, mouse) in self.mouse_events_in_rect(rect) {
369                match mouse.kind {
370                    MouseKind::ScrollUp => {
371                        adjust(state, step);
372                        consumed.push(i);
373                    }
374                    MouseKind::ScrollDown => {
375                        adjust(state, -step);
376                        consumed.push(i);
377                    }
378                    _ => {}
379                }
380            }
381            self.consume_indices(consumed);
382        }
383
384        // Final clamp guards against any direct mutation or scroll adjustment.
385        state.value = state.clamped();
386
387        response.focused = focused;
388        // `changed` is true iff the committed value actually moved this frame.
389        response.changed = (state.value - old).abs() > f64::EPSILON;
390        response
391    }
392}
393
394/// Whether `ch` may be appended to the in-progress edit buffer.
395///
396/// Always allows ASCII digits. Allows a single `.` in float mode (not when the
397/// buffer already contains one). Allows a leading `-` only when negatives are
398/// representable (`min < 0`) and the buffer is empty.
399fn is_number_char(ch: char, state: &NumberInputState) -> bool {
400    if ch.is_ascii_digit() {
401        return true;
402    }
403    let buf = state.editing.as_deref().unwrap_or("");
404    match ch {
405        '.' => !state.integer && !buf.contains('.'),
406        '-' => state.min < 0.0 && buf.is_empty(),
407        _ => false,
408    }
409}