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