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