1use std::ops::RangeInclusive;
9
10use egui::{
11 emath::Numeric, CornerRadius, CursorIcon, Event, Id, Key, Pos2, Rect, Response, Sense, Stroke,
12 StrokeKind, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
13};
14
15use crate::theme::{mix, with_alpha, Accent, Theme};
16
17#[must_use = "Add with `ui.add(...)`."]
35pub struct RangeSlider<'a, T: Numeric> {
36 low: &'a mut T,
37 high: &'a mut T,
38 range: RangeInclusive<T>,
39 label: Option<WidgetText>,
40 suffix: String,
41 decimals: Option<usize>,
42 value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
43 show_value: bool,
44 step: Option<f64>,
45 ticks: Option<usize>,
46 show_tick_labels: bool,
47 accent: Accent,
48 desired_width: Option<f32>,
49 enabled: bool,
50 id_salt: Option<Id>,
51}
52
53impl<'a, T: Numeric> std::fmt::Debug for RangeSlider<'a, T> {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("RangeSlider")
56 .field("range_lo", &self.range.start().to_f64())
57 .field("range_hi", &self.range.end().to_f64())
58 .field("suffix", &self.suffix)
59 .field("decimals", &self.decimals)
60 .field("show_value", &self.show_value)
61 .field("step", &self.step)
62 .field("ticks", &self.ticks)
63 .field("show_tick_labels", &self.show_tick_labels)
64 .field("accent", &self.accent)
65 .field("desired_width", &self.desired_width)
66 .field("enabled", &self.enabled)
67 .finish()
68 }
69}
70
71impl<'a, T: Numeric> RangeSlider<'a, T> {
72 pub fn new(low: &'a mut T, high: &'a mut T, range: RangeInclusive<T>) -> Self {
74 Self {
75 low,
76 high,
77 range,
78 label: None,
79 suffix: String::new(),
80 decimals: None,
81 value_fmt: None,
82 show_value: true,
83 step: None,
84 ticks: None,
85 show_tick_labels: false,
86 accent: Accent::Sky,
87 desired_width: None,
88 enabled: true,
89 id_salt: None,
90 }
91 }
92
93 #[inline]
95 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
96 self.label = Some(label.into());
97 self
98 }
99
100 #[inline]
102 pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
103 self.suffix = suffix.into();
104 self
105 }
106
107 #[inline]
110 pub fn decimals(mut self, n: usize) -> Self {
111 self.decimals = Some(n);
112 self
113 }
114
115 pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
118 self.value_fmt = Some(Box::new(fmt));
119 self
120 }
121
122 #[inline]
125 pub fn show_value(mut self, show: bool) -> Self {
126 self.show_value = show;
127 self
128 }
129
130 #[inline]
133 pub fn step(mut self, step: f64) -> Self {
134 self.step = Some(step);
135 self
136 }
137
138 #[inline]
142 pub fn ticks(mut self, n: usize) -> Self {
143 self.ticks = Some(n);
144 self
145 }
146
147 #[inline]
150 pub fn show_tick_labels(mut self, show: bool) -> Self {
151 self.show_tick_labels = show;
152 self
153 }
154
155 #[inline]
157 pub fn accent(mut self, accent: Accent) -> Self {
158 self.accent = accent;
159 self
160 }
161
162 #[inline]
164 pub fn desired_width(mut self, width: f32) -> Self {
165 self.desired_width = Some(width);
166 self
167 }
168
169 #[inline]
172 pub fn enabled(mut self, enabled: bool) -> Self {
173 self.enabled = enabled;
174 self
175 }
176
177 #[inline]
181 pub fn id_salt(mut self, id_salt: impl std::hash::Hash) -> Self {
182 self.id_salt = Some(Id::new(id_salt));
183 self
184 }
185
186 fn format_value(&self, v: f64) -> String {
187 if let Some(fmt) = &self.value_fmt {
188 return fmt(v);
189 }
190 let n = self.decimals.unwrap_or(if T::INTEGRAL { 0 } else { 2 });
191 if self.suffix.is_empty() {
192 format!("{v:.n$}")
193 } else {
194 format!("{v:.n$}{}", self.suffix)
195 }
196 }
197}
198
199impl<'a, T: Numeric> Widget for RangeSlider<'a, T> {
200 fn ui(self, ui: &mut Ui) -> Response {
201 let theme = Theme::current(ui.ctx());
202 let p = &theme.palette;
203 let t = &theme.typography;
204 let accent_fill = p.accent_fill(self.accent);
205
206 let lo_raw = self.range.start().to_f64();
207 let hi_raw = self.range.end().to_f64();
208 let (range_lo, range_hi) = if lo_raw <= hi_raw {
209 (lo_raw, hi_raw)
210 } else {
211 (hi_raw, lo_raw)
212 };
213 let span = (range_hi - range_lo).max(f64::EPSILON);
214
215 let mut low_v = self.low.to_f64();
216 let mut high_v = self.high.to_f64();
217 if low_v.is_nan() {
218 low_v = range_lo;
219 }
220 if high_v.is_nan() {
221 high_v = range_hi;
222 }
223 low_v = low_v.clamp(range_lo, range_hi);
224 high_v = high_v.clamp(range_lo, range_hi);
225 if low_v > high_v {
226 std::mem::swap(&mut low_v, &mut high_v);
227 }
228
229 let step = self.step.or(if T::INTEGRAL { Some(1.0) } else { None });
230
231 let track_h: f32 = 6.0;
232 let thumb_d: f32 = 14.0;
233 let halo_pad: f32 = 4.0; let strip_h = thumb_d + 2.0 * halo_pad;
235 let mut row_h = strip_h;
236 if let Some(n) = self.ticks {
239 if n >= 2 {
240 row_h += 4.0;
241 if self.show_tick_labels {
242 row_h += t.small + 4.0;
243 }
244 }
245 }
246
247 let id_salt = self.id_salt.unwrap_or_else(|| Id::new(ui.next_auto_id()));
248 let drag_state_id = id_salt.with("range_slider_drag_idx");
249 let label_text = self
250 .label
251 .as_ref()
252 .map(|l| l.text().to_string())
253 .unwrap_or_default();
254
255 ui.vertical(|ui| {
256 if self.label.is_some() || self.show_value {
258 ui.horizontal(|ui| {
259 if let Some(label) = &self.label {
260 let color = if self.enabled { p.text } else { p.text_faint };
261 let rich = egui::RichText::new(label.text()).color(color).size(t.label);
262 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
263 }
264 if self.show_value {
265 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
266 let lo_text = self.format_value(low_v);
267 let hi_text = self.format_value(high_v);
268 let value_text = format!("{lo_text} \u{2013} {hi_text}");
269 let color = if self.enabled {
270 p.text_muted
271 } else {
272 p.text_faint
273 };
274 let rich = egui::RichText::new(value_text)
275 .color(color)
276 .size(t.label)
277 .family(egui::FontFamily::Monospace);
278 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
279 });
280 }
281 });
282 ui.add_space(4.0);
283 }
284
285 let total_w = self
287 .desired_width
288 .unwrap_or_else(|| ui.available_width())
289 .max(thumb_d * 4.0);
290 let bg_sense = if self.enabled {
291 Sense::click_and_drag()
292 } else {
293 Sense::hover()
294 };
295 let (rect, bg_resp) = ui.allocate_exact_size(Vec2::new(total_w, row_h), bg_sense);
296
297 let thumb_pad = thumb_d * 0.5;
298 let track_left = rect.min.x + thumb_pad;
299 let track_right = rect.max.x - thumb_pad;
300 let track_span = (track_right - track_left).max(1.0);
301 let track_y = rect.min.y + strip_h * 0.5;
304
305 let to_x = |v: f64| -> f32 {
306 let frac = ((v - range_lo) / span).clamp(0.0, 1.0) as f32;
307 track_left + track_span * frac
308 };
309 let to_value = |x: f32| -> f64 {
310 let frac = ((x - track_left) / track_span).clamp(0.0, 1.0) as f64;
311 range_lo + frac * span
312 };
313 let snap = |mut v: f64| -> f64 {
314 if let Some(s) = step {
315 if s > 0.0 {
316 v = range_lo + ((v - range_lo) / s).round() * s;
317 }
318 }
319 v.clamp(range_lo, range_hi)
320 };
321
322 let thumb_x = [to_x(low_v), to_x(high_v)];
323 let thumb_centers = [
324 Pos2::new(thumb_x[0], track_y),
325 Pos2::new(thumb_x[1], track_y),
326 ];
327
328 let thumb_hit = thumb_d.max(20.0);
330 let thumb_rects = [
331 Rect::from_center_size(thumb_centers[0], Vec2::splat(thumb_hit)),
332 Rect::from_center_size(thumb_centers[1], Vec2::splat(thumb_hit)),
333 ];
334 let thumb_sense = if self.enabled {
335 Sense::click_and_drag()
336 } else {
337 Sense::hover()
338 };
339 let thumb_resp = [
340 ui.interact(thumb_rects[0], id_salt.with("low"), thumb_sense),
341 ui.interact(thumb_rects[1], id_salt.with("high"), thumb_sense),
342 ];
343
344 let mut new_low = low_v;
345 let mut new_high = high_v;
346 let mut changed = false;
347
348 let mut handled_thumb_drag = false;
350 for (i, resp) in thumb_resp.iter().enumerate() {
351 if self.enabled && resp.is_pointer_button_down_on() {
352 if let Some(pos) = resp.interact_pointer_pos() {
353 let v = snap(to_value(pos.x));
354 apply_to_endpoint(&mut new_low, &mut new_high, i, v);
355 changed = true;
356 handled_thumb_drag = true;
357 }
358 }
359 }
360
361 if self.enabled && bg_resp.is_pointer_button_down_on() && !handled_thumb_drag {
365 if let Some(pos) = bg_resp.interact_pointer_pos() {
366 let v = to_value(pos.x);
367 let stored: Option<usize> = ui.ctx().data(|d| d.get_temp(drag_state_id));
368 let idx = match stored {
369 Some(i) => i,
370 None => {
371 let i = if (v - low_v).abs() <= (v - high_v).abs() {
372 0
373 } else {
374 1
375 };
376 ui.ctx().data_mut(|d| d.insert_temp(drag_state_id, i));
377 thumb_resp[i].request_focus();
378 i
379 }
380 };
381 let snapped = snap(v);
382 apply_to_endpoint(&mut new_low, &mut new_high, idx, snapped);
383 changed = true;
384 }
385 } else {
386 ui.ctx().data_mut(|d| d.remove::<usize>(drag_state_id));
387 }
388
389 if self.enabled {
391 for (i, resp) in thumb_resp.iter().enumerate() {
392 if !resp.has_focus() {
393 continue;
394 }
395 let small_step = step.unwrap_or(span * 0.01);
396 let big_step = step.map(|s| s * 10.0).unwrap_or(span * 0.1);
397 let events = ui.input(|input| input.events.clone());
398 for ev in &events {
399 if let Event::Key {
400 key,
401 pressed: true,
402 modifiers,
403 ..
404 } = ev
405 {
406 let cur = if i == 0 { new_low } else { new_high };
407 let next = match key {
408 Key::ArrowLeft | Key::ArrowDown => Some(
409 cur - if modifiers.shift {
410 big_step
411 } else {
412 small_step
413 },
414 ),
415 Key::ArrowRight | Key::ArrowUp => Some(
416 cur + if modifiers.shift {
417 big_step
418 } else {
419 small_step
420 },
421 ),
422 Key::Home => Some(range_lo),
423 Key::End => Some(range_hi),
424 _ => None,
425 };
426 if let Some(v) = next {
427 let v = snap(v);
428 apply_to_endpoint(&mut new_low, &mut new_high, i, v);
429 changed = true;
430 }
431 }
432 }
433 }
434 }
435
436 if self.enabled {
438 let any_hovered =
439 bg_resp.hovered() || thumb_resp[0].hovered() || thumb_resp[1].hovered();
440 let any_pressed = bg_resp.is_pointer_button_down_on()
441 || thumb_resp[0].is_pointer_button_down_on()
442 || thumb_resp[1].is_pointer_button_down_on();
443 if any_pressed {
444 ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
445 } else if any_hovered {
446 ui.ctx().set_cursor_icon(CursorIcon::Grab);
447 }
448 }
449
450 if changed {
452 if (new_low - low_v).abs() > f64::EPSILON {
453 *self.low = T::from_f64(new_low);
454 low_v = new_low;
455 }
456 if (new_high - high_v).abs() > f64::EPSILON {
457 *self.high = T::from_f64(new_high);
458 high_v = new_high;
459 }
460 }
461
462 if ui.is_rect_visible(rect) {
464 let painter = ui.painter();
465 let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
466 let track_rect = Rect::from_min_max(
467 Pos2::new(rect.min.x, track_y - track_h * 0.5),
468 Pos2::new(rect.max.x, track_y + track_h * 0.5),
469 );
470
471 painter.rect(
473 track_rect,
474 track_radius,
475 p.input_bg,
476 Stroke::new(1.0, p.border),
477 StrokeKind::Inside,
478 );
479
480 let lo_x = to_x(low_v);
482 let hi_x = to_x(high_v);
483 if hi_x > lo_x + 0.5 {
484 let fill_rect = Rect::from_min_max(
485 Pos2::new(lo_x, track_rect.min.y),
486 Pos2::new(hi_x, track_rect.max.y),
487 );
488 let fill = if self.enabled {
489 accent_fill
490 } else {
491 mix(accent_fill, p.card, 0.55)
492 };
493 painter.rect_filled(fill_rect, track_radius, fill);
494 }
495
496 if let Some(n) = self.ticks {
498 if n >= 2 {
499 for i in 0..n {
500 let frac = i as f32 / (n - 1) as f32;
501 let x = track_left + track_span * frac;
502 let v = range_lo + (frac as f64) * span;
503 let inside_fill = v >= low_v && v <= high_v;
504 let color = if inside_fill {
505 p.text_muted
506 } else {
507 p.text_faint
508 };
509 painter.line_segment(
510 [
511 Pos2::new(x, track_y - track_h * 0.5 - 3.0),
512 Pos2::new(x, track_y - track_h * 0.5 - 7.0),
513 ],
514 Stroke::new(1.0, color),
515 );
516
517 if self.show_tick_labels {
518 let label = self.format_value(v);
519 let galley = crate::theme::placeholder_galley(
520 ui,
521 &label,
522 t.small,
523 false,
524 f32::INFINITY,
525 );
526 let pos = Pos2::new(
527 x - galley.size().x * 0.5,
528 track_y + track_h * 0.5 + 4.0,
529 );
530 painter.galley(pos, galley, p.text_faint);
531 }
532 }
533 }
534 }
535
536 for i in 0..2 {
538 let center = thumb_centers[i];
539 let active = self.enabled
540 && (thumb_resp[i].has_focus() || thumb_resp[i].is_pointer_button_down_on());
541 if active {
542 painter.circle_filled(
543 center,
544 thumb_d * 0.5 + 4.0,
545 with_alpha(accent_fill, 55),
546 );
547 }
548 let (fill, ring) = if !self.enabled {
549 (mix(p.text, p.card, 0.5), Stroke::new(1.0, p.border))
550 } else {
551 (p.text, Stroke::new(2.0, accent_fill))
552 };
553 painter.circle(center, thumb_d * 0.5, fill, ring);
554 }
555 }
556
557 for (i, side) in ["low", "high"].iter().enumerate() {
559 let v = if i == 0 { low_v } else { high_v };
560 let label = if label_text.is_empty() {
561 (*side).to_string()
562 } else {
563 format!("{label_text} ({side})")
564 };
565 let resp = thumb_resp[i].clone();
566 resp.widget_info(|| {
567 let mut info = WidgetInfo::labeled(WidgetType::Slider, self.enabled, &label);
568 info.value = Some(v);
569 info
570 });
571 }
572
573 let mut combined = bg_resp;
574 combined |= thumb_resp[0].clone();
575 combined |= thumb_resp[1].clone();
576 if changed {
577 combined.mark_changed();
578 }
579 combined
580 })
581 .inner
582 }
583}
584
585fn apply_to_endpoint(low: &mut f64, high: &mut f64, idx: usize, v: f64) {
586 if idx == 0 {
587 *low = v.min(*high);
588 } else {
589 *high = v.max(*low);
590 }
591}