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