1use std::f32::consts::PI;
20use std::ops::RangeInclusive;
21
22use egui::{
23 emath::Numeric,
24 epaint::{PathShape, PathStroke},
25 pos2, vec2, Align2, Color32, FontId, Pos2, Response, Sense, Stroke, Ui, Vec2, Widget,
26 WidgetInfo, WidgetText, WidgetType,
27};
28
29use crate::theme::{with_alpha, Accent, Theme};
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub enum KnobSize {
34 Small,
36 #[default]
38 Medium,
39 Large,
41}
42
43#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
45pub enum KnobScale {
46 #[default]
48 Linear,
49 Log,
52}
53
54#[derive(Clone, Copy)]
55struct Geom {
56 arc_r: f32,
57 arc_stroke: f32,
58 rim_r: f32,
59 face_r: f32,
60 inner_r: f32,
61 indicator_inner: f32,
62 indicator_outer: f32,
63 indicator_w: f32,
64 label_size: f32,
65}
66
67impl Geom {
68 fn for_size(size: KnobSize) -> Self {
69 match size {
70 KnobSize::Small => Self {
71 arc_r: 22.0,
72 arc_stroke: 3.5,
73 rim_r: 18.0,
74 face_r: 14.0,
75 inner_r: 0.0,
76 indicator_inner: 8.0,
77 indicator_outer: 16.0,
78 indicator_w: 1.8,
79 label_size: 9.5,
80 },
81 KnobSize::Medium => Self {
82 arc_r: 34.0,
83 arc_stroke: 5.0,
84 rim_r: 27.0,
85 face_r: 22.0,
86 inner_r: 18.0,
87 indicator_inner: 12.0,
88 indicator_outer: 24.0,
89 indicator_w: 2.4,
90 label_size: 10.5,
91 },
92 KnobSize::Large => Self {
93 arc_r: 52.0,
94 arc_stroke: 6.0,
95 rim_r: 42.0,
96 face_r: 35.0,
97 inner_r: 28.0,
98 indicator_inner: 18.0,
99 indicator_outer: 38.0,
100 indicator_w: 3.0,
101 label_size: 11.0,
102 },
103 }
104 }
105}
106
107#[must_use = "Add with `ui.add(...)`."]
132pub struct Knob<'a, T: Numeric> {
133 value: &'a mut T,
134 range: RangeInclusive<T>,
135 label: Option<WidgetText>,
136 size: KnobSize,
137 accent: Accent,
138 bipolar: bool,
139 detents: Option<Vec<(f64, String)>>,
140 step: Option<f64>,
141 scale: KnobScale,
142 value_fmt: Option<Box<dyn Fn(f64) -> String + 'a>>,
143 show_value: bool,
144 default_value: Option<f64>,
145 enabled: bool,
146}
147
148impl<'a, T: Numeric> std::fmt::Debug for Knob<'a, T> {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("Knob")
151 .field("range_lo", &self.range.start().to_f64())
152 .field("range_hi", &self.range.end().to_f64())
153 .field("size", &self.size)
154 .field("accent", &self.accent)
155 .field("bipolar", &self.bipolar)
156 .field("detent_count", &self.detents.as_ref().map(Vec::len))
157 .field("step", &self.step)
158 .field("scale", &self.scale)
159 .field("show_value", &self.show_value)
160 .field("default", &self.default_value)
161 .field("enabled", &self.enabled)
162 .finish()
163 }
164}
165
166impl<'a, T: Numeric> Knob<'a, T> {
167 pub fn new(value: &'a mut T, range: RangeInclusive<T>) -> Self {
169 Self {
170 value,
171 range,
172 label: None,
173 size: KnobSize::Medium,
174 accent: Accent::Sky,
175 bipolar: false,
176 detents: None,
177 step: None,
178 scale: KnobScale::Linear,
179 value_fmt: None,
180 show_value: false,
181 default_value: None,
182 enabled: true,
183 }
184 }
185
186 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
188 self.label = Some(label.into());
189 self
190 }
191
192 #[inline]
194 pub fn size(mut self, size: KnobSize) -> Self {
195 self.size = size;
196 self
197 }
198
199 #[inline]
201 pub fn accent(mut self, accent: Accent) -> Self {
202 self.accent = accent;
203 self
204 }
205
206 #[inline]
210 pub fn bipolar(mut self) -> Self {
211 self.bipolar = true;
212 self
213 }
214
215 pub fn detents<I, S>(mut self, detents: I) -> Self
219 where
220 I: IntoIterator<Item = (T, S)>,
221 S: Into<String>,
222 {
223 self.detents = Some(
224 detents
225 .into_iter()
226 .map(|(v, lbl)| (v.to_f64(), lbl.into()))
227 .collect(),
228 );
229 self
230 }
231
232 pub fn step(mut self, step: f64) -> Self {
236 self.step = Some(step);
237 self
238 }
239
240 #[inline]
243 pub fn log_scale(mut self) -> Self {
244 self.scale = KnobScale::Log;
245 self
246 }
247
248 pub fn value_fmt(mut self, fmt: impl Fn(f64) -> String + 'a) -> Self {
250 self.value_fmt = Some(Box::new(fmt));
251 self
252 }
253
254 #[inline]
256 pub fn show_value(mut self, show: bool) -> Self {
257 self.show_value = show;
258 self
259 }
260
261 pub fn default(mut self, default: T) -> Self {
264 self.default_value = Some(default.to_f64());
265 self
266 }
267
268 #[inline]
270 pub fn enabled(mut self, enabled: bool) -> Self {
271 self.enabled = enabled;
272 self
273 }
274}
275
276fn pos_to_angle(pos: f32) -> f32 {
279 let north = -PI * 0.5;
282 let from_north = (pos.clamp(0.0, 1.0) * 270.0 - 135.0).to_radians();
283 north + from_north
284}
285
286fn radial_point(center: Pos2, r: f32, pos: f32) -> Pos2 {
287 let a = pos_to_angle(pos);
288 let (s, c) = a.sin_cos();
289 pos2(center.x + r * c, center.y + r * s)
290}
291
292fn linear_value_to_pos(v: f64, lo: f64, hi: f64) -> f64 {
293 if hi > lo {
294 ((v - lo) / (hi - lo)).clamp(0.0, 1.0)
295 } else {
296 0.0
297 }
298}
299
300fn log_value_to_pos(v: f64, lo: f64, hi: f64) -> f64 {
301 let lmin = lo.ln();
302 let lmax = hi.ln();
303 ((v.max(lo).ln() - lmin) / (lmax - lmin)).clamp(0.0, 1.0)
304}
305
306fn pos_to_linear_value(p: f64, lo: f64, hi: f64) -> f64 {
307 lo + p.clamp(0.0, 1.0) * (hi - lo)
308}
309
310fn pos_to_log_value(p: f64, lo: f64, hi: f64) -> f64 {
311 let lmin = lo.ln();
312 let lmax = hi.ln();
313 (lmin + p.clamp(0.0, 1.0) * (lmax - lmin)).exp()
314}
315
316impl<'a, T: Numeric> Widget for Knob<'a, T> {
317 fn ui(self, ui: &mut Ui) -> Response {
318 let Knob {
322 value,
323 range,
324 label,
325 size,
326 accent,
327 bipolar,
328 detents,
329 step,
330 scale,
331 value_fmt,
332 show_value,
333 default_value,
334 enabled,
335 } = self;
336
337 let theme = Theme::current(ui.ctx());
338 let p = &theme.palette;
339 let t = &theme.typography;
340 let accent_fill = p.accent_fill(accent);
341
342 let lo_raw = range.start().to_f64();
344 let hi_raw = range.end().to_f64();
345 let (lo, hi) = if lo_raw <= hi_raw {
346 (lo_raw, hi_raw)
347 } else {
348 (hi_raw, lo_raw)
349 };
350 let log_ok = matches!(scale, KnobScale::Log) && lo > 0.0;
351
352 let value_to_pos = |v: f64| -> f64 {
353 if log_ok {
354 log_value_to_pos(v, lo, hi)
355 } else {
356 linear_value_to_pos(v, lo, hi)
357 }
358 };
359 let pos_to_value = |p: f64| -> f64 {
360 if log_ok {
361 pos_to_log_value(p, lo, hi)
362 } else {
363 pos_to_linear_value(p, lo, hi)
364 }
365 };
366
367 let snap = |v: f64| -> f64 {
368 if let Some(d) = detents.as_ref() {
369 if d.is_empty() {
370 return v.clamp(lo, hi);
371 }
372 let target_pos = value_to_pos(v);
373 let mut best = d[0].0;
374 let mut best_d = (value_to_pos(best) - target_pos).abs();
375 for (dv, _) in d.iter().skip(1) {
376 let dd = (value_to_pos(*dv) - target_pos).abs();
377 if dd < best_d {
378 best_d = dd;
379 best = *dv;
380 }
381 }
382 return best;
383 }
384 let eff_step = step.or(if T::INTEGRAL { Some(1.0) } else { None });
385 let mut snapped = v;
386 if let Some(s) = eff_step {
387 if s > 0.0 {
388 snapped = lo + ((v - lo) / s).round() * s;
389 }
390 }
391 snapped.clamp(lo, hi)
392 };
393
394 let mut current = value.to_f64();
395 if current.is_nan() {
396 current = lo;
397 }
398 current = snap(current);
399 if (current - value.to_f64()).abs() > f64::EPSILON {
401 *value = T::from_f64(current);
402 }
403
404 let g = Geom::for_size(size);
406 let label_text = label
407 .as_ref()
408 .map(|l| l.text().to_string())
409 .unwrap_or_default();
410
411 let detent_labels: Vec<(f32, String)> = detents
413 .as_ref()
414 .map(|d| {
415 d.iter()
416 .filter(|(_, lbl)| !lbl.is_empty())
417 .map(|(v, lbl)| (value_to_pos(*v) as f32, lbl.clone()))
418 .collect()
419 })
420 .unwrap_or_default();
421
422 let tick_inner_r = g.arc_r + g.arc_stroke * 0.5 + 2.0;
423 let tick_outer_r = tick_inner_r + 5.0;
424 let label_gap = 6.0;
425 let mut max_label_w: f32 = 0.0;
426 let mut max_label_h: f32 = 0.0;
427 if !detent_labels.is_empty() {
428 for (_, txt) in &detent_labels {
429 let galley =
430 crate::theme::placeholder_galley(ui, txt, g.label_size, false, f32::INFINITY);
431 max_label_w = max_label_w.max(galley.size().x);
432 max_label_h = max_label_h.max(galley.size().y);
433 }
434 }
435
436 let outer_r = if detent_labels.is_empty() {
438 g.arc_r + g.arc_stroke * 0.5 + 4.0
439 } else {
440 tick_outer_r + label_gap + max_label_w.max(max_label_h)
441 };
442 let dial_diameter = (outer_r * 2.0).ceil() + 2.0;
444
445 ui.vertical(|ui| {
446 ui.spacing_mut().item_spacing.y = 4.0;
447
448 if !label_text.is_empty() {
449 let rich = egui::RichText::new(&label_text)
450 .color(p.text_muted)
451 .size(t.label);
452 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
453 }
454
455 let sense = if enabled {
457 Sense::click_and_drag()
458 } else {
459 Sense::hover()
460 };
461 let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(dial_diameter), sense);
462 let center = rect.center();
463
464 if enabled {
466 if response.double_clicked() {
467 if let Some(d) = default_value {
468 let snapped = snap(d.clamp(lo, hi));
469 if (snapped - current).abs() > f64::EPSILON {
470 current = snapped;
471 *value = T::from_f64(current);
472 response.mark_changed();
473 }
474 }
475 }
476
477 if response.drag_started() {
478 let alt = ui.input(|i| i.modifiers.alt);
479 if alt {
480 if let Some(d) = default_value {
481 let snapped = snap(d.clamp(lo, hi));
482 if (snapped - current).abs() > f64::EPSILON {
483 current = snapped;
484 *value = T::from_f64(current);
485 response.mark_changed();
486 }
487 }
488 }
489 }
490
491 if response.dragged() {
492 let alt = ui.input(|i| i.modifiers.alt);
493 if !alt {
494 let delta = response.drag_delta();
498 let combined = (delta.x - delta.y) as f64;
499 let fine = ui.input(|i| i.modifiers.shift);
500 let sensitivity = if fine { 1.0 / 600.0 } else { 1.0 / 180.0 };
501 let cur_pos = value_to_pos(current);
502 let new_pos = (cur_pos + combined * sensitivity).clamp(0.0, 1.0);
503 let mut new_v = pos_to_value(new_pos);
504 new_v = snap(new_v);
505 if (new_v - current).abs() > f64::EPSILON {
506 current = new_v;
507 *value = T::from_f64(current);
508 response.mark_changed();
509 }
510 }
511 }
512
513 if response.hovered() {
514 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
515
516 let scroll = ui.input(|i| i.smooth_scroll_delta.y);
517 if scroll.abs() > 0.5 {
518 let dir = if scroll > 0.0 { 1.0 } else { -1.0 };
519 let fine = ui.input(|i| i.modifiers.shift);
520 let new_v = nudge_value(
521 current,
522 dir,
523 fine,
524 lo,
525 hi,
526 step,
527 T::INTEGRAL,
528 detents.as_deref(),
529 &value_to_pos,
530 &pos_to_value,
531 );
532 let new_v = snap(new_v);
533 if (new_v - current).abs() > f64::EPSILON {
534 current = new_v;
535 *value = T::from_f64(current);
536 response.mark_changed();
537 }
538 }
539 }
540
541 if response.has_focus() {
542 let (up, down, page_up, page_down, home, end_, reset) = ui.input(|i| {
543 (
544 i.key_pressed(egui::Key::ArrowUp)
545 || i.key_pressed(egui::Key::ArrowRight),
546 i.key_pressed(egui::Key::ArrowDown)
547 || i.key_pressed(egui::Key::ArrowLeft),
548 i.key_pressed(egui::Key::PageUp),
549 i.key_pressed(egui::Key::PageDown),
550 i.key_pressed(egui::Key::Home),
551 i.key_pressed(egui::Key::End),
552 i.key_pressed(egui::Key::Num0) || i.key_pressed(egui::Key::Space),
553 )
554 });
555 let fine = ui.input(|i| i.modifiers.shift);
556 let mut next = current;
557 if up {
558 next = nudge_value(
559 next,
560 1.0,
561 fine,
562 lo,
563 hi,
564 step,
565 T::INTEGRAL,
566 detents.as_deref(),
567 &value_to_pos,
568 &pos_to_value,
569 );
570 }
571 if down {
572 next = nudge_value(
573 next,
574 -1.0,
575 fine,
576 lo,
577 hi,
578 step,
579 T::INTEGRAL,
580 detents.as_deref(),
581 &value_to_pos,
582 &pos_to_value,
583 );
584 }
585 if page_up {
586 for _ in 0..4 {
587 next = nudge_value(
588 next,
589 1.0,
590 fine,
591 lo,
592 hi,
593 step,
594 T::INTEGRAL,
595 detents.as_deref(),
596 &value_to_pos,
597 &pos_to_value,
598 );
599 }
600 }
601 if page_down {
602 for _ in 0..4 {
603 next = nudge_value(
604 next,
605 -1.0,
606 fine,
607 lo,
608 hi,
609 step,
610 T::INTEGRAL,
611 detents.as_deref(),
612 &value_to_pos,
613 &pos_to_value,
614 );
615 }
616 }
617 if home {
618 next = lo;
619 }
620 if end_ {
621 next = hi;
622 }
623 if reset {
624 if let Some(d) = default_value {
625 next = d.clamp(lo, hi);
626 }
627 }
628 next = snap(next);
629 if (next - current).abs() > f64::EPSILON {
630 current = next;
631 *value = T::from_f64(current);
632 response.mark_changed();
633 }
634 }
635 }
636
637 if ui.is_rect_visible(rect) {
639 let painter = ui.painter();
640 let track_color = p.depth_tint(p.card, 0.18);
641 let pos = value_to_pos(current) as f32;
642
643 paint_arc(
645 painter,
646 center,
647 g.arc_r,
648 g.arc_stroke,
649 track_color,
650 0.0,
651 1.0,
652 );
653
654 if bipolar {
656 let lo_p = pos.min(0.5);
657 let hi_p = pos.max(0.5);
658 if (hi_p - lo_p).abs() > 1e-4 {
659 paint_arc(
660 painter,
661 center,
662 g.arc_r,
663 g.arc_stroke,
664 accent_fill,
665 lo_p,
666 hi_p,
667 );
668 }
669 } else if pos > 1e-4 {
670 paint_arc(
671 painter,
672 center,
673 g.arc_r,
674 g.arc_stroke,
675 accent_fill,
676 0.0,
677 pos,
678 );
679 }
680
681 if !detent_labels.is_empty() {
683 let active_pos = pos;
684 for (dpos, txt) in &detent_labels {
685 let hot = (dpos - active_pos).abs() < 1e-3;
686 let tick_color = if hot { p.text } else { p.border };
687 let a = radial_point(center, tick_inner_r, *dpos);
688 let b = radial_point(center, tick_outer_r, *dpos);
689 painter.line_segment([a, b], Stroke::new(1.0, tick_color));
690
691 let lp = radial_point(center, tick_outer_r + label_gap, *dpos);
692 let dx = lp.x - center.x;
697 let dy = lp.y - center.y;
698 let h = if dx.abs() < 4.0 {
699 egui::Align::Center
700 } else if dx < 0.0 {
701 egui::Align::Max
702 } else {
703 egui::Align::Min
704 };
705 let v = if dy.abs() < 4.0 {
706 egui::Align::Center
707 } else if dy < 0.0 {
708 egui::Align::Max
709 } else {
710 egui::Align::Min
711 };
712 let anchor = Align2([h, v]);
713 let label_color = if hot { accent_fill } else { p.text_muted };
714 painter.text(
715 lp,
716 anchor,
717 txt,
718 FontId::proportional(g.label_size),
719 label_color,
720 );
721 }
722 }
723
724 let rim_fill = p.depth_tint(p.card, 0.12);
726 let face_fill = p.card;
727 painter.circle(center, g.rim_r, rim_fill, Stroke::new(1.0, p.border));
728 painter.circle_filled(center, g.face_r, face_fill);
729 if g.inner_r > 0.0 {
730 painter.circle_stroke(center, g.inner_r, Stroke::new(1.0, p.border));
731 }
732
733 let angle = pos_to_angle(pos);
737 let (s, c) = angle.sin_cos();
738 let dir = vec2(c, s);
739 let a = center + dir * g.indicator_inner;
740 let b = center + dir * g.indicator_outer;
741 let ind_color = if enabled { accent_fill } else { p.text_faint };
742 painter.line_segment([a, b], Stroke::new(g.indicator_w, ind_color));
743
744 if response.has_focus() {
746 painter.circle_stroke(
747 center,
748 g.rim_r + 4.0,
749 Stroke::new(1.5, with_alpha(p.sky, 180)),
750 );
751 }
752 }
753
754 if show_value {
755 let text = if let Some(f) = &value_fmt {
756 f(current)
757 } else if T::INTEGRAL {
758 format!("{current:.0}")
759 } else {
760 format!("{current:.2}")
761 };
762 let rich = egui::RichText::new(text)
763 .color(p.text)
764 .size(t.small)
765 .strong();
766 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
767 }
768
769 response.widget_info(|| WidgetInfo::labeled(WidgetType::Slider, true, &label_text));
770 response
771 })
772 .inner
773 }
774}
775
776#[allow(clippy::too_many_arguments)]
778fn nudge_value(
779 current: f64,
780 dir: f64,
781 fine: bool,
782 lo: f64,
783 hi: f64,
784 step: Option<f64>,
785 integral: bool,
786 detents: Option<&[(f64, String)]>,
787 value_to_pos: &dyn Fn(f64) -> f64,
788 pos_to_value: &dyn Fn(f64) -> f64,
789) -> f64 {
790 if let Some(detents) = detents {
791 if !detents.is_empty() {
792 let mut sorted: Vec<f64> = detents.iter().map(|(v, _)| *v).collect();
793 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
794 let mut idx = 0usize;
795 let mut best = (sorted[0] - current).abs();
796 for (i, v) in sorted.iter().enumerate().skip(1) {
797 let dv = (v - current).abs();
798 if dv < best {
799 best = dv;
800 idx = i;
801 }
802 }
803 let next_idx = if dir > 0.0 {
804 (idx + 1).min(sorted.len() - 1)
805 } else {
806 idx.saturating_sub(1)
807 };
808 return sorted[next_idx];
809 }
810 }
811 let effective_step = step.or(if integral { Some(1.0) } else { None });
812 if let Some(s) = effective_step {
813 let mult = if fine { 0.25 } else { 1.0 };
814 return (current + dir * s * mult).clamp(lo, hi);
815 }
816 let frac = if fine { 1.0 / 200.0 } else { 1.0 / 40.0 };
817 let cur_pos = value_to_pos(current);
818 let new_pos = (cur_pos + dir * frac).clamp(0.0, 1.0);
819 pos_to_value(new_pos)
820}
821
822fn paint_arc(
823 painter: &egui::Painter,
824 center: Pos2,
825 radius: f32,
826 stroke: f32,
827 color: Color32,
828 p0: f32,
829 p1: f32,
830) {
831 if (p1 - p0).abs() < 1e-4 {
832 return;
833 }
834 let a0 = pos_to_angle(p0);
835 let a1 = pos_to_angle(p1);
836 let n = ((p1 - p0).abs() * 96.0).ceil() as usize + 2;
838 let points: Vec<Pos2> = (0..=n)
839 .map(|i| {
840 let t = i as f32 / n as f32;
841 let a = a0 + (a1 - a0) * t;
842 let (s, c) = a.sin_cos();
843 pos2(center.x + radius * c, center.y + radius * s)
844 })
845 .collect();
846 if let (Some(first), Some(last)) = (points.first(), points.last()) {
848 painter.circle_filled(*first, stroke * 0.5, color);
849 painter.circle_filled(*last, stroke * 0.5, color);
850 }
851 painter.add(PathShape::line(points, PathStroke::new(stroke, color)));
852}