slt/context/widgets_input/
feedback.rs1use super::*;
2
3impl Context {
4 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 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 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 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 pub fn number_input(&mut self, state: &mut NumberInputState) -> Response {
244 let focused = self.register_focusable();
245
246 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 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 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 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 state.value = state.clamped();
386
387 response.focused = focused;
388 response.changed = (state.value - old).abs() > f64::EPSILON;
390 response
391 }
392}
393
394fn 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}