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