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 (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 pub fn number_input(&mut self, state: &mut NumberInputState) -> Response {
249 let focused = self.register_focusable();
250 let (gained_focus, lost_focus) = self.focus_transitions(focused);
253
254 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 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 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 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 state.value = state.clamped();
394
395 response.focused = focused;
396 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
404fn 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}