1use std::ops::RangeInclusive;
8
9use egui::{
10 emath::Numeric, CornerRadius, CursorIcon, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
11 Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
12};
13
14use crate::theme::{with_alpha, Accent, Theme};
15
16#[must_use = "Add with `ui.add(...)`."]
26pub struct Slider<'a, T: Numeric> {
27 value: &'a mut T,
28 range: RangeInclusive<T>,
29 label: Option<WidgetText>,
30 suffix: String,
31 decimals: Option<usize>,
32 value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
33 show_value: bool,
34 step: Option<f64>,
35 accent: Accent,
36 desired_width: Option<f32>,
37}
38
39impl<'a, T: Numeric> std::fmt::Debug for Slider<'a, T> {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("Slider")
42 .field("range_lo", &self.range.start().to_f64())
43 .field("range_hi", &self.range.end().to_f64())
44 .field("suffix", &self.suffix)
45 .field("decimals", &self.decimals)
46 .field("show_value", &self.show_value)
47 .field("step", &self.step)
48 .field("accent", &self.accent)
49 .field("desired_width", &self.desired_width)
50 .finish()
51 }
52}
53
54impl<'a, T: Numeric> Slider<'a, T> {
55 pub fn new(value: &'a mut T, range: RangeInclusive<T>) -> Self {
57 Self {
58 value,
59 range,
60 label: None,
61 suffix: String::new(),
62 decimals: None,
63 value_fmt: None,
64 show_value: true,
65 step: None,
66 accent: Accent::Sky,
67 desired_width: None,
68 }
69 }
70
71 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
73 self.label = Some(label.into());
74 self
75 }
76
77 pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
79 self.suffix = suffix.into();
80 self
81 }
82
83 pub fn decimals(mut self, n: usize) -> Self {
86 self.decimals = Some(n);
87 self
88 }
89
90 pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
93 self.value_fmt = Some(Box::new(fmt));
94 self
95 }
96
97 pub fn show_value(mut self, show: bool) -> Self {
99 self.show_value = show;
100 self
101 }
102
103 pub fn step(mut self, step: f64) -> Self {
106 self.step = Some(step);
107 self
108 }
109
110 pub fn accent(mut self, accent: Accent) -> Self {
112 self.accent = accent;
113 self
114 }
115
116 pub fn desired_width(mut self, width: f32) -> Self {
118 self.desired_width = Some(width);
119 self
120 }
121
122 fn format_value(&self, v: f64) -> String {
123 if let Some(fmt) = &self.value_fmt {
124 return fmt(v);
125 }
126 let n = self.decimals.unwrap_or(if T::INTEGRAL { 0 } else { 2 });
127 if self.suffix.is_empty() {
128 format!("{v:.n$}")
129 } else {
130 format!("{v:.n$}{}", self.suffix)
131 }
132 }
133}
134
135impl<'a, T: Numeric> Widget for Slider<'a, T> {
136 fn ui(self, ui: &mut Ui) -> Response {
137 let theme = Theme::current(ui.ctx());
138 let p = &theme.palette;
139 let t = &theme.typography;
140 let accent_fill = p.accent_fill(self.accent);
141
142 let lo_raw = self.range.start().to_f64();
143 let hi_raw = self.range.end().to_f64();
144 let (lo, hi) = if lo_raw <= hi_raw {
145 (lo_raw, hi_raw)
146 } else {
147 (hi_raw, lo_raw)
148 };
149
150 let mut current = self.value.to_f64();
151 if current.is_nan() {
152 current = lo;
153 }
154 current = current.clamp(lo, hi);
155
156 let step = self.step.or(if T::INTEGRAL { Some(1.0) } else { None });
157
158 let track_h: f32 = 6.0;
159 let thumb_d: f32 = 14.0;
160 let row_h = thumb_d.max(t.label + 2.0);
161 let value_gap: f32 = 10.0;
162
163 let value_reserve = if self.show_value {
164 let lo_text = self.format_value(lo);
165 let hi_text = self.format_value(hi);
166 let w_lo =
167 crate::theme::placeholder_galley(ui, &lo_text, t.label, false, f32::INFINITY)
168 .size()
169 .x;
170 let w_hi =
171 crate::theme::placeholder_galley(ui, &hi_text, t.label, false, f32::INFINITY)
172 .size()
173 .x;
174 w_lo.max(w_hi).ceil() + value_gap
175 } else {
176 0.0
177 };
178
179 let label_text = self
180 .label
181 .as_ref()
182 .map(|l| l.text().to_string())
183 .unwrap_or_default();
184
185 ui.vertical(|ui| {
186 if !label_text.is_empty() {
187 ui.add_space(2.0);
188 let rich = egui::RichText::new(&label_text)
189 .color(p.text_muted)
190 .size(t.label);
191 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
192 ui.add_space(2.0);
193 }
194
195 let total_w = self
196 .desired_width
197 .unwrap_or_else(|| ui.available_width())
198 .max(value_reserve + thumb_d * 2.0);
199 let (rect, mut response) =
200 ui.allocate_exact_size(Vec2::new(total_w, row_h), Sense::click_and_drag());
201
202 let track_w = (total_w - value_reserve).max(thumb_d);
203 let thumb_pad = thumb_d * 0.5;
204 let track_left = rect.min.x + thumb_pad;
205 let track_right = rect.min.x + track_w - thumb_pad;
206 let track_span = (track_right - track_left).max(1.0);
207 let track_y = rect.center().y;
208 let track_rect = Rect::from_min_max(
209 Pos2::new(rect.min.x, track_y - track_h * 0.5),
210 Pos2::new(rect.min.x + track_w, track_y + track_h * 0.5),
211 );
212
213 if response.is_pointer_button_down_on() {
215 if let Some(pos) = response.interact_pointer_pos() {
216 let clamped_x = pos.x.clamp(track_left, track_right);
217 let frac = ((clamped_x - track_left) / track_span).clamp(0.0, 1.0) as f64;
218 let mut new_value = lo + frac * (hi - lo);
219 if let Some(step) = step {
220 if step > 0.0 {
221 new_value = lo + ((new_value - lo) / step).round() * step;
222 }
223 }
224 new_value = new_value.clamp(lo, hi);
225 if (new_value - current).abs() > f64::EPSILON {
226 current = new_value;
227 *self.value = T::from_f64(current);
228 response.mark_changed();
229 }
230 }
231 }
232
233 if response.hovered() {
234 ui.ctx().set_cursor_icon(CursorIcon::Grab);
235 }
236 if response.is_pointer_button_down_on() {
237 ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
238 }
239
240 if ui.is_rect_visible(rect) {
241 let frac = if hi > lo {
242 ((current - lo) / (hi - lo)).clamp(0.0, 1.0) as f32
243 } else {
244 0.0
245 };
246 let thumb_x = track_left + track_span * frac;
247 let thumb_center = Pos2::new(thumb_x, track_y);
248
249 let painter = ui.painter();
250 let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);
251
252 painter.rect(
254 track_rect,
255 track_radius,
256 p.input_bg,
257 Stroke::new(1.0, p.border),
258 StrokeKind::Inside,
259 );
260
261 if thumb_x > track_rect.min.x + 0.5 {
263 let fill_rect = Rect::from_min_max(
264 Pos2::new(track_rect.min.x, track_rect.min.y),
265 Pos2::new(thumb_x, track_rect.max.y),
266 );
267 painter.rect_filled(fill_rect, track_radius, accent_fill);
268 }
269
270 if response.has_focus() || response.is_pointer_button_down_on() {
272 painter.circle_filled(
273 thumb_center,
274 thumb_d * 0.5 + 4.0,
275 with_alpha(accent_fill, 55),
276 );
277 }
278
279 painter.circle(
281 thumb_center,
282 thumb_d * 0.5,
283 p.text,
284 Stroke::new(2.0, accent_fill),
285 );
286
287 if self.show_value {
288 let text = self.format_value(current);
289 let galley =
290 crate::theme::placeholder_galley(ui, &text, t.label, false, f32::INFINITY);
291 let text_pos = Pos2::new(
292 rect.max.x - galley.size().x,
293 rect.center().y - galley.size().y * 0.5,
294 );
295 painter.galley(text_pos, galley, p.text);
296 }
297 }
298
299 response.widget_info(|| WidgetInfo::labeled(WidgetType::Slider, true, &label_text));
300 response
301 })
302 .inner
303 }
304}