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        // v0.21.1: capture focus-edge flags (issue #208 gap — slider assembled
128        // its Response by hand and never set gained_focus/lost_focus).
129        let (gained_focus, lost_focus) = self.focus_transitions(focused);
130        let mut changed = false;
131
132        let start = *range.start();
133        let end = *range.end();
134        let span = (end - start).max(0.0);
135
136        *value = (*value).clamp(start, end);
137
138        if focused {
139            let mut consumed_indices = Vec::new();
140            for (i, key) in self.available_key_presses() {
141                match key.code {
142                    KeyCode::Left | KeyCode::Char('h') => {
143                        if step > 0.0 {
144                            let next = (*value - step).max(start);
145                            if (next - *value).abs() > f64::EPSILON {
146                                *value = next;
147                                changed = true;
148                            }
149                        }
150                        consumed_indices.push(i);
151                    }
152                    KeyCode::Right | KeyCode::Char('l') => {
153                        if step > 0.0 {
154                            let next = (*value + step).min(end);
155                            if (next - *value).abs() > f64::EPSILON {
156                                *value = next;
157                                changed = true;
158                            }
159                        }
160                        consumed_indices.push(i);
161                    }
162                    _ => {}
163                }
164            }
165            self.consume_indices(consumed_indices);
166        }
167
168        let ratio = if span <= f64::EPSILON {
169            0.0
170        } else {
171            ((*value - start) / span).clamp(0.0, 1.0)
172        };
173
174        let value_text = format_compact_number(*value);
175        let label_width = UnicodeWidthStr::width(label) as u32;
176        let value_width = UnicodeWidthStr::width(value_text.as_str()) as u32;
177        let track_width = self
178            .area_width
179            .saturating_sub(label_width + value_width + 8)
180            .max(10) as usize;
181        let thumb_idx = if track_width <= 1 {
182            0
183        } else {
184            (ratio * (track_width as f64 - 1.0)).round() as usize
185        };
186
187        let mut track = String::with_capacity(track_width);
188        for i in 0..track_width {
189            if i == thumb_idx {
190                track.push('○');
191            } else if i < thumb_idx {
192                track.push('█');
193            } else {
194                track.push('━');
195            }
196        }
197
198        let text_color = self.theme.text;
199        let border_color = self.theme.border;
200        let primary_color = self.theme.primary;
201        let dim_color = self.theme.text_dim;
202        let mut response = self.container().row(|ui| {
203            ui.text(label).fg(text_color);
204            ui.text("[").fg(border_color);
205            ui.text(track).grow(1).fg(primary_color);
206            ui.text("]").fg(border_color);
207            if focused {
208                ui.text(value_text.as_str()).bold().fg(primary_color);
209            } else {
210                ui.text(value_text.as_str()).fg(dim_color);
211            }
212        });
213        response.focused = focused;
214        response.changed = changed;
215        response.gained_focus = gained_focus;
216        response.lost_focus = lost_focus;
217        response
218    }
219
220    /// Numeric stepper field: Up/Down (or `k`/`j`) and scroll-wheel adjust by
221    /// `step`, or type a value directly and press `Enter`. The committed value
222    /// is always clamped to `[min, max]` (and rounded in integer mode).
223    ///
224    /// Unlike [`slider`](Context::slider) — a bar-and-thumb control keyed by
225    /// Left/Right — this renders the raw value as a `▾ 42 ▴` field that accepts
226    /// direct typing. Config lives on [`NumberInputState`]. Up/`k` increments,
227    /// Down/`j` decrements; `Enter` commits a typed buffer, `Esc` discards it,
228    /// `Backspace` edits it. Left/Right are intentionally unused (reserved).
229    ///
230    /// `Response.focused` reflects focus and `Response.changed` is `true` iff
231    /// the committed value changed this frame. All handled key and scroll
232    /// events are consumed so they do not leak to other widgets or the global
233    /// quit handler.
234    ///
235    /// Available since `0.21.0`.
236    ///
237    /// # Example
238    ///
239    /// ```
240    /// # use slt::*;
241    /// # use slt::widgets::NumberInputState;
242    /// # TestBackend::new(80, 24).render(|ui| {
243    /// let mut qty = NumberInputState::integer(3, 0, 10).step(1.0);
244    /// let r = ui.number_input(&mut qty);
245    /// if r.changed { /* qty.value updated */ }
246    /// # });
247    /// ```
248    pub fn number_input(&mut self, state: &mut NumberInputState) -> Response {
249        let focused = self.register_focusable();
250        // v0.21.1: capture focus-edge flags (issue #208 gap — number_input
251        // assembled its Response by hand and never set gained/lost_focus).
252        let (gained_focus, lost_focus) = self.focus_transitions(focused);
253
254        // Normalize the committed value before processing input so the
255        // pre-frame baseline used for `changed` is itself in-range.
256        state.value = state.clamped();
257        let old = state.value;
258        let step = state.step.max(0.0);
259
260        let adjust = |state: &mut NumberInputState, delta: f64| {
261            if delta == 0.0 {
262                return;
263            }
264            // Adjusting commits any in-progress buffer (discarding it) and
265            // clears a prior parse error.
266            state.editing = None;
267            state.parse_error = None;
268            state.value = (state.value + delta).clamp(state.min, state.max);
269            if state.integer {
270                state.value = state.value.round();
271            }
272        };
273
274        if focused {
275            let mut consumed_indices = Vec::new();
276            for (i, key) in self.available_key_presses() {
277                match key.code {
278                    KeyCode::Up | KeyCode::Char('k') => {
279                        adjust(state, step);
280                        consumed_indices.push(i);
281                    }
282                    KeyCode::Down | KeyCode::Char('j') => {
283                        adjust(state, -step);
284                        consumed_indices.push(i);
285                    }
286                    KeyCode::Char(ch) if is_number_char(ch, state) => {
287                        let buf = state.editing.get_or_insert_with(String::new);
288                        buf.push(ch);
289                        state.parse_error = None;
290                        consumed_indices.push(i);
291                    }
292                    KeyCode::Backspace => {
293                        if let Some(buf) = state.editing.as_mut() {
294                            buf.pop();
295                            state.parse_error = None;
296                            consumed_indices.push(i);
297                        }
298                    }
299                    KeyCode::Enter => {
300                        if let Some(buf) = state.editing.take() {
301                            let trimmed = buf.trim();
302                            match trimmed.parse::<f64>() {
303                                Ok(parsed) if parsed.is_finite() => {
304                                    state.value = parsed.clamp(state.min, state.max);
305                                    if state.integer {
306                                        state.value = state.value.round();
307                                    }
308                                    state.parse_error = None;
309                                }
310                                _ => {
311                                    state.parse_error = Some(format!("invalid number: {trimmed}"));
312                                }
313                            }
314                            consumed_indices.push(i);
315                        }
316                    }
317                    KeyCode::Esc if state.editing.is_some() => {
318                        state.editing = None;
319                        state.parse_error = None;
320                        consumed_indices.push(i);
321                    }
322                    _ => {}
323                }
324            }
325            self.consume_indices(consumed_indices);
326        }
327
328        // Clamp again after key handling so the rendered value is in-range.
329        state.value = state.clamped();
330
331        let display = if let Some(buf) = state.editing.as_ref() {
332            buf.clone()
333        } else if state.integer {
334            format!("{:.0}", state.value)
335        } else {
336            format_compact_number(state.value)
337        };
338
339        let primary_color = self.theme.primary;
340        let dim_color = self.theme.text_dim;
341        let error_color = self.theme.error;
342        let value_color = if focused { primary_color } else { dim_color };
343        let arrow_color = if focused { primary_color } else { dim_color };
344        let parse_error = state.parse_error.clone();
345        let editing = state.editing.is_some();
346
347        let mut response = self.container().row(|ui| {
348            ui.text("▾").fg(arrow_color);
349            ui.text(" ");
350            if focused {
351                ui.text(display.as_str()).bold().fg(value_color);
352            } else {
353                ui.text(display.as_str()).fg(value_color);
354            }
355            ui.text(" ");
356            ui.text("▴").fg(arrow_color);
357            if editing {
358                ui.text(" ✎").fg(dim_color);
359            }
360            if let Some(err) = parse_error.as_ref() {
361                let mut indicator = String::with_capacity(2 + err.len());
362                indicator.push_str("  ⚠ ");
363                indicator.push_str(err);
364                ui.text(indicator).dim().fg(error_color);
365            }
366        });
367
368        // Scroll-wheel adjustment over the rendered field's rect. The row's
369        // `Response.rect` comes from the previous frame's hit map (the standard
370        // `prev_hit_map` pattern, mirroring `rich_log`), so a scroll tick takes
371        // effect on the next frame. `ScrollUp` increments, `ScrollDown`
372        // decrements, both clamped to `[min, max]`.
373        if response.rect.width > 0 && response.rect.height > 0 {
374            let rect = response.rect;
375            let mut consumed = Vec::new();
376            for (i, mouse) in self.mouse_events_in_rect(rect) {
377                match mouse.kind {
378                    MouseKind::ScrollUp => {
379                        adjust(state, step);
380                        consumed.push(i);
381                    }
382                    MouseKind::ScrollDown => {
383                        adjust(state, -step);
384                        consumed.push(i);
385                    }
386                    _ => {}
387                }
388            }
389            self.consume_indices(consumed);
390        }
391
392        // Final clamp guards against any direct mutation or scroll adjustment.
393        state.value = state.clamped();
394
395        response.focused = focused;
396        // `changed` is true iff the committed value actually moved this frame.
397        response.changed = (state.value - old).abs() > f64::EPSILON;
398        response.gained_focus = gained_focus;
399        response.lost_focus = lost_focus;
400        response
401    }
402}
403
404/// Whether `ch` may be appended to the in-progress edit buffer.
405///
406/// Always allows ASCII digits. Allows a single `.` in float mode (not when the
407/// buffer already contains one). Allows a leading `-` only when negatives are
408/// representable (`min < 0`) and the buffer is empty.
409fn is_number_char(ch: char, state: &NumberInputState) -> bool {
410    if ch.is_ascii_digit() {
411        return true;
412    }
413    let buf = state.editing.as_deref().unwrap_or("");
414    match ch {
415        '.' => !state.integer && !buf.contains('.'),
416        '-' => state.min < 0.0 && buf.is_empty(),
417        _ => false,
418    }
419}