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 self.slider_with(value, crate::widgets::SliderOpts::new(label, range))
91 }
92
93 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 #[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 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 pub fn number_input(&mut self, state: &mut NumberInputState) -> Response {
270 let focused = self.register_focusable();
271 let (gained_focus, lost_focus) = self.focus_transitions(focused);
274
275 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 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 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 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 state.value = state.clamped();
418
419 response.focused = focused;
420 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
428fn 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}