Skip to main content

egui_macview/controls/
checkbox.rs

1//! Authentic macOS AppKit Square Checkbox (`NSButtonTypeSwitch`, `v0.6.5`).
2//!
3//! Grounded in macOS Sonoma 14.8.9 AppKit `NSButton(checkboxWithTitle:)` ground-truth metrics
4//! and W3C DTCG tokens via [`MacTheme`].
5//!
6//! Unlike [`crate::MacToggle`] (`NSSwitch`, a `26x15pt` sliding pill), `MacCheckbox` renders
7//! the native AppKit square rounded checkbox (`14x14pt` Regular, `12x12pt` Small, `10x10pt` Mini)
8//! with a vector checkmark (or mixed `-` dash) and optical-tracked label aligned on the AppKit baseline.
9
10use crate::controls::MacControlSize;
11use crate::layout::subpixel_snap_rect;
12use crate::theme::MacTheme;
13use egui::{
14    CornerRadius, FontId, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
15    Vec2, Widget, WidgetText,
16};
17
18/// Authentic macOS AppKit Square Checkbox (`NSButtonTypeSwitch`).
19///
20/// # Example
21/// ```rust
22/// use egui_macview::{MacAppearance, MacCheckbox, MacControlSize, MacTheme};
23/// # let ctx = egui::Context::default();
24/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
25/// let theme = MacTheme::new(MacAppearance::Aqua);
26/// let mut checked = true;
27/// MacCheckbox::new(&mut checked, "Show Grid Lines").control_size(MacControlSize::Regular).theme(&theme).show(ui);
28/// # });
29/// # out.textures_delta.clear();
30/// ```
31pub struct MacCheckbox<'a> {
32    checked: &'a mut bool,
33    label: WidgetText,
34    mixed: bool,
35    radio: bool,
36    unaccented: bool,
37    control_size: MacControlSize,
38    fixed_rect: Option<Rect>,
39    enabled: bool,
40    theme: Option<&'a MacTheme>,
41}
42
43/// Authentic macOS AppKit Radio Button (`NSButtonTypeRadio`), backed by [`MacCheckbox`].
44pub struct MacRadioButton<'a>(MacCheckbox<'a>);
45
46impl<'a> MacRadioButton<'a> {
47    /// Creates a new AppKit circular radio button (`NSButtonTypeRadio`) bound to `checked` with `label`.
48    pub fn new(checked: &'a mut bool, label: impl Into<WidgetText>) -> Self {
49        Self(MacCheckbox::new_radio(checked, label))
50    }
51    /// Creates a new AppKit circular radio button (`NSButtonTypeRadio`) bound to `checked` with `label`.
52    pub fn new_radio(checked: &'a mut bool, label: impl Into<WidgetText>) -> Self {
53        Self::new(checked, label)
54    }
55    /// Enables or disables circular radio button presentation.
56    pub fn radio(mut self, radio: bool) -> Self { self.0 = self.0.radio(radio); self }
57    /// Enables unaccented NIB / Graphite indicator presentation.
58    pub fn unaccented(mut self, unaccented: bool) -> Self { self.0 = self.0.unaccented(unaccented); self }
59    /// Sets the AppKit control size (`Regular` `14x14pt`, `Small` `12x12pt`, `Mini` `10x10pt`).
60    pub fn control_size(mut self, size: MacControlSize) -> Self { self.0 = self.0.control_size(size); self }
61    /// Pins the radio button to an exact NIB rectangle.
62    pub fn fixed_rect(mut self, rect: Rect) -> Self { self.0 = self.0.fixed_rect(rect); self }
63    /// Enables or disables interaction.
64    pub fn enabled(mut self, enabled: bool) -> Self { self.0 = self.0.enabled(enabled); self }
65    /// Injects dynamic [`MacTheme`].
66    pub fn theme(mut self, theme: &'a MacTheme) -> Self { self.0 = self.0.theme(theme); self }
67    /// Resolves the indicator diameter (`14.0pt`, `12.0pt`, or `10.0pt`).
68    pub fn box_size(&self) -> f32 { self.0.box_size() }
69    /// Renders the radio button into `ui`.
70    pub fn show(self, ui: &mut Ui) -> Response { self.0.show(ui) }
71}
72
73impl<'a> Widget for MacRadioButton<'a> {
74    fn ui(self, ui: &mut Ui) -> Response {
75        self.0.ui(ui)
76    }
77}
78
79impl<'a> MacCheckbox<'a> {
80    /// Creates a new AppKit square checkbox bound to `checked` with `label`.
81    pub fn new(checked: &'a mut bool, label: impl Into<WidgetText>) -> Self {
82        Self {
83            checked,
84            label: label.into(),
85            mixed: false,
86            radio: false,
87            unaccented: false,
88            control_size: MacControlSize::Regular,
89            fixed_rect: None,
90            enabled: true,
91            theme: None,
92        }
93    }
94
95    /// Creates a new AppKit circular radio button (`NSButtonTypeRadio`) bound to `checked` with `label`.
96    pub fn new_radio(checked: &'a mut bool, label: impl Into<WidgetText>) -> Self {
97        Self::new(checked, label).radio(true)
98    }
99
100    /// Enables circular AppKit radio button presentation (`NSButtonTypeRadio`).
101    pub fn radio(mut self, radio: bool) -> Self {
102        self.radio = radio;
103        self
104    }
105
106    /// Enables unaccented NIB / Graphite indicator presentation (white bezel with dark glyph).
107    pub fn unaccented(mut self, unaccented: bool) -> Self {
108        self.unaccented = unaccented;
109        self
110    }
111
112    /// Sets the AppKit indeterminate / mixed state (`NSMixedState`, horizontal `-` dash).
113    pub fn mixed(mut self, mixed: bool) -> Self {
114        self.mixed = mixed;
115        self
116    }
117
118    /// Sets the AppKit control size (`Regular` `14x14pt`, `Small` `12x12pt`, `Mini` `10x10pt`).
119    pub fn control_size(mut self, size: MacControlSize) -> Self {
120        self.control_size = size;
121        self
122    }
123
124    /// Pins the checkbox to an exact NIB rectangle.
125    pub fn fixed_rect(mut self, rect: Rect) -> Self {
126        self.fixed_rect = Some(rect);
127        self
128    }
129
130    /// Enables or disables interaction.
131    pub fn enabled(mut self, enabled: bool) -> Self {
132        self.enabled = enabled;
133        self
134    }
135
136    /// Injects dynamic [`MacTheme`].
137    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
138        self.theme = Some(theme);
139        self
140    }
141
142    /// Resolves the square indicator box dimension (`14.0pt`, `12.0pt`, or `10.0pt`).
143    pub fn box_size(&self) -> f32 {
144        let theme_cow = MacTheme::resolve_or_default(self.theme);
145        let cm = theme_cow.checkbox();
146        match self.control_size {
147            MacControlSize::Regular | MacControlSize::Large => cm.regular.box_size.points(),
148            MacControlSize::Small | MacControlSize::CompactInfobox => cm.small.box_size.points(),
149            MacControlSize::Mini => cm.mini.box_size.points(),
150        }
151    }
152
153    /// Renders the checkbox into `ui`.
154    pub fn show(self, ui: &mut Ui) -> Response {
155        ui.add(self)
156    }
157}
158
159impl<'a> Widget for MacCheckbox<'a> {
160    fn ui(self, ui: &mut Ui) -> Response {
161        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
162        let theme: &MacTheme = &theme_cow;
163        let cm = theme.checkbox();
164        let lm = theme.label_metrics();
165
166        let (sz_metrics, ascender, cell_h) = match self.control_size {
167            MacControlSize::Regular | MacControlSize::Large => (&cm.regular, lm.regular_ascender.points(), lm.regular_height.points()),
168            MacControlSize::Small | MacControlSize::CompactInfobox => (&cm.small, lm.small_ascender.points(), lm.small_height.points()),
169            MacControlSize::Mini => (&cm.mini, lm.mini_ascender.points(), lm.mini_height.points()),
170        };
171
172        let box_sz = sz_metrics.box_size.points();
173        let corner_r = sz_metrics.corner_radius.points();
174        let gap = sz_metrics.label_gap.points();
175        let font_sz = sz_metrics.font_size.points();
176        let check_stroke_w = sz_metrics.checkmark_stroke.points();
177        let baseline_from_top = sz_metrics.first_baseline_offset_from_top.points();
178        let border_w = cm.border_width.points();
179
180        let enabled = self.enabled && ui.is_enabled();
181        let alpha = if enabled { 1.0 } else { theme.disabled_opacity() };
182        let probe_pos = self.fixed_rect.map_or_else(
183            || ui.next_widget_position(),
184            |r| Pos2::new(r.left() + (box_sz * 0.5).min(r.width() * 0.5), r.center().y),
185        );
186        let surface_bg = theme.active_surface_at(ui, probe_pos);
187        let text_color = if enabled { theme.label_on(surface_bg) } else { theme.tertiary_label_on(surface_bg) };
188        let label_str = self.label.text();
189        let galley = (!label_str.is_empty()).then(|| {
190            theme.layout_tracked_galley(ui, label_str, FontId::proportional(font_sz), text_color, f32::INFINITY)
191        });
192
193        let desired_w = match (&self.fixed_rect, &galley) {
194            (Some(r), _) => r.width(),
195            (None, Some(g)) => box_sz + gap + g.size().x,
196            (None, None) => box_sz,
197        };
198        let desired_h = self.fixed_rect.map(|r| r.height()).unwrap_or_else(|| box_sz.max(cell_h));
199
200        let sense = if enabled { Sense::click() } else { Sense::hover() };
201        let ppp = ui.ctx().pixels_per_point();
202        let (rect, mut response) = if let Some(fixed) = self.fixed_rect {
203            let snapped = subpixel_snap_rect(fixed, ppp);
204            let id = ui.next_auto_id().with("mac_checkbox_fixed");
205            ui.skip_ahead_auto_ids(1);
206            (snapped, ui.interact(snapped, id, sense))
207        } else {
208            let (r, resp) = ui.allocate_exact_size(Vec2::new(desired_w, desired_h), sense);
209            (subpixel_snap_rect(r, ppp), resp)
210        };
211
212        if enabled {
213            let key_toggle = response.has_focus() && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Space));
214            if response.clicked() || key_toggle {
215                *self.checked = !*self.checked;
216                response.mark_changed();
217            }
218        }
219
220        let painter = ui.painter_at(rect);
221        let box_rect = subpixel_snap_rect(
222            Rect::from_min_size(Pos2::new(rect.left(), rect.center().y - box_sz * 0.5), Vec2::splat(box_sz)),
223            ppp,
224        );
225
226        if enabled && response.has_focus() {
227            theme.paint_focus_ring(&painter, box_rect, corner_r);
228        }
229
230        let is_active = *self.checked || self.mixed;
231        let fill_color = if is_active && !self.unaccented {
232            theme.control_accent().gamma_multiply(alpha)
233        } else if response.is_pointer_button_down_on() && enabled {
234            theme.control().gamma_multiply(alpha)
235        } else {
236            theme.control_background().gamma_multiply(alpha)
237        };
238
239        let stroke = if is_active && !self.unaccented {
240            Stroke::NONE
241        } else {
242            Stroke::new(border_w, theme.separator().gamma_multiply(alpha))
243        };
244        let glyph_fg = if self.unaccented {
245            theme.label().gamma_multiply(alpha)
246        } else {
247            theme.on_accent_text().gamma_multiply(alpha)
248        };
249
250        if self.radio {
251            painter.circle(box_rect.center(), box_sz * 0.5, fill_color, stroke);
252            if *self.checked {
253                painter.circle_filled(box_rect.center(), box_sz * cm.mixed_bar_inset_ratio.points(), glyph_fg);
254            }
255        } else {
256            painter.rect(box_rect, CornerRadius::same(corner_r.round() as u8), fill_color, stroke, StrokeKind::Inside);
257            let glyph_stroke = Stroke::new(check_stroke_w, glyph_fg);
258            if self.mixed {
259                let (inset, cy) = (box_sz * cm.mixed_bar_inset_ratio.points(), box_rect.center().y);
260                painter.line_segment([Pos2::new(box_rect.left() + inset, cy), Pos2::new(box_rect.right() - inset, cy)], glyph_stroke);
261            } else if *self.checked {
262                let p1 = Pos2::new(box_rect.left() + box_sz * cm.check_p1_x.points(), box_rect.top() + box_sz * cm.check_p1_y.points());
263                let p2 = Pos2::new(box_rect.left() + box_sz * cm.check_p2_x.points(), box_rect.top() + box_sz * cm.check_p2_y.points());
264                let p3 = Pos2::new(box_rect.left() + box_sz * cm.check_p3_x.points(), box_rect.top() + box_sz * cm.check_p3_y.points());
265                painter.line_segment([p1, p2], glyph_stroke);
266                painter.line_segment([p2, p3], glyph_stroke);
267            }
268        }
269
270        if let Some(galley) = galley {
271            let text_y = theme.baseline_text_y(rect, baseline_from_top, ascender, ppp);
272            let text_pos = Pos2::new(box_rect.right() + gap, text_y);
273            painter.galley(text_pos, galley, text_color);
274        }
275
276        response
277    }
278}