1use std::ops::RangeInclusive;
8
9use egui::{
10 Color32, CornerRadius, CursorIcon, Event, EventFilter, Key, Painter, Pos2, Rect, Response,
11 Role, Sense, Stroke, StrokeKind, Ui, Vec2, Widget, WidgetInfo, WidgetText, emath::Numeric,
12};
13
14use crate::theme::{Accent, Theme, with_alpha};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum SliderHandle {
24 #[default]
27 Circle,
28 Line,
32}
33
34pub(crate) const LINE_HANDLE_W: f32 = 3.0;
36pub(crate) const LINE_HANDLE_H: f32 = 18.0;
40
41pub(crate) fn paint_handle(
49 painter: &Painter,
50 handle: SliderHandle,
51 center: Pos2,
52 thumb_d: f32,
53 body: Color32,
54 accent_stroke: Stroke,
55 halo: Option<Color32>,
56) {
57 match handle {
58 SliderHandle::Circle => {
59 if let Some(c) = halo {
60 painter.circle_filled(center, thumb_d * 0.5 + 4.0, c);
61 }
62 painter.circle(center, thumb_d * 0.5, body, accent_stroke);
63 }
64 SliderHandle::Line => {
65 if let Some(c) = halo {
66 let halo_rect = Rect::from_center_size(
67 center,
68 Vec2::new(LINE_HANDLE_W + 10.0, LINE_HANDLE_H + 8.0),
69 );
70 painter.rect_filled(halo_rect, CornerRadius::same(6), c);
71 }
72 let bar = Rect::from_center_size(center, Vec2::new(LINE_HANDLE_W, LINE_HANDLE_H));
73 painter.rect_filled(bar, CornerRadius::same(1), body);
74 }
75 }
76}
77
78#[must_use = "Add with `ui.add(...)`."]
88pub struct Slider<'a, T: Numeric> {
89 value: &'a mut T,
90 range: RangeInclusive<T>,
91 label: Option<WidgetText>,
92 suffix: String,
93 decimals: Option<usize>,
94 value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
95 show_value: bool,
96 step: Option<f64>,
97 accent: Accent,
98 desired_width: Option<f32>,
99 handle: SliderHandle,
100}
101
102impl<'a, T: Numeric> std::fmt::Debug for Slider<'a, T> {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.debug_struct("Slider")
105 .field("range_lo", &self.range.start().to_f64())
106 .field("range_hi", &self.range.end().to_f64())
107 .field("suffix", &self.suffix)
108 .field("decimals", &self.decimals)
109 .field("show_value", &self.show_value)
110 .field("step", &self.step)
111 .field("accent", &self.accent)
112 .field("desired_width", &self.desired_width)
113 .field("handle", &self.handle)
114 .finish()
115 }
116}
117
118impl<'a, T: Numeric> Slider<'a, T> {
119 pub fn new(value: &'a mut T, range: RangeInclusive<T>) -> Self {
121 Self {
122 value,
123 range,
124 label: None,
125 suffix: String::new(),
126 decimals: None,
127 value_fmt: None,
128 show_value: true,
129 step: None,
130 accent: Accent::Sky,
131 desired_width: None,
132 handle: SliderHandle::Circle,
133 }
134 }
135
136 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
142 self.label = Some(label.into());
143 self
144 }
145
146 pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
148 self.suffix = suffix.into();
149 self
150 }
151
152 pub fn decimals(mut self, n: usize) -> Self {
155 self.decimals = Some(n);
156 self
157 }
158
159 pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
162 self.value_fmt = Some(Box::new(fmt));
163 self
164 }
165
166 pub fn show_value(mut self, show: bool) -> Self {
168 self.show_value = show;
169 self
170 }
171
172 pub fn step(mut self, step: f64) -> Self {
175 self.step = Some(step);
176 self
177 }
178
179 pub fn accent(mut self, accent: Accent) -> Self {
181 self.accent = accent;
182 self
183 }
184
185 pub fn desired_width(mut self, width: f32) -> Self {
187 self.desired_width = Some(width);
188 self
189 }
190
191 pub fn handle(mut self, handle: SliderHandle) -> Self {
195 self.handle = handle;
196 self
197 }
198
199 fn format_value(&self, v: f64) -> String {
200 if let Some(fmt) = &self.value_fmt {
201 return fmt(v);
202 }
203 let n = self.decimals.unwrap_or(if T::INTEGRAL { 0 } else { 2 });
204 if self.suffix.is_empty() {
205 format!("{v:.n$}")
206 } else {
207 format!("{v:.n$}{}", self.suffix)
208 }
209 }
210}
211
212impl<'a, T: Numeric> Widget for Slider<'a, T> {
213 fn ui(self, ui: &mut Ui) -> Response {
214 let theme = Theme::current(ui.ctx());
215 let p = &theme.palette;
216 let t = &theme.typography;
217 let accent_fill = p.accent_fill(self.accent);
218
219 let lo_raw = self.range.start().to_f64();
220 let hi_raw = self.range.end().to_f64();
221 let (lo, hi) = if lo_raw <= hi_raw {
222 (lo_raw, hi_raw)
223 } else {
224 (hi_raw, lo_raw)
225 };
226
227 let mut current = self.value.to_f64();
228 if current.is_nan() {
229 current = lo;
230 }
231 current = current.clamp(lo, hi);
232
233 let step = self.step.or(if T::INTEGRAL { Some(1.0) } else { None });
234
235 let track_h: f32 = 6.0;
236 let thumb_d: f32 = 14.0;
237 let row_h = thumb_d.max(t.label + 2.0);
238 let value_gap: f32 = 10.0;
239
240 let value_reserve = if self.show_value {
241 let lo_text = self.format_value(lo);
242 let hi_text = self.format_value(hi);
243 let w_lo =
244 crate::theme::placeholder_galley(ui, &lo_text, t.label, false, f32::INFINITY)
245 .size()
246 .x;
247 let w_hi =
248 crate::theme::placeholder_galley(ui, &hi_text, t.label, false, f32::INFINITY)
249 .size()
250 .x;
251 w_lo.max(w_hi).ceil() + value_gap
252 } else {
253 0.0
254 };
255
256 let label_text = self
257 .label
258 .as_ref()
259 .map(|l| l.text().to_string())
260 .unwrap_or_default();
261
262 ui.vertical(|ui| {
263 if !label_text.is_empty() {
264 ui.add_space(2.0);
265 let rich = egui::RichText::new(&label_text)
266 .color(p.text_muted)
267 .size(t.label);
268 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
269 ui.add_space(2.0);
270 }
271
272 let total_w = self
273 .desired_width
274 .unwrap_or_else(|| ui.available_width())
275 .max(value_reserve + thumb_d * 2.0);
276 let (rect, mut response) =
277 ui.allocate_exact_size(Vec2::new(total_w, row_h), Sense::click_and_drag());
278
279 let track_w = (total_w - value_reserve).max(thumb_d);
280 let thumb_pad = thumb_d * 0.5;
281 let track_left = rect.min.x + thumb_pad;
282 let track_right = rect.min.x + track_w - thumb_pad;
283 let track_span = (track_right - track_left).max(1.0);
284 let track_y = rect.center().y;
285 let track_rect = Rect::from_min_max(
286 Pos2::new(rect.min.x, track_y - track_h * 0.5),
287 Pos2::new(rect.min.x + track_w, track_y + track_h * 0.5),
288 );
289
290 crate::focus::focus_on_press(&response);
291
292 if response.is_pointer_button_down_on()
294 && let Some(pos) = response.interact_pointer_pos()
295 {
296 let clamped_x = pos.x.clamp(track_left, track_right);
297 let frac = ((clamped_x - track_left) / track_span).clamp(0.0, 1.0) as f64;
298 let mut new_value = lo + frac * (hi - lo);
299 if let Some(step) = step
300 && step > 0.0
301 {
302 new_value = lo + ((new_value - lo) / step).round() * step;
303 }
304 new_value = new_value.clamp(lo, hi);
305 if (new_value - current).abs() > f64::EPSILON {
306 current = new_value;
307 *self.value = T::from_f64(current);
308 response.mark_changed();
309 }
310 }
311
312 if response.has_focus() {
323 ui.memory_mut(|m| {
324 m.set_focus_lock_filter(
325 response.id,
326 EventFilter {
327 horizontal_arrows: true,
328 ..Default::default()
329 },
330 );
331 });
332
333 let span = hi - lo;
334 let small_step = step.unwrap_or(span * 0.01);
335 let big_step = step.map(|s| s * 10.0).unwrap_or(span * 0.1);
336 let events = ui.input(|input| input.events.clone());
337 for ev in &events {
338 if let Event::Key {
339 key,
340 pressed: true,
341 modifiers,
342 ..
343 } = ev
344 {
345 let nudge = if modifiers.shift {
346 big_step
347 } else {
348 small_step
349 };
350 let next = match key {
351 Key::ArrowLeft => Some(current - nudge),
352 Key::ArrowRight => Some(current + nudge),
353 Key::Home => Some(lo),
354 Key::End => Some(hi),
355 _ => None,
356 };
357 if let Some(mut new_value) = next {
358 if let Some(s) = step
359 && s > 0.0
360 {
361 new_value = lo + ((new_value - lo) / s).round() * s;
362 }
363 new_value = new_value.clamp(lo, hi);
364 if (new_value - current).abs() > f64::EPSILON {
365 current = new_value;
366 *self.value = T::from_f64(current);
367 response.mark_changed();
368 crate::commit::mark_commit(ui.ctx(), response.id);
371 }
372 }
373 }
374 }
375 }
376
377 if response.hovered() {
378 ui.ctx().set_cursor_icon(CursorIcon::Grab);
379 }
380 if response.is_pointer_button_down_on() {
381 ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
382 }
383
384 if ui.is_rect_visible(rect) {
385 let frac = if hi > lo {
386 ((current - lo) / (hi - lo)).clamp(0.0, 1.0) as f32
387 } else {
388 0.0
389 };
390 let thumb_x = track_left + track_span * frac;
391 let thumb_center = Pos2::new(thumb_x, track_y);
392
393 let painter = ui.painter();
394 let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
395
396 painter.rect(
398 track_rect,
399 track_radius,
400 p.input_bg,
401 Stroke::new(1.0, p.border),
402 StrokeKind::Inside,
403 );
404
405 if thumb_x > track_rect.min.x + 0.5 {
407 let fill_rect = Rect::from_min_max(
408 Pos2::new(track_rect.min.x, track_rect.min.y),
409 Pos2::new(thumb_x, track_rect.max.y),
410 );
411 painter.rect_filled(fill_rect, track_radius, accent_fill);
412 }
413
414 let active = response.has_focus() || response.is_pointer_button_down_on();
415 let halo = active.then(|| with_alpha(accent_fill, 55));
416 let line_body = if p.is_dark {
417 p.text
418 } else {
419 p.accent_hover(self.accent)
420 };
421 let (body, ring) = match self.handle {
422 SliderHandle::Circle => (p.text, Stroke::new(2.0, accent_fill)),
423 SliderHandle::Line => (line_body, Stroke::NONE),
424 };
425 paint_handle(
426 painter,
427 self.handle,
428 thumb_center,
429 thumb_d,
430 body,
431 ring,
432 halo,
433 );
434
435 if self.show_value {
436 let text = self.format_value(current);
437 let galley =
438 crate::theme::placeholder_galley(ui, &text, t.label, false, f32::INFINITY);
439 let text_pos = Pos2::new(
440 rect.max.x - galley.size().x,
441 rect.center().y - galley.size().y * 0.5,
442 );
443 painter.galley(text_pos, galley, p.text);
444 }
445 }
446
447 response.widget_info(|| WidgetInfo::labeled(Role::Slider, true, &label_text));
448 response
449 })
450 .inner
451 }
452}