Skip to main content

elegance/
knob.rs

1//! Rotary knob: 270-degree arc, accent-coloured fill, optional labeled
2//! detents.
3//!
4//! A leaf widget bound to caller state, generic over [`egui::emath::Numeric`].
5//! The visual treatment matches an instrument-panel knob: a thin arc track
6//! with an active fill that grows clockwise from the lower-left, a body with
7//! a subtle depth-tinted rim, and a tick indicator pointing at the current
8//! value. Three preset sizes cover compact instrument rows, default forms,
9//! and prominent stepped controls with labeled positions.
10//!
11//! Interaction: drag combines horizontal and vertical motion (right and
12//! up both increase, left and down both decrease, so a diagonal flick reads
13//! as a single gesture); `Shift` slows the drag for fine control. Scroll
14//! wheel, arrow keys / Page Up / Page Down / Home / End all nudge.
15//! Alt+click or double-click resets to a configured default. Bipolar knobs
16//! fill from the centre of the range outward toward the current value,
17//! suited to signed offsets (DC bias, pan, balance).
18
19use 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/// Visual size preset for [`Knob`].
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub enum KnobSize {
34    /// Compact knob suited to instrument-panel rows of four-plus controls.
35    Small,
36    /// Default knob, readable as a primary control inside a form.
37    #[default]
38    Medium,
39    /// Prominent knob, sized to host labeled detents around its rim.
40    Large,
41}
42
43/// Value mapping along the arc.
44#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
45pub enum KnobScale {
46    /// Linear position-to-value mapping (default).
47    #[default]
48    Linear,
49    /// Logarithmic mapping. Requires the range minimum to be positive;
50    /// non-positive minima fall back to linear.
51    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/// Rotary knob bound to a numeric value.
108///
109/// ```no_run
110/// # use elegance::{Accent, Knob, KnobSize};
111/// # egui::__run_test_ui(|ui| {
112/// let mut gain = -12.0_f32;
113/// ui.add(
114///     Knob::new(&mut gain, -60.0..=12.0)
115///         .label("Gain")
116///         .size(KnobSize::Small)
117///         .default(0.0_f32)
118///         .value_fmt(|v| format!("{v:.0} dB")),
119/// );
120///
121/// let mut dc = -1.4_f32;
122/// ui.add(
123///     Knob::new(&mut dc, -5.0..=5.0)
124///         .label("DC offset")
125///         .bipolar()
126///         .accent(Accent::Purple)
127///         .show_value(true),
128/// );
129/// # });
130/// ```
131#[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    /// Create a knob bound to `value`, constrained to `range`.
168    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    /// Show a label above the knob.
187    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
188        self.label = Some(label.into());
189        self
190    }
191
192    /// Pick a visual size. Default: [`KnobSize::Medium`].
193    #[inline]
194    pub fn size(mut self, size: KnobSize) -> Self {
195        self.size = size;
196        self
197    }
198
199    /// Pick the fill colour from one of the theme accents. Default: [`Accent::Sky`].
200    #[inline]
201    pub fn accent(mut self, accent: Accent) -> Self {
202        self.accent = accent;
203        self
204    }
205
206    /// Render as a bipolar knob: the active arc fills from the centre of the
207    /// range toward the current value, suited to signed values (DC offset,
208    /// pan, balance).
209    #[inline]
210    pub fn bipolar(mut self) -> Self {
211        self.bipolar = true;
212        self
213    }
214
215    /// Snap to a fixed list of `(value, label)` detents and render a labeled
216    /// tick at each. Drag, scroll, and arrow keys step between detents.
217    /// Existing [`step`](Self::step) is overridden.
218    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    /// Snap continuous values to multiples of `step` (in the knob's value
233    /// units). Integer-typed knobs snap to `1.0` automatically.
234    /// Ignored if [`detents`](Self::detents) is set.
235    pub fn step(mut self, step: f64) -> Self {
236        self.step = Some(step);
237        self
238    }
239
240    /// Use a logarithmic value mapping. Falls back to linear if the range
241    /// minimum is non-positive.
242    #[inline]
243    pub fn log_scale(mut self) -> Self {
244        self.scale = KnobScale::Log;
245        self
246    }
247
248    /// Provide a value formatter for the optional inline display.
249    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    /// Render the formatted value below the knob. Default: hidden.
255    #[inline]
256    pub fn show_value(mut self, show: bool) -> Self {
257        self.show_value = show;
258        self
259    }
260
261    /// Value to reset to on Alt+click or double-click. If unset, the reset
262    /// gesture is a no-op.
263    pub fn default(mut self, default: T) -> Self {
264        self.default_value = Some(default.to_f64());
265        self
266    }
267
268    /// Disable the knob — no pointer, scroll, or keyboard input. Default: enabled.
269    #[inline]
270    pub fn enabled(mut self, enabled: bool) -> Self {
271        self.enabled = enabled;
272        self
273    }
274}
275
276/// Position [0,1] along the 270-degree sweep, where 0 is the lower-left
277/// (-135 degrees from north, going clockwise to +135 degrees).
278fn pos_to_angle(pos: f32) -> f32 {
279    // Egui's angle convention: 0 = east, +y is down. Convert from the
280    // "0 at north, clockwise" convention used by the JS mockup.
281    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        // Destructure up front so the borrow checker sees independent
319        // borrows for `value` (mutable) vs. config fields (shared) inside
320        // the layout closure.
321        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        // ---- value range / current ----
343        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        // Write back any clamping/snapping so the bound state is consistent.
400        if (current - value.to_f64()).abs() > f64::EPSILON {
401            *value = T::from_f64(current);
402        }
403
404        // ---- geometry ----
405        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        // Tick label sizing for the labeled-detent variant.
412        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        // Outer extent of the dial (centre to furthest visible pixel).
437        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        // Add a touch of padding to keep AA edges clean.
443        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            // Allocate the dial.
456            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            // ---- interaction ----
465            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                        // Combine horizontal and vertical motion: right = up = +.
495                        // Diagonal drags add naturally so a single up-right or
496                        // down-left flick reads as one gesture rather than two.
497                        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            // ---- paint ----
638            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                // Track (full 270deg sweep).
644                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                // Active fill.
655                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                // Detent ticks + labels.
682                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                        // Anchor each label on the side of its rect closest to the
693                        // knob centre so the text always extends *outward*. Without
694                        // this, labels near 12 o'clock sit BELOW their anchor and
695                        // crowd into the tick.
696                        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                // Body: rim + face + optional inner ring.
725                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                // Indicator: line pointing along current angle, drawn from
734                // a near-centre radius outward to near the rim. Coloured
735                // accent so it reads against the card-tone face.
736                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                // Focus ring.
745                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/// Compute one nudge step in value space. Direction is +/-1.
777#[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    // ~64 segments across the full 270deg sweep, scaled by the visible fraction.
837    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    // Rounded endpoint caps so the arc doesn't read as a clipped rectangle.
847    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}