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 fallback_theme;
198        let theme = match self.theme {
199            Some(t) => t,
200            None => {
201                fallback_theme = MacTheme::from_ctx(ui.ctx());
202                &fallback_theme
203            }
204        };
205
206        let lm = theme.level_indicator();
207        let len = self.length.unwrap_or_else(|| lm.default_length.points());
208        let thick = self.thickness.unwrap_or_else(|| lm.thickness.points());
209        let size = match self.orientation {
210            MacLevelOrientation::Horizontal => Vec2::new(len, thick),
211            MacLevelOrientation::Vertical => Vec2::new(thick, len),
212        };
213
214        let (raw_rect, response) = ui.allocate_exact_size(size, Sense::hover());
215        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
216        let painter = ui.painter_at(rect);
217
218        let outer_r = CornerRadius::same(lm.corner_radius.points() as u8);
219        painter.rect(
220            rect,
221            outer_r,
222            theme.control_background(),
223            Stroke::new(lm.border_width.points(), theme.separator()),
224            StrokeKind::Inside,
225        );
226
227        let inset = lm.track_inset.points();
228        let inner = rect.shrink(inset);
229        let frac = self.normalize(self.value);
230        let min_v = *self.range.start();
231        let max_v = *self.range.end();
232        let span = (max_v - min_v).max(f32::EPSILON);
233
234        match self.style {
235            MacLevelIndicatorStyle::Continuous => {
236                let seg_r = CornerRadius::same(lm.segment_corner_radius.points() as u8);
237                let fill_color = self.color_for_value(theme, self.value);
238                if frac > 0.0 {
239                    let fill_rect = match self.orientation {
240                        MacLevelOrientation::Horizontal => Rect::from_min_size(
241                            inner.min,
242                            Vec2::new(inner.width() * frac, inner.height()),
243                        ),
244                        MacLevelOrientation::Vertical => {
245                            let h = inner.height() * frac;
246                            Rect::from_min_max(
247                                Pos2::new(inner.left(), inner.bottom() - h),
248                                inner.max,
249                            )
250                        }
251                    };
252                    painter.rect_filled(fill_rect, seg_r, fill_color);
253                }
254            }
255            MacLevelIndicatorStyle::Discrete => {
256                let seg_count = self
257                    .segment_count
258                    .unwrap_or_else(|| (lm.default_segment_count.points().round() as usize).max(1));
259                let gap = lm.segment_spacing.points();
260                let seg_r = CornerRadius::same(lm.segment_corner_radius.points() as u8);
261                let total_axis = match self.orientation {
262                    MacLevelOrientation::Horizontal => inner.width(),
263                    MacLevelOrientation::Vertical => inner.height(),
264                };
265                let total_gaps = gap * ((seg_count.saturating_sub(1)) as f32);
266                let seg_len = ((total_axis - total_gaps) / (seg_count as f32)).max(1.0);
267
268                for idx in 0..seg_count {
269                    let seg_frac_end = (idx + 1) as f32 / (seg_count as f32);
270                    let seg_mid_frac = (idx as f32 + 0.5) / (seg_count as f32);
271                    let seg_val = min_v + seg_mid_frac * span;
272                    let is_lit = frac >= (idx as f32 / (seg_count as f32))
273                        && frac > 0.0
274                        && (frac >= seg_frac_end
275                            || idx == ((frac * seg_count as f32).floor() as usize));
276
277                    let seg_rect = match self.orientation {
278                        MacLevelOrientation::Horizontal => {
279                            let x = inner.left() + (idx as f32) * (seg_len + gap);
280                            Rect::from_min_size(
281                                Pos2::new(x, inner.top()),
282                                Vec2::new(seg_len, inner.height()),
283                            )
284                        }
285                        MacLevelOrientation::Vertical => {
286                            let y_bottom = inner.bottom() - (idx as f32) * (seg_len + gap);
287                            Rect::from_min_max(
288                                Pos2::new(inner.left(), y_bottom - seg_len),
289                                Pos2::new(inner.right(), y_bottom),
290                            )
291                        }
292                    };
293
294                    let color = if is_lit {
295                        if self.segmented_threshold_colors {
296                            self.color_for_value(theme, seg_val)
297                        } else {
298                            self.color_for_value(theme, self.value)
299                        }
300                    } else {
301                        theme.control()
302                    };
303                    painter.rect_filled(seg_rect, seg_r, color);
304                }
305            }
306        }
307
308        if self.tick_marks > 0 {
309            let tick_stroke = Stroke::new(lm.border_width.points(), theme.secondary_label());
310            let tick_len = lm.track_inset.points() + lm.border_width.points();
311            let denom = (self.tick_marks.saturating_sub(1)).max(1) as f32;
312            for idx in 0..self.tick_marks {
313                let t_frac = if self.tick_marks == 1 {
314                    0.5
315                } else {
316                    (idx as f32) / denom
317                };
318                match self.orientation {
319                    MacLevelOrientation::Horizontal => {
320                        let tx = inner.left() + inner.width() * t_frac;
321                        painter.line_segment(
322                            [
323                                Pos2::new(tx, rect.bottom() - tick_len),
324                                Pos2::new(tx, rect.bottom()),
325                            ],
326                            tick_stroke,
327                        );
328                    }
329                    MacLevelOrientation::Vertical => {
330                        let ty = inner.bottom() - inner.height() * t_frac;
331                        painter.line_segment(
332                            [
333                                Pos2::new(rect.right() - tick_len, ty),
334                                Pos2::new(rect.right(), ty),
335                            ],
336                            tick_stroke,
337                        );
338                    }
339                }
340            }
341        }
342
343        if let Some(peak) = self.peak_hold_value {
344            let peak_frac = self.normalize(peak);
345            let peak_color = self.color_for_value(theme, peak);
346            let peak_stroke = Stroke::new(lm.peak_line_width.points(), peak_color);
347            match self.orientation {
348                MacLevelOrientation::Horizontal => {
349                    let px = inner.left() + inner.width() * peak_frac;
350                    painter.line_segment(
351                        [Pos2::new(px, inner.top()), Pos2::new(px, inner.bottom())],
352                        peak_stroke,
353                    );
354                }
355                MacLevelOrientation::Vertical => {
356                    let py = inner.bottom() - inner.height() * peak_frac;
357                    painter.line_segment(
358                        [Pos2::new(inner.left(), py), Pos2::new(inner.right(), py)],
359                        peak_stroke,
360                    );
361                }
362            }
363        }
364
365        response
366    }
367}