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}