Skip to main content

egui_macview/controls/
level_indicator.rs

1//! Authentic macOS AppKit Level Indicator (`NSLevelIndicator` equivalent, SPEC-011).
2//!
3//! Grounded in Apple AppKit `NSLevelIndicator` and W3C DTCG tokens via [`MacTheme`].
4//! Supports `Continuous` and `Discrete` styles in both `Horizontal` and `Vertical` orientations,
5//! with normal (`system_green`), warning (`system_yellow`), and critical (`system_red`) thresholds
6//! plus an optional peak-hold marker line.
7
8use crate::layout::subpixel_snap_rect;
9use crate::theme::MacTheme;
10use egui::{
11    Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget,
12};
13use std::ops::RangeInclusive;
14
15/// Visual meter style for [`MacLevelIndicator`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum MacLevelIndicatorStyle {
18    /// Smooth continuous level bar (`NSLevelIndicatorStyleContinuousCapacity`).
19    #[default]
20    Continuous,
21    /// Segmented block meter (`NSLevelIndicatorStyleDiscreteCapacity`).
22    Discrete,
23}
24
25/// Primary axis orientation for [`MacLevelIndicator`].
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum MacLevelOrientation {
28    /// Left-to-right horizontal meter.
29    #[default]
30    Horizontal,
31    /// Bottom-to-top vertical meter.
32    Vertical,
33}
34
35/// Authentic macOS AppKit signal and audio level meter (`NSLevelIndicator` equivalent).
36///
37/// # Example
38///
39/// ```rust
40/// use egui_macview::{MacLevelIndicator, MacLevelIndicatorStyle, MacTheme};
41///
42/// # let ctx = egui::Context::default();
43/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
44/// let theme = MacTheme::default();
45/// MacLevelIndicator::new(-12.0, -60.0..=0.0)
46///     .theme(&theme)
47///     .warning_value(-18.0)
48///     .critical_value(-6.0)
49///     .peak_hold(-3.0)
50///     .style(MacLevelIndicatorStyle::Discrete)
51///     .show(ui);
52/// # });
53/// # output.textures_delta.clear();
54/// ```
55pub struct MacLevelIndicator<'a> {
56    value: f32,
57    range: RangeInclusive<f32>,
58    warning_value: f32,
59    critical_value: f32,
60    peak_hold_value: Option<f32>,
61    style: MacLevelIndicatorStyle,
62    orientation: MacLevelOrientation,
63    segment_count: Option<usize>,
64    segmented_threshold_colors: bool,
65    tick_marks: usize,
66    length: Option<f32>,
67    thickness: Option<f32>,
68    enabled: bool,
69    theme: Option<&'a MacTheme>,
70}
71
72impl<'a> MacLevelIndicator<'a> {
73    /// Creates a new level indicator for `value` within `range`.
74    pub fn new(value: f32, range: RangeInclusive<f32>) -> Self {
75        let min_v = *range.start();
76        let max_v = *range.end();
77        let span = max_v - min_v;
78        Self {
79            value,
80            range,
81            warning_value: min_v + span * 0.5,
82            critical_value: min_v + span,
83            peak_hold_value: None,
84            style: MacLevelIndicatorStyle::Continuous,
85            orientation: MacLevelOrientation::Horizontal,
86            segment_count: None,
87            segmented_threshold_colors: false,
88            tick_marks: 0,
89            length: None,
90            thickness: None,
91            enabled: true,
92            theme: None,
93        }
94    }
95
96    /// Sets warning threshold where meter switches from green to yellow/amber.
97    pub fn warning_value(mut self, warning: f32) -> Self {
98        self.warning_value = warning;
99        self
100    }
101
102    /// Sets critical threshold where meter switches from yellow/amber to red.
103    pub fn critical_value(mut self, critical: f32) -> Self {
104        self.critical_value = critical;
105        self
106    }
107
108    /// Sets optional peak-hold marker value.
109    pub fn peak_hold(mut self, peak: f32) -> Self {
110        self.peak_hold_value = Some(peak);
111        self
112    }
113
114    /// Sets meter style (`Continuous` or `Discrete`).
115    pub fn style(mut self, style: MacLevelIndicatorStyle) -> Self {
116        self.style = style;
117        self
118    }
119
120    /// Sets meter orientation (`Horizontal` or `Vertical`).
121    pub fn orientation(mut self, orientation: MacLevelOrientation) -> Self {
122        self.orientation = orientation;
123        self
124    }
125
126    /// Sets discrete segment count.
127    pub fn segments(mut self, count: usize) -> Self {
128        self.segment_count = Some(count.max(1));
129        self
130    }
131
132    /// Enables per-segment VU meter threshold coloring instead of uniform AppKit discrete capacity color.
133    pub fn segmented_threshold_colors(mut self, enabled: bool) -> Self {
134        self.segmented_threshold_colors = enabled;
135        self
136    }
137
138    /// Sets the number of graduation tick marks rendered along the meter axis.
139    pub fn tick_marks(mut self, count: usize) -> Self {
140        self.tick_marks = count;
141        self
142    }
143
144    /// Overrides primary-axis length in points.
145    pub fn length(mut self, length: f32) -> Self {
146        self.length = Some(length);
147        self
148    }
149
150    /// Overrides cross-axis thickness in points.
151    pub fn thickness(mut self, thickness: f32) -> Self {
152        self.thickness = Some(thickness);
153        self
154    }
155
156    /// Enables or disables the control.
157    pub fn enabled(mut self, enabled: bool) -> Self {
158        self.enabled = enabled;
159        self
160    }
161
162    /// Injects dynamic [`MacTheme`].
163    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
164        self.theme = Some(theme);
165        self
166    }
167
168    /// Evaluates the semantic AppKit meter color for a given sample level `sample_value`.
169    pub fn color_for_value(&self, theme: &MacTheme, sample_value: f32) -> Color32 {
170        if !self.enabled {
171            return theme.selected_control_inactive();
172        }
173        if sample_value >= self.critical_value {
174            theme.system_red()
175        } else if sample_value >= self.warning_value {
176            theme.system_yellow()
177        } else {
178            theme.system_green()
179        }
180    }
181
182    fn normalize(&self, v: f32) -> f32 {
183        let min_v = *self.range.start();
184        let max_v = *self.range.end();
185        let span = (max_v - min_v).max(f32::EPSILON);
186        ((v - min_v) / span).clamp(0.0, 1.0)
187    }
188
189    /// Renders the level indicator in `ui`.
190    pub fn show(self, ui: &mut Ui) -> Response {
191        ui.add(self)
192    }
193}
194
195impl<'a> Widget for MacLevelIndicator<'a> {
196    fn ui(self, ui: &mut Ui) -> Response {
197        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
198        let theme: &MacTheme = &theme_cow;
199
200        let lm = theme.level_indicator();
201        let len = self.length.unwrap_or_else(|| lm.default_length.points());
202        let thick = self.thickness.unwrap_or_else(|| lm.thickness.points());
203        let size = match self.orientation {
204            MacLevelOrientation::Horizontal => Vec2::new(len, thick),
205            MacLevelOrientation::Vertical => Vec2::new(thick, len),
206        };
207
208        let (raw_rect, response) = ui.allocate_exact_size(size, Sense::hover());
209        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
210        let painter = ui.painter_at(rect);
211
212        let outer_r = CornerRadius::same(lm.corner_radius.points() as u8);
213        painter.rect(
214            rect,
215            outer_r,
216            theme.control_background(),
217            Stroke::new(lm.border_width.points(), theme.separator()),
218            StrokeKind::Inside,
219        );
220
221        let inset = lm.track_inset.points();
222        let inner = rect.shrink(inset);
223        let frac = self.normalize(self.value);
224        let min_v = *self.range.start();
225        let max_v = *self.range.end();
226        let span = (max_v - min_v).max(f32::EPSILON);
227
228        match self.style {
229            MacLevelIndicatorStyle::Continuous => {
230                let seg_r = CornerRadius::same(lm.segment_corner_radius.points() as u8);
231                let fill_color = self.color_for_value(theme, self.value);
232                if frac > 0.0 {
233                    let fill_rect = match self.orientation {
234                        MacLevelOrientation::Horizontal => Rect::from_min_size(
235                            inner.min,
236                            Vec2::new(inner.width() * frac, inner.height()),
237                        ),
238                        MacLevelOrientation::Vertical => {
239                            let h = inner.height() * frac;
240                            Rect::from_min_max(
241                                Pos2::new(inner.left(), inner.bottom() - h),
242                                inner.max,
243                            )
244                        }
245                    };
246                    painter.rect_filled(fill_rect, seg_r, fill_color);
247                }
248            }
249            MacLevelIndicatorStyle::Discrete => {
250                let seg_count = self
251                    .segment_count
252                    .unwrap_or_else(|| (lm.default_segment_count.points().round() as usize).max(1));
253                let gap = lm.segment_spacing.points();
254                let seg_r = CornerRadius::same(lm.segment_corner_radius.points() as u8);
255                let total_axis = match self.orientation {
256                    MacLevelOrientation::Horizontal => inner.width(),
257                    MacLevelOrientation::Vertical => inner.height(),
258                };
259                let total_gaps = gap * ((seg_count.saturating_sub(1)) as f32);
260                let seg_len = ((total_axis - total_gaps) / (seg_count as f32)).max(1.0);
261
262                for idx in 0..seg_count {
263                    let seg_frac_end = (idx + 1) as f32 / (seg_count as f32);
264                    let seg_mid_frac = (idx as f32 + 0.5) / (seg_count as f32);
265                    let seg_val = min_v + seg_mid_frac * span;
266                    let is_lit = frac >= (idx as f32 / (seg_count as f32))
267                        && frac > 0.0
268                        && (frac >= seg_frac_end
269                            || idx == ((frac * seg_count as f32).floor() as usize));
270
271                    let seg_rect = match self.orientation {
272                        MacLevelOrientation::Horizontal => {
273                            let x = inner.left() + (idx as f32) * (seg_len + gap);
274                            Rect::from_min_size(
275                                Pos2::new(x, inner.top()),
276                                Vec2::new(seg_len, inner.height()),
277                            )
278                        }
279                        MacLevelOrientation::Vertical => {
280                            let y_bottom = inner.bottom() - (idx as f32) * (seg_len + gap);
281                            Rect::from_min_max(
282                                Pos2::new(inner.left(), y_bottom - seg_len),
283                                Pos2::new(inner.right(), y_bottom),
284                            )
285                        }
286                    };
287
288                    let color = if is_lit {
289                        if self.segmented_threshold_colors {
290                            self.color_for_value(theme, seg_val)
291                        } else {
292                            self.color_for_value(theme, self.value)
293                        }
294                    } else {
295                        theme.control()
296                    };
297                    painter.rect_filled(seg_rect, seg_r, color);
298                }
299            }
300        }
301
302        if self.tick_marks > 0 {
303            let tick_stroke = Stroke::new(lm.border_width.points(), theme.secondary_label());
304            let tick_len = lm.track_inset.points() + lm.border_width.points();
305            let denom = (self.tick_marks.saturating_sub(1)).max(1) as f32;
306            for idx in 0..self.tick_marks {
307                let t_frac = if self.tick_marks == 1 {
308                    0.5
309                } else {
310                    (idx as f32) / denom
311                };
312                match self.orientation {
313                    MacLevelOrientation::Horizontal => {
314                        let tx = inner.left() + inner.width() * t_frac;
315                        painter.line_segment(
316                            [
317                                Pos2::new(tx, rect.bottom() - tick_len),
318                                Pos2::new(tx, rect.bottom()),
319                            ],
320                            tick_stroke,
321                        );
322                    }
323                    MacLevelOrientation::Vertical => {
324                        let ty = inner.bottom() - inner.height() * t_frac;
325                        painter.line_segment(
326                            [
327                                Pos2::new(rect.right() - tick_len, ty),
328                                Pos2::new(rect.right(), ty),
329                            ],
330                            tick_stroke,
331                        );
332                    }
333                }
334            }
335        }
336
337        if let Some(peak) = self.peak_hold_value {
338            let peak_frac = self.normalize(peak);
339            let peak_color = self.color_for_value(theme, peak);
340            let peak_stroke = Stroke::new(lm.peak_line_width.points(), peak_color);
341            match self.orientation {
342                MacLevelOrientation::Horizontal => {
343                    let px = inner.left() + inner.width() * peak_frac;
344                    painter.line_segment(
345                        [Pos2::new(px, inner.top()), Pos2::new(px, inner.bottom())],
346                        peak_stroke,
347                    );
348                }
349                MacLevelOrientation::Vertical => {
350                    let py = inner.bottom() - inner.height() * peak_frac;
351                    painter.line_segment(
352                        [Pos2::new(inner.left(), py), Pos2::new(inner.right(), py)],
353                        peak_stroke,
354                    );
355                }
356            }
357        }
358
359        response
360    }
361}
362
363/// Calculates stereo equal-power pan weights `(left_gain, right_gain)` for `pan in -1.0..=1.0`.
364pub fn calculate_pan_weights(pan: f32) -> (f32, f32) {
365    let pan_clamped = pan.clamp(-1.0, 1.0);
366    let pan_l = ((1.0 - pan_clamped) * 0.5).sqrt();
367    let pan_r = ((1.0 + pan_clamped) * 0.5).sqrt();
368    (pan_l, pan_r)
369}