Skip to main content

egui_macview/controls/
label.rs

1//! Authentic macOS AppKit Non-Editable Label (`NSTextField.labelWithString:`, `v0.6.5`).
2//!
3//! Grounded in macOS Sonoma 14.8.9 AppKit `NSTextField` label metrics and W3C DTCG tokens via [`MacTheme`].
4//!
5//! Provides exact AppKit typography sizing (`13pt` Regular, `11pt` Small, `9pt` Mini),
6//! weight variants (`Regular`, `Medium`, `Semibold`, `Bold`), semantic label colors
7//! (`Primary`, `Secondary`, `Tertiary`), optical tracking, and deterministic
8//! cross-platform baseline positioning (`firstBaselineOffsetFromTop`).
9
10use crate::controls::MacControlSize;
11use crate::layout::{subpixel_snap, subpixel_snap_rect};
12use crate::theme::MacTheme;
13use egui::{Align, Color32, FontId, Pos2, Rect, Response, Sense, Ui, Vec2, Widget, WidgetText};
14
15/// Font weight tiers for [`MacLabel`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum MacFontWeight {
18    /// Standard regular weight (`NSFontWeightRegular`).
19    #[default]
20    Regular,
21    /// Medium weight (`NSFontWeightMedium`).
22    Medium,
23    /// Semibold weight (`NSFontWeightSemibold`, used for section headers).
24    Semibold,
25    /// Bold weight (`NSFontWeightBold`).
26    Bold,
27}
28
29/// Semantic AppKit label color tiers for [`MacLabel`].
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum MacLabelColor {
32    /// Primary label color (`NSColor.labelColor`).
33    #[default]
34    Primary,
35    /// Secondary label color (`NSColor.secondaryLabelColor`).
36    Secondary,
37    /// Tertiary label color (`NSColor.tertiaryLabelColor`).
38    Tertiary,
39}
40
41/// Authentic macOS AppKit Non-Editable Label (`NSTextField.labelWithString:`).
42///
43/// # Example
44/// ```rust
45/// use egui_macview::{
46///     MacAppearance, MacControlSize, MacFontWeight, MacLabel, MacLabelColor, MacTheme,
47/// };
48///
49/// # let ctx = egui::Context::default();
50/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
51/// let theme = MacTheme::new(MacAppearance::Aqua);
52///
53/// MacLabel::new("Output Resolution:")
54///     .control_size(MacControlSize::Regular)
55///     .weight(MacFontWeight::Semibold)
56///     .color(MacLabelColor::Primary)
57///     .theme(&theme)
58///     .show(ui);
59/// # });
60/// # out.textures_delta.clear();
61/// ```
62pub struct MacLabel<'a> {
63    text: WidgetText,
64    control_size: MacControlSize,
65    weight: MacFontWeight,
66    color: MacLabelColor,
67    alignment: Align,
68    fixed_rect: Option<Rect>,
69    theme: Option<&'a MacTheme>,
70}
71
72impl<'a> MacLabel<'a> {
73    /// Creates a new AppKit non-editable label with `text`.
74    pub fn new(text: impl Into<WidgetText>) -> Self {
75        Self {
76            text: text.into(),
77            control_size: MacControlSize::Regular,
78            weight: MacFontWeight::Regular,
79            color: MacLabelColor::Primary,
80            alignment: Align::Min,
81            fixed_rect: None,
82            theme: None,
83        }
84    }
85
86    /// Sets the AppKit control size (`Regular` `13pt`, `Small` `11pt`, `Mini` `9pt`).
87    pub fn control_size(mut self, size: MacControlSize) -> Self {
88        self.control_size = size;
89        self
90    }
91
92    /// Sets the font weight (`Regular`, `Medium`, `Semibold`, `Bold`).
93    pub fn weight(mut self, weight: MacFontWeight) -> Self {
94        self.weight = weight;
95        self
96    }
97
98    /// Sets the semantic label color (`Primary`, `Secondary`, `Tertiary`).
99    pub fn color(mut self, color: MacLabelColor) -> Self {
100        self.color = color;
101        self
102    }
103
104    /// Sets horizontal text alignment (`Align::Min`, `Align::Center`, `Align::Max`).
105    pub fn alignment(mut self, alignment: Align) -> Self {
106        self.alignment = alignment;
107        self
108    }
109
110    /// Pins the label to an exact NIB rectangle.
111    pub fn fixed_rect(mut self, rect: Rect) -> Self {
112        self.fixed_rect = Some(rect);
113        self
114    }
115
116    /// Injects dynamic [`MacTheme`].
117    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
118        self.theme = Some(theme);
119        self
120    }
121
122    /// Resolves the font size in points (`13.0pt`, `11.0pt`, or `9.0pt`).
123    pub fn font_size(&self) -> f32 {
124        let theme_cow = MacTheme::resolve_or_default(self.theme);
125        let lm = theme_cow.label_metrics();
126        match self.control_size {
127            MacControlSize::Regular | MacControlSize::Large => lm.regular_font_size.points(),
128            MacControlSize::Small | MacControlSize::CompactInfobox => lm.small_font_size.points(),
129            MacControlSize::Mini => lm.mini_font_size.points(),
130        }
131    }
132
133    /// Resolves the semantic [`Color32`] from `theme` on the default window background.
134    pub fn resolved_color(&self, theme: &MacTheme) -> Color32 {
135        match self.color {
136            MacLabelColor::Primary => theme.label(),
137            MacLabelColor::Secondary => theme.secondary_label(),
138            MacLabelColor::Tertiary => theme.tertiary_label(),
139        }
140    }
141
142    /// Resolves the vibrant, WCAG-clamped semantic [`Color32`] from `theme` on `surface_bg`.
143    pub fn resolved_color_on(&self, theme: &MacTheme, surface_bg: Color32) -> Color32 {
144        match self.color {
145            MacLabelColor::Primary => theme.label_on(surface_bg),
146            MacLabelColor::Secondary => theme.secondary_label_on(surface_bg),
147            MacLabelColor::Tertiary => theme.tertiary_label_on(surface_bg),
148        }
149    }
150
151    /// Renders the label into `ui`.
152    pub fn show(self, ui: &mut Ui) -> Response {
153        ui.add(self)
154    }
155}
156
157impl<'a> Widget for MacLabel<'a> {
158    fn ui(self, ui: &mut Ui) -> Response {
159        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
160        let theme: &MacTheme = &theme_cow;
161        let lm = theme.label_metrics();
162
163        let (font_sz, cell_h, baseline_from_top, ascender) = match self.control_size {
164            MacControlSize::Regular | MacControlSize::Large => (
165                lm.regular_font_size.points(),
166                lm.regular_height.points(),
167                lm.regular_baseline_from_top.points(),
168                lm.regular_ascender.points(),
169            ),
170            MacControlSize::Small | MacControlSize::CompactInfobox => (
171                lm.small_font_size.points(),
172                lm.small_height.points(),
173                lm.small_baseline_from_top.points(),
174                lm.small_ascender.points(),
175            ),
176            MacControlSize::Mini => (
177                lm.mini_font_size.points(),
178                lm.mini_height.points(),
179                lm.mini_baseline_from_top.points(),
180                lm.mini_ascender.points(),
181            ),
182        };
183
184        let surface_bg = theme.active_surface(ui);
185        let text_color = self.resolved_color_on(theme, surface_bg);
186        let is_heavy = matches!(
187            self.weight,
188            MacFontWeight::Medium | MacFontWeight::Semibold | MacFontWeight::Bold
189        );
190        let (font_id, has_semibold_family) = if is_heavy {
191            theme.semibold_font_id(ui, font_sz)
192        } else {
193            (FontId::proportional(font_sz), false)
194        };
195
196        let wrap_w = self.fixed_rect.map_or(f32::INFINITY, |r| r.width());
197        let galley =
198            theme.layout_tracked_galley(ui, self.text.text(), font_id, text_color, wrap_w);
199
200        let ppp = ui.ctx().pixels_per_point();
201        let (rect, response) = if let Some(fixed) = self.fixed_rect {
202            let snapped = subpixel_snap_rect(fixed, ppp);
203            let id = ui.next_auto_id().with("mac_label_fixed");
204            let resp = ui.interact(snapped, id, Sense::hover());
205            (snapped, resp)
206        } else {
207            let desired = Vec2::new(galley.size().x, cell_h.max(galley.size().y));
208            let (r, resp) = ui.allocate_exact_size(desired, Sense::hover());
209            (subpixel_snap_rect(r, ppp), resp)
210        };
211
212        let painter = ui.painter_at(rect);
213        let text_y = subpixel_snap(rect.top() + (baseline_from_top - ascender), ppp);
214        let text_x = match self.alignment {
215            Align::Min => rect.left(),
216            Align::Center => rect.center().x - galley.size().x * 0.5,
217            Align::Max => (rect.right() - galley.size().x).max(rect.left()),
218        };
219        let pos = Pos2::new(text_x, text_y);
220        painter.galley(pos, galley.clone(), text_color);
221
222        if is_heavy && !has_semibold_family {
223            let boost = match self.weight {
224                MacFontWeight::Bold => lm.bold_stroke_boost.points(),
225                MacFontWeight::Medium | MacFontWeight::Semibold => lm.semibold_stroke_boost.points(),
226                MacFontWeight::Regular => 0.0,
227            };
228            if boost > 0.0 {
229                painter.galley(Pos2::new(pos.x + boost, pos.y), galley, text_color);
230            }
231        }
232
233        response
234    }
235}