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