Skip to main content

egui_macview/controls/
button_cell.rs

1//! Authentic macOS AppKit Push Button / Tool Button.
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via [`MacTheme`].
4//! Fully data-driven: zero hardcoded colors or dimensions.
5
6use crate::controls::MacControlSize;
7use crate::layout::subpixel_snap_rect;
8use crate::theme::MacTheme;
9use egui::{Color32, CornerRadius, Rect, Response, Stroke, StrokeKind, Ui, Vec2, Widget};
10
11/// Authentic macOS AppKit Push Button / Tool Button (`NSButtonCell` equivalent).
12///
13/// Features authentic theme-driven background, border, selection states,
14/// typography metrics with Apple HIG optical tracking, and hover feedback.
15///
16/// # Example
17///
18/// ```rust
19/// use egui_macview::{MacButtonCell, MacTheme};
20///
21/// # let ctx = egui::Context::default();
22/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
23/// let theme = MacTheme::default();
24///
25/// if ui.add(MacButtonCell::new("Save Changes").theme(&theme)).clicked() {
26///     // Handle save
27/// }
28/// # });
29/// # output.textures_delta.clear();
30/// ```
31pub struct MacButtonCell<'a> {
32    text: &'a str,
33    selected: bool,
34    enabled: bool,
35    theme: Option<&'a MacTheme>,
36    control_size: MacControlSize,
37    width: Option<f32>,
38    height: Option<f32>,
39    corner_radius: Option<f32>,
40    fixed_rect: Option<Rect>,
41}
42
43/// Convenience alias for [`MacButtonCell`].
44pub type MacButton<'a> = MacButtonCell<'a>;
45
46impl<'a> MacButtonCell<'a> {
47    /// Creates a new button with label `text`.
48    pub fn new(text: &'a str) -> Self {
49        Self {
50            text,
51            selected: false,
52            enabled: true,
53            theme: None,
54            control_size: MacControlSize::Regular,
55            width: None,
56            height: None,
57            corner_radius: None,
58            fixed_rect: None,
59        }
60    }
61
62    /// Sets the AppKit control size (`Regular` `22pt`, `Small` `18pt`, or `Mini` `15pt`).
63    pub fn control_size(mut self, size: MacControlSize) -> Self {
64        self.control_size = size;
65        self
66    }
67
68    /// Pins the button cell to an exact NIB rectangle.
69    pub fn fixed_rect(mut self, rect: Rect) -> Self {
70        self.fixed_rect = Some(rect);
71        self
72    }
73
74    /// Renders the button cell into `ui`.
75    pub fn show(self, ui: &mut Ui) -> Response {
76        ui.add(self)
77    }
78
79    /// Enables or disables the button cell.
80    pub fn enabled(mut self, enabled: bool) -> Self {
81        self.enabled = enabled;
82        self
83    }
84
85    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
86    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
87        self.theme = Some(theme);
88        self
89    }
90
91    /// Sets whether the button is presented in an active/selected state.
92    pub fn selected(mut self, selected: bool) -> Self {
93        self.selected = selected;
94        self
95    }
96
97    /// Overrides button width in points (defaults to content width + padding).
98    pub fn width(mut self, width: f32) -> Self {
99        self.width = Some(width);
100        self
101    }
102
103    /// Overrides button height in points (defaults to token regular button height).
104    pub fn height(mut self, height: f32) -> Self {
105        self.height = Some(height);
106        self
107    }
108
109    /// Overrides corner radius in points (defaults to token corner radius).
110    pub fn corner_radius(mut self, r: f32) -> Self {
111        self.corner_radius = Some(r);
112        self
113    }
114}
115
116impl<'a> Widget for MacButtonCell<'a> {
117    fn ui(self, ui: &mut Ui) -> Response {
118        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
119        let theme: &MacTheme = &theme_cow;
120
121        let metrics = match self.control_size {
122            MacControlSize::Regular | MacControlSize::Large => &theme.button().regular,
123            MacControlSize::Small | MacControlSize::CompactInfobox => &theme.button().small,
124            MacControlSize::Mini => &theme.button().mini,
125        };
126        let height = self
127            .fixed_rect
128            .map(|r| r.height())
129            .or(self.height)
130            .unwrap_or_else(|| metrics.height.points());
131        let font_sz = if height <= theme.button().mini.height.points() {
132            theme.button().mini.font_size.points()
133        } else if height <= theme.button().small.height.points() {
134            theme.button().small.font_size.points()
135        } else {
136            metrics.font_size.points()
137        };
138        let corner_radius = self
139            .corner_radius
140            .unwrap_or_else(|| metrics.corner_radius.points());
141        let padding_h = metrics
142            .padding_horizontal
143            .as_ref()
144            .map(|d| d.points())
145            .unwrap_or(font_sz);
146
147        let font_id = egui::FontId::proportional(font_sz);
148        let text_layout = theme.layout_tracked_galley(
149            ui,
150            self.text,
151            font_id,
152            Color32::PLACEHOLDER,
153            f32::INFINITY,
154        );
155        let desired_width = self
156            .width
157            .unwrap_or_else(|| text_layout.size().x + padding_h * 2.0);
158
159        let enabled = self.enabled && ui.is_enabled();
160        let sense = if enabled {
161            egui::Sense::click()
162        } else {
163            egui::Sense::hover()
164        };
165        let ppp = ui.ctx().pixels_per_point();
166        let (rect, response) = if let Some(fixed) = self.fixed_rect {
167            let snapped = subpixel_snap_rect(fixed, ppp);
168            let id = ui.next_auto_id().with("mac_button_cell_fixed");
169            ui.skip_ahead_auto_ids(1);
170            let resp = ui.interact(snapped, id, sense);
171            (snapped, resp)
172        } else {
173            let (r, resp) = ui.allocate_exact_size(Vec2::new(desired_width, height), sense);
174            (subpixel_snap_rect(r, ppp), resp)
175        };
176        let painter = ui.painter_at(rect);
177
178        let alpha_factor = if enabled {
179            1.0
180        } else {
181            theme.disabled_opacity()
182        };
183
184        // Dynamic AppKit styling (pressed feedback works even when selected == true)
185        let base_bg = if !enabled {
186            if self.selected {
187                theme.selected_control_inactive()
188            } else {
189                theme.control_background()
190            }
191        } else if response.is_pointer_button_down_on() {
192            theme.selected_control_inactive()
193        } else if self.selected {
194            theme.selected_control()
195        } else if response.hovered() {
196            theme.control()
197        } else {
198            theme.control_background()
199        };
200        let bg_color = base_bg.gamma_multiply(alpha_factor);
201
202        let border_stroke = Stroke::new(
203            metrics.border_width.points(),
204            theme.separator().gamma_multiply(alpha_factor),
205        );
206
207        painter.rect(
208            rect,
209            CornerRadius::same(corner_radius as u8),
210            bg_color,
211            border_stroke,
212            StrokeKind::Inside,
213        );
214
215        let bg_style = theme.background_style(base_bg);
216        let text_color = bg_style
217            .primary_text_color(theme)
218            .gamma_multiply(alpha_factor);
219
220        let text_pos = egui::Align2::CENTER_CENTER
221            .anchor_size(rect.center(), text_layout.size())
222            .min;
223        painter.galley(text_pos, text_layout, text_color);
224
225        response
226    }
227}