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, 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 alpha = if self.enabled {
141            1.0
142        } else {
143            theme.disabled_opacity()
144        };
145        let text_color = theme.label().gamma_multiply(alpha);
146        let label_str = self.label.text();
147        let galley = if label_str.is_empty() {
148            None
149        } else {
150            Some(theme.layout_tracked_galley(
151                ui,
152                label_str,
153                FontId::proportional(font_sz),
154                text_color,
155                f32::INFINITY,
156            ))
157        };
158
159        let desired_w = match (&self.fixed_rect, &galley) {
160            (Some(r), _) => r.width(),
161            (None, Some(g)) => box_sz + gap + g.size().x,
162            (None, None) => box_sz,
163        };
164        let desired_h = self
165            .fixed_rect
166            .map(|r| r.height())
167            .unwrap_or_else(|| box_sz.max(cell_h));
168
169        let sense = if self.enabled {
170            Sense::click()
171        } else {
172            Sense::hover()
173        };
174        let ppp = ui.ctx().pixels_per_point();
175        let (rect, mut response) = if let Some(fixed) = self.fixed_rect {
176            let snapped = subpixel_snap_rect(fixed, ppp);
177            let id = ui.next_auto_id().with("mac_checkbox_fixed");
178            let resp = ui.interact(snapped, id, sense);
179            (snapped, resp)
180        } else {
181            let (r, resp) = ui.allocate_exact_size(Vec2::new(desired_w, desired_h), sense);
182            (subpixel_snap_rect(r, ppp), resp)
183        };
184
185        if self.enabled {
186            let key_toggle = response.has_focus()
187                && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Space));
188            if response.clicked() || key_toggle {
189                *self.checked = !*self.checked;
190                response.mark_changed();
191            }
192        }
193
194        let painter = ui.painter_at(rect);
195        let box_rect = subpixel_snap_rect(
196            Rect::from_min_size(
197                Pos2::new(rect.left(), rect.center().y - box_sz * 0.5),
198                Vec2::splat(box_sz),
199            ),
200            ppp,
201        );
202
203        if self.enabled && response.has_focus() {
204            theme.paint_focus_ring(&painter, box_rect, corner_r);
205        }
206
207        let is_active = *self.checked || self.mixed;
208        let fill_color = if is_active {
209            theme.control_accent().gamma_multiply(alpha)
210        } else if response.is_pointer_button_down_on() && self.enabled {
211            theme.control().gamma_multiply(alpha)
212        } else {
213            theme.control_background().gamma_multiply(alpha)
214        };
215
216        let stroke = if is_active {
217            Stroke::NONE
218        } else {
219            Stroke::new(border_w, theme.separator().gamma_multiply(alpha))
220        };
221
222        painter.rect(
223            box_rect,
224            CornerRadius::same(corner_r.round() as u8),
225            fill_color,
226            stroke,
227            StrokeKind::Inside,
228        );
229
230        let glyph_stroke = Stroke::new(check_stroke_w, theme.on_accent_text().gamma_multiply(alpha));
231        if self.mixed {
232            let inset = box_sz * cm.mixed_bar_inset_ratio.points();
233            let cy = box_rect.center().y;
234            painter.line_segment(
235                [
236                    Pos2::new(box_rect.left() + inset, cy),
237                    Pos2::new(box_rect.right() - inset, cy),
238                ],
239                glyph_stroke,
240            );
241        } else if *self.checked {
242            let p1 = Pos2::new(
243                box_rect.left() + box_sz * cm.check_p1_x.points(),
244                box_rect.top() + box_sz * cm.check_p1_y.points(),
245            );
246            let p2 = Pos2::new(
247                box_rect.left() + box_sz * cm.check_p2_x.points(),
248                box_rect.top() + box_sz * cm.check_p2_y.points(),
249            );
250            let p3 = Pos2::new(
251                box_rect.left() + box_sz * cm.check_p3_x.points(),
252                box_rect.top() + box_sz * cm.check_p3_y.points(),
253            );
254            painter.line_segment([p1, p2], glyph_stroke);
255            painter.line_segment([p2, p3], glyph_stroke);
256        }
257
258        if let Some(galley) = galley {
259            let text_y = subpixel_snap(rect.top() + (baseline_from_top - ascender).max(0.0), ppp);
260            let text_pos = Pos2::new(box_rect.right() + gap, text_y);
261            painter.galley(text_pos, galley, text_color);
262        }
263
264        response
265    }
266}