1use crate::ext::ArmasContextExt;
6use egui::{pos2, vec2, Color32, Rect, Response, Sense, Stroke, Ui};
7
8#[derive(Clone, Copy, Debug, PartialEq, Default)]
10enum DragTarget {
11 #[default]
12 None,
13 Min,
14 Max,
15 Both,
16}
17
18#[derive(Clone, Default)]
20struct RangeSliderDragState {
21 target: DragTarget,
22 drag_start_min: f32,
23 drag_start_max: f32,
24 drag_start_x: f32,
25}
26
27struct SliderGeometry<'a> {
29 track_rect: &'a Rect,
30 thumb_radius: f32,
31 min_x: f32,
32 max_x: f32,
33}
34
35pub struct RangeSlider {
52 id: Option<egui::Id>,
53 range_min: f32,
54 range_max: f32,
55 width: f32,
56 height: f32,
57 show_value: bool,
58 label: Option<String>,
59 suffix: Option<String>,
60 step: Option<f32>,
61 min_gap: f32,
62 allow_range_drag: bool,
63}
64
65impl RangeSlider {
66 #[must_use]
68 pub const fn new(range_min: f32, range_max: f32) -> Self {
69 Self {
70 id: None,
71 range_min,
72 range_max,
73 width: 200.0,
74 height: 20.0,
75 show_value: true,
76 label: None,
77 suffix: None,
78 step: None,
79 min_gap: 0.0,
80 allow_range_drag: true,
81 }
82 }
83
84 #[must_use]
86 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
87 self.id = Some(id.into());
88 self
89 }
90
91 #[must_use]
93 pub const fn width(mut self, width: f32) -> Self {
94 self.width = width;
95 self
96 }
97
98 #[must_use]
100 pub const fn height(mut self, height: f32) -> Self {
101 self.height = height;
102 self
103 }
104
105 #[must_use]
107 pub const fn show_value(mut self, show: bool) -> Self {
108 self.show_value = show;
109 self
110 }
111
112 #[must_use]
114 pub fn label(mut self, label: impl Into<String>) -> Self {
115 self.label = Some(label.into());
116 self
117 }
118
119 #[must_use]
121 pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
122 self.suffix = Some(suffix.into());
123 self
124 }
125
126 #[must_use]
128 pub const fn step(mut self, step: f32) -> Self {
129 self.step = Some(step);
130 self
131 }
132
133 #[must_use]
135 pub const fn min_gap(mut self, gap: f32) -> Self {
136 self.min_gap = gap;
137 self
138 }
139
140 #[must_use]
142 pub const fn allow_range_drag(mut self, allow: bool) -> Self {
143 self.allow_range_drag = allow;
144 self
145 }
146
147 pub fn show(
149 self,
150 ui: &mut Ui,
151 min_value: &mut f32,
152 max_value: &mut f32,
153 ) -> RangeSliderResponse {
154 let theme = ui.ctx().armas_theme();
155 let mut changed = false;
156
157 let slider_id = self
158 .id
159 .unwrap_or_else(|| ui.make_persistent_id("range_slider"));
160 let drag_state_id = slider_id.with("drag_state");
161
162 if let Some(id) = self.id {
164 let min_state_id = id.with("min_value");
165 let max_state_id = id.with("max_value");
166 *min_value = ui
167 .ctx()
168 .data_mut(|d| d.get_temp(min_state_id).unwrap_or(*min_value));
169 *max_value = ui
170 .ctx()
171 .data_mut(|d| d.get_temp(max_state_id).unwrap_or(*max_value));
172 }
173
174 self.clamp_values(min_value, max_value);
176
177 ui.vertical(|ui| {
178 ui.spacing_mut().item_spacing.y = 4.0;
179
180 self.draw_label(ui, *min_value, *max_value);
182
183 let (rect, response) =
185 ui.allocate_exact_size(vec2(self.width, self.height), Sense::click_and_drag());
186
187 let track_height = 6.0;
189 let thumb_radius = 8.0;
190 let track_rect =
191 Rect::from_center_size(rect.center(), vec2(rect.width(), track_height));
192
193 let min_x = self.value_to_x(*min_value, &track_rect);
195 let max_x = self.value_to_x(*max_value, &track_rect);
196
197 let geometry = SliderGeometry {
198 track_rect: &track_rect,
199 thumb_radius,
200 min_x,
201 max_x,
202 };
203
204 let drag_state = self.handle_interaction(
206 ui,
207 &response,
208 drag_state_id,
209 &geometry,
210 min_value,
211 max_value,
212 &mut changed,
213 );
214
215 let hovered_thumb = if response.hovered() {
217 response.hover_pos().and_then(|pos| {
218 let dist_to_min = (pos.x - geometry.min_x).abs();
219 let dist_to_max = (pos.x - geometry.max_x).abs();
220 if dist_to_min <= geometry.thumb_radius {
221 Some(DragTarget::Min)
222 } else if dist_to_max <= geometry.thumb_radius {
223 Some(DragTarget::Max)
224 } else {
225 None
226 }
227 })
228 } else {
229 None
230 };
231
232 self.draw(
234 ui,
235 &response,
236 &geometry,
237 track_height,
238 &drag_state,
239 hovered_thumb,
240 &theme,
241 );
242 });
243
244 if let Some(id) = self.id {
246 let min_state_id = id.with("min_value");
247 let max_state_id = id.with("max_value");
248 ui.ctx().data_mut(|d| {
249 d.insert_temp(min_state_id, *min_value);
250 d.insert_temp(max_state_id, *max_value);
251 });
252 }
253
254 let response = ui.interact(ui.min_rect(), slider_id.with("response"), Sense::hover());
255
256 RangeSliderResponse {
257 response,
258 min_value: *min_value,
259 max_value: *max_value,
260 changed,
261 }
262 }
263
264 fn clamp_values(&self, min_value: &mut f32, max_value: &mut f32) {
265 *min_value = min_value.clamp(self.range_min, self.range_max);
266 *max_value = max_value.clamp(self.range_min, self.range_max);
267 if *min_value > *max_value {
268 std::mem::swap(min_value, max_value);
269 }
270 }
271
272 fn draw_label(&self, ui: &mut Ui, min_value: f32, max_value: f32) {
273 if self.label.is_none() && !self.show_value {
274 return;
275 }
276
277 ui.horizontal(|ui| {
278 ui.spacing_mut().item_spacing.x = 8.0;
279
280 if let Some(label) = &self.label {
281 ui.label(label);
282 }
283
284 if self.show_value {
285 ui.allocate_space(ui.available_size());
286 ui.label(format!(
287 "{} - {}",
288 self.format_value(min_value),
289 self.format_value(max_value)
290 ));
291 }
292 });
293 }
294
295 fn format_value(&self, value: f32) -> String {
296 self.suffix.as_ref().map_or_else(
297 || format!("{value:.1}"),
298 |suffix| format!("{value:.1}{suffix}"),
299 )
300 }
301
302 fn apply_step(&self, value: f32) -> f32 {
303 self.step
304 .map_or(value, |step| (value / step).round() * step)
305 }
306
307 fn value_to_x(&self, value: f32, track_rect: &Rect) -> f32 {
308 let t = (value - self.range_min) / (self.range_max - self.range_min);
309 track_rect.left() + t * track_rect.width()
310 }
311
312 fn x_to_value(&self, x: f32, track_rect: &Rect) -> f32 {
313 let t = ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0);
314 self.range_min + t * (self.range_max - self.range_min)
315 }
316
317 fn determine_target(
318 &self,
319 pos_x: f32,
320 min_x: f32,
321 max_x: f32,
322 handle_radius: f32,
323 ) -> DragTarget {
324 let dist_to_min = (pos_x - min_x).abs();
325 let dist_to_max = (pos_x - max_x).abs();
326 let in_range = pos_x > min_x + handle_radius && pos_x < max_x - handle_radius;
327
328 if self.allow_range_drag
329 && in_range
330 && dist_to_min > handle_radius
331 && dist_to_max > handle_radius
332 {
333 DragTarget::Both
334 } else if dist_to_min <= dist_to_max {
335 DragTarget::Min
336 } else {
337 DragTarget::Max
338 }
339 }
340
341 fn handle_interaction(
342 &self,
343 ui: &mut Ui,
344 response: &Response,
345 drag_state_id: egui::Id,
346 geometry: &SliderGeometry,
347 min_value: &mut f32,
348 max_value: &mut f32,
349 changed: &mut bool,
350 ) -> RangeSliderDragState {
351 let mut drag_state: RangeSliderDragState = ui
352 .ctx()
353 .data_mut(|d| d.get_temp(drag_state_id).unwrap_or_default());
354
355 if response.drag_started() {
357 if let Some(pos) = response.interact_pointer_pos() {
358 drag_state.target = self.determine_target(
359 pos.x,
360 geometry.min_x,
361 geometry.max_x,
362 geometry.thumb_radius,
363 );
364 drag_state.drag_start_min = *min_value;
365 drag_state.drag_start_max = *max_value;
366 drag_state.drag_start_x = pos.x;
367 }
368 }
369
370 if response.dragged() {
372 if let Some(pos) = response.interact_pointer_pos() {
373 if drag_state.target == DragTarget::None {
375 drag_state.target = self.determine_target(
376 pos.x,
377 geometry.min_x,
378 geometry.max_x,
379 geometry.thumb_radius,
380 );
381 drag_state.drag_start_min = *min_value;
382 drag_state.drag_start_max = *max_value;
383 drag_state.drag_start_x = pos.x;
384 }
385
386 self.update_values_from_drag(
387 pos.x,
388 geometry.track_rect,
389 &drag_state,
390 min_value,
391 max_value,
392 changed,
393 );
394 }
395 }
396
397 if response.drag_stopped() {
399 drag_state.target = DragTarget::None;
400 }
401
402 ui.ctx()
404 .data_mut(|d| d.insert_temp(drag_state_id, drag_state.clone()));
405
406 drag_state
407 }
408
409 fn update_values_from_drag(
410 &self,
411 pos_x: f32,
412 track_rect: &Rect,
413 drag_state: &RangeSliderDragState,
414 min_value: &mut f32,
415 max_value: &mut f32,
416 changed: &mut bool,
417 ) {
418 let raw_value = self.x_to_value(pos_x, track_rect);
419
420 match drag_state.target {
421 DragTarget::Min => {
422 let new_value = self
423 .apply_step(raw_value)
424 .clamp(self.range_min, *max_value - self.min_gap);
425
426 if (new_value - *min_value).abs() > 0.001 {
427 *min_value = new_value;
428 *changed = true;
429 }
430 }
431 DragTarget::Max => {
432 let new_value = self
433 .apply_step(raw_value)
434 .clamp(*min_value + self.min_gap, self.range_max);
435
436 if (new_value - *max_value).abs() > 0.001 {
437 *max_value = new_value;
438 *changed = true;
439 }
440 }
441 DragTarget::Both => {
442 let delta_x = pos_x - drag_state.drag_start_x;
443 let delta_value = delta_x / track_rect.width() * (self.range_max - self.range_min);
444
445 let range_size = drag_state.drag_start_max - drag_state.drag_start_min;
446 let mut new_min = drag_state.drag_start_min + delta_value;
447 #[allow(clippy::useless_let_if_seq)]
448 let mut new_max = drag_state.drag_start_max + delta_value;
449
450 if new_min < self.range_min {
452 new_min = self.range_min;
453 new_max = self.range_min + range_size;
454 }
455 if new_max > self.range_max {
456 new_max = self.range_max;
457 new_min = self.range_max - range_size;
458 }
459
460 new_min = self.apply_step(new_min);
461 new_max = self.apply_step(new_max);
462
463 if (new_min - *min_value).abs() > 0.001 || (new_max - *max_value).abs() > 0.001 {
464 *min_value = new_min;
465 *max_value = new_max;
466 *changed = true;
467 }
468 }
469 DragTarget::None => {}
470 }
471 }
472
473 fn draw(
474 &self,
475 ui: &Ui,
476 response: &Response,
477 geometry: &SliderGeometry,
478 track_height: f32,
479 drag_state: &RangeSliderDragState,
480 hovered_thumb: Option<DragTarget>,
481 theme: &crate::Theme,
482 ) {
483 let painter = ui.painter();
484
485 painter.rect_filled(*geometry.track_rect, track_height / 2.0, theme.muted());
487
488 let fill_rect = Rect::from_min_max(
490 pos2(geometry.min_x, geometry.track_rect.top()),
491 pos2(geometry.max_x, geometry.track_rect.bottom()),
492 );
493 painter.rect_filled(fill_rect, track_height / 2.0, theme.primary());
494
495 for (x, is_min) in [(geometry.min_x, true), (geometry.max_x, false)] {
497 let center = pos2(x, geometry.track_rect.center().y);
498 let this_target = if is_min {
499 DragTarget::Min
500 } else {
501 DragTarget::Max
502 };
503
504 let is_active = response.dragged()
506 && (drag_state.target == this_target || drag_state.target == DragTarget::Both);
507
508 let is_hovered = hovered_thumb == Some(this_target);
510
511 if is_active || is_hovered {
513 let ring_color = theme.ring().gamma_multiply(0.5);
514 painter.circle_filled(center, geometry.thumb_radius + 4.0, ring_color);
515 }
516
517 painter.circle_filled(
519 center + vec2(0.0, 1.0),
520 geometry.thumb_radius,
521 Color32::from_black_alpha(40),
522 );
523
524 let handle_color = if is_active {
526 theme.primary()
527 } else {
528 theme.foreground()
529 };
530
531 painter.circle_filled(center, geometry.thumb_radius, handle_color);
532 painter.circle_stroke(
533 center,
534 geometry.thumb_radius,
535 Stroke::new(1.0, theme.primary()),
536 );
537 }
538 }
539}
540
541pub struct RangeSliderResponse {
543 pub response: Response,
545 pub min_value: f32,
547 pub max_value: f32,
549 pub changed: bool,
551}