1#[cfg(all(target_os = "macos", feature = "macos"))]
6use objc2_app_kit::{NSAppearance, NSColor, NSColorSpace, NSFont, NSFontWeight};
7#[cfg(all(target_os = "macos", feature = "macos"))]
8use objc2_foundation::NSString;
9
10#[cfg(all(target_os = "macos", feature = "macos"))]
16fn nscolor_to_rgba(color: &NSColor, srgb: &NSColorSpace) -> Option<crate::Rgba> {
17 let srgb_color = unsafe { color.colorUsingColorSpace(srgb) }?;
18 let r = unsafe { srgb_color.redComponent() } as f32;
19 let g = unsafe { srgb_color.greenComponent() } as f32;
20 let b = unsafe { srgb_color.blueComponent() } as f32;
21 let a = unsafe { srgb_color.alphaComponent() } as f32;
22 Some(crate::Rgba::from_f32(r, g, b, a))
23}
24
25#[cfg(all(target_os = "macos", feature = "macos"))]
30fn read_semantic_colors() -> crate::ThemeColors {
31 let srgb = unsafe { NSColorSpace::sRGBColorSpace() };
32
33 let label_c = unsafe { NSColor::labelColor() };
38 let control_accent = unsafe { NSColor::controlAccentColor() };
39 let window_bg = unsafe { NSColor::windowBackgroundColor() };
40 let control_bg = unsafe { NSColor::controlBackgroundColor() };
41 let separator_c = unsafe { NSColor::separatorColor() };
42 let secondary_label = unsafe { NSColor::secondaryLabelColor() };
43 let shadow_c = unsafe { NSColor::shadowColor() };
44 let alt_sel_text = unsafe { NSColor::alternateSelectedControlTextColor() };
45 let control_c = unsafe { NSColor::controlColor() };
46 let control_text = unsafe { NSColor::controlTextColor() };
47 let system_red = unsafe { NSColor::systemRedColor() };
48 let system_orange = unsafe { NSColor::systemOrangeColor() };
49 let system_green = unsafe { NSColor::systemGreenColor() };
50 let system_blue = unsafe { NSColor::systemBlueColor() };
51 let sel_content_bg = unsafe { NSColor::selectedContentBackgroundColor() };
52 let sel_text = unsafe { NSColor::selectedTextColor() };
53 let link_c = unsafe { NSColor::linkColor() };
54 let focus_c = unsafe { NSColor::keyboardFocusIndicatorColor() };
55 let under_page_bg = unsafe { NSColor::underPageBackgroundColor() };
56 let text_bg = unsafe { NSColor::textBackgroundColor() };
57 let text_c = unsafe { NSColor::textColor() };
58 let disabled_text = unsafe { NSColor::disabledControlTextColor() };
59
60 let label = nscolor_to_rgba(&label_c, &srgb);
61
62 crate::ThemeColors {
63 accent: nscolor_to_rgba(&control_accent, &srgb),
65 background: nscolor_to_rgba(&window_bg, &srgb),
66 foreground: label,
67 surface: nscolor_to_rgba(&control_bg, &srgb),
68 border: nscolor_to_rgba(&separator_c, &srgb),
69 muted: nscolor_to_rgba(&secondary_label, &srgb),
70 shadow: nscolor_to_rgba(&shadow_c, &srgb),
71 primary_background: nscolor_to_rgba(&control_accent, &srgb),
73 primary_foreground: nscolor_to_rgba(&alt_sel_text, &srgb),
74 secondary_background: nscolor_to_rgba(&control_c, &srgb),
76 secondary_foreground: nscolor_to_rgba(&control_text, &srgb),
77 danger: nscolor_to_rgba(&system_red, &srgb),
79 danger_foreground: label,
80 warning: nscolor_to_rgba(&system_orange, &srgb),
81 warning_foreground: label,
82 success: nscolor_to_rgba(&system_green, &srgb),
83 success_foreground: label,
84 info: nscolor_to_rgba(&system_blue, &srgb),
85 info_foreground: label,
86 selection: nscolor_to_rgba(&sel_content_bg, &srgb),
88 selection_foreground: nscolor_to_rgba(&sel_text, &srgb),
89 link: nscolor_to_rgba(&link_c, &srgb),
90 focus_ring: nscolor_to_rgba(&focus_c, &srgb),
91 sidebar: nscolor_to_rgba(&under_page_bg, &srgb),
93 sidebar_foreground: label,
94 tooltip: nscolor_to_rgba(&window_bg, &srgb),
95 tooltip_foreground: label,
96 popover: nscolor_to_rgba(&window_bg, &srgb),
97 popover_foreground: label,
98 button: nscolor_to_rgba(&control_c, &srgb),
100 button_foreground: nscolor_to_rgba(&control_text, &srgb),
101 input: nscolor_to_rgba(&text_bg, &srgb),
102 input_foreground: nscolor_to_rgba(&text_c, &srgb),
103 disabled: nscolor_to_rgba(&disabled_text, &srgb),
104 separator: nscolor_to_rgba(&separator_c, &srgb),
105 alternate_row: nscolor_to_rgba(&control_bg, &srgb),
106 }
107}
108
109#[cfg(all(target_os = "macos", feature = "macos"))]
114fn read_fonts() -> crate::ThemeFonts {
115 let system_size = unsafe { NSFont::systemFontSize() };
116 let system_font = unsafe { NSFont::systemFontOfSize(system_size) };
117 let mono_font =
118 unsafe { NSFont::monospacedSystemFontOfSize_weight(system_size, NSFontWeight::Regular) };
119
120 crate::ThemeFonts {
121 family: system_font.familyName().map(|n| n.to_string()),
122 size: Some(unsafe { system_font.pointSize() } as f32),
123 mono_family: mono_font.familyName().map(|n| n.to_string()),
124 mono_size: Some(unsafe { mono_font.pointSize() } as f32),
125 }
126}
127
128#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
133fn macos_widget_metrics() -> crate::model::widget_metrics::WidgetMetrics {
134 use crate::model::widget_metrics::*;
135
136 WidgetMetrics {
137 button: ButtonMetrics {
138 min_height: Some(22.0), padding_horizontal: Some(12.0),
140 ..Default::default()
141 },
142 checkbox: CheckboxMetrics {
143 indicator_size: Some(14.0), spacing: Some(4.0),
145 ..Default::default()
146 },
147 input: InputMetrics {
148 min_height: Some(22.0), padding_horizontal: Some(4.0),
150 ..Default::default()
151 },
152 scrollbar: ScrollbarMetrics {
153 width: Some(15.0), slider_width: Some(7.0), ..Default::default()
156 },
157 slider: SliderMetrics {
158 track_height: Some(4.0), thumb_size: Some(21.0),
160 ..Default::default()
161 },
162 progress_bar: ProgressBarMetrics {
163 height: Some(6.0), ..Default::default()
165 },
166 tab: TabMetrics {
167 min_height: Some(24.0), padding_horizontal: Some(12.0),
169 ..Default::default()
170 },
171 menu_item: MenuItemMetrics {
172 height: Some(22.0), padding_horizontal: Some(12.0),
174 ..Default::default()
175 },
176 tooltip: TooltipMetrics {
177 padding: Some(4.0),
178 ..Default::default()
179 },
180 list_item: ListItemMetrics {
181 height: Some(24.0), padding_horizontal: Some(4.0),
183 ..Default::default()
184 },
185 toolbar: ToolbarMetrics {
186 height: Some(38.0), item_spacing: Some(8.0),
188 ..Default::default()
189 },
190 splitter: SplitterMetrics {
191 width: Some(9.0), },
193 }
194}
195
196#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
203fn build_theme(
204 light_colors: crate::ThemeColors,
205 dark_colors: crate::ThemeColors,
206 fonts: crate::ThemeFonts,
207) -> crate::NativeTheme {
208 let wm = macos_widget_metrics();
209
210 crate::NativeTheme {
211 name: "macOS".to_string(),
212 light: Some(crate::ThemeVariant {
213 colors: light_colors,
214 fonts: fonts.clone(),
215 geometry: Default::default(),
216 spacing: Default::default(),
217 widget_metrics: Some(wm.clone()),
218 icon_set: None,
219 }),
220 dark: Some(crate::ThemeVariant {
221 colors: dark_colors,
222 fonts,
223 geometry: Default::default(),
224 spacing: Default::default(),
225 widget_metrics: Some(wm),
226 icon_set: None,
227 }),
228 }
229}
230
231#[cfg(all(target_os = "macos", feature = "macos"))]
242pub fn from_macos() -> crate::Result<crate::NativeTheme> {
243 let light_name = NSString::from_str("NSAppearanceNameAqua");
244 let dark_name = NSString::from_str("NSAppearanceNameDarkAqua");
245
246 let light_appearance = unsafe { NSAppearance::appearanceNamed(&light_name) };
247 let dark_appearance = unsafe { NSAppearance::appearanceNamed(&dark_name) };
248
249 if light_appearance.is_none() && dark_appearance.is_none() {
250 return Err(crate::Error::Unavailable(
251 "neither light nor dark NSAppearance could be created".to_string(),
252 ));
253 }
254
255 let light_colors = if let Some(app) = &light_appearance {
256 let mut colors = crate::ThemeColors::default();
257 app.performAsCurrentDrawingAppearance(|| {
258 colors = read_semantic_colors();
259 });
260 colors
261 } else {
262 crate::ThemeColors::default()
263 };
264
265 let dark_colors = if let Some(app) = &dark_appearance {
266 let mut colors = crate::ThemeColors::default();
267 app.performAsCurrentDrawingAppearance(|| {
268 colors = read_semantic_colors();
269 });
270 colors
271 } else {
272 crate::ThemeColors::default()
273 };
274
275 let fonts = read_fonts();
276
277 Ok(build_theme(light_colors, dark_colors, fonts))
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn sample_light_colors() -> crate::ThemeColors {
285 crate::ThemeColors {
286 accent: Some(crate::Rgba::rgb(0, 122, 255)),
287 background: Some(crate::Rgba::rgb(246, 246, 246)),
288 foreground: Some(crate::Rgba::rgb(0, 0, 0)),
289 surface: Some(crate::Rgba::rgb(255, 255, 255)),
290 border: Some(crate::Rgba::rgb(200, 200, 200)),
291 ..Default::default()
292 }
293 }
294
295 fn sample_dark_colors() -> crate::ThemeColors {
296 crate::ThemeColors {
297 accent: Some(crate::Rgba::rgb(10, 132, 255)),
298 background: Some(crate::Rgba::rgb(30, 30, 30)),
299 foreground: Some(crate::Rgba::rgb(255, 255, 255)),
300 surface: Some(crate::Rgba::rgb(44, 44, 46)),
301 border: Some(crate::Rgba::rgb(56, 56, 58)),
302 ..Default::default()
303 }
304 }
305
306 fn sample_fonts() -> crate::ThemeFonts {
307 crate::ThemeFonts {
308 family: Some("SF Pro".to_string()),
309 size: Some(13.0),
310 mono_family: Some("SF Mono".to_string()),
311 mono_size: Some(13.0),
312 }
313 }
314
315 #[test]
316 fn build_theme_populates_both_variants() {
317 let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
318
319 assert!(theme.light.is_some(), "light variant should be Some");
320 assert!(theme.dark.is_some(), "dark variant should be Some");
321
322 let light = theme.light.as_ref().unwrap();
324 let dark = theme.dark.as_ref().unwrap();
325 assert_ne!(light.colors.accent, dark.colors.accent);
326 assert_ne!(light.colors.background, dark.colors.background);
327
328 assert_eq!(light.fonts, dark.fonts);
330 }
331
332 #[test]
333 fn build_theme_name_is_macos() {
334 let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
335 assert_eq!(theme.name, "macOS");
336 }
337
338 #[test]
339 fn build_theme_fonts_populated() {
340 let fonts = crate::ThemeFonts {
341 family: Some("SF Pro".to_string()),
342 size: Some(13.0),
343 mono_family: Some("SF Mono".to_string()),
344 mono_size: Some(13.0),
345 };
346
347 let theme = build_theme(
348 crate::ThemeColors::default(),
349 crate::ThemeColors::default(),
350 fonts,
351 );
352
353 let light = theme.light.as_ref().unwrap();
354 assert_eq!(light.fonts.family.as_deref(), Some("SF Pro"));
355 assert_eq!(light.fonts.size, Some(13.0));
356 assert_eq!(light.fonts.mono_family.as_deref(), Some("SF Mono"));
357 assert_eq!(light.fonts.mono_size, Some(13.0));
358
359 let dark = theme.dark.as_ref().unwrap();
360 assert_eq!(dark.fonts.family.as_deref(), Some("SF Pro"));
361 assert_eq!(dark.fonts.size, Some(13.0));
362 }
363
364 #[test]
365 fn build_theme_geometry_and_spacing_default() {
366 let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
367
368 let light = theme.light.as_ref().unwrap();
369 assert!(
370 light.geometry.is_empty(),
371 "light geometry should be default"
372 );
373 assert!(light.spacing.is_empty(), "light spacing should be default");
374
375 let dark = theme.dark.as_ref().unwrap();
376 assert!(dark.geometry.is_empty(), "dark geometry should be default");
377 assert!(dark.spacing.is_empty(), "dark spacing should be default");
378 }
379
380 #[test]
381 fn build_theme_colors_propagated_correctly() {
382 let blue = crate::Rgba::rgb(0, 122, 255);
383 let red = crate::Rgba::rgb(255, 59, 48);
384
385 let light_colors = crate::ThemeColors {
386 accent: Some(blue),
387 ..Default::default()
388 };
389 let dark_colors = crate::ThemeColors {
390 accent: Some(red),
391 ..Default::default()
392 };
393
394 let theme = build_theme(light_colors, dark_colors, crate::ThemeFonts::default());
395
396 let light = theme.light.as_ref().unwrap();
397 let dark = theme.dark.as_ref().unwrap();
398
399 assert_eq!(light.colors.accent, Some(blue));
400 assert_eq!(dark.colors.accent, Some(red));
401 }
402
403 #[test]
404 fn macos_widget_metrics_spot_check() {
405 let wm = macos_widget_metrics();
406 assert_eq!(
407 wm.button.min_height,
408 Some(22.0),
409 "NSButton regular control size"
410 );
411 assert_eq!(wm.scrollbar.width, Some(15.0), "NSScroller legacy style");
412 assert_eq!(
413 wm.checkbox.indicator_size,
414 Some(14.0),
415 "NSButton switch type"
416 );
417 assert_eq!(wm.slider.thumb_size, Some(21.0), "NSSlider circular knob");
418 }
419
420 #[test]
421 fn build_theme_populates_widget_metrics() {
422 let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
423
424 let light = theme.light.as_ref().unwrap();
425 assert!(
426 light.widget_metrics.is_some(),
427 "light widget_metrics should be Some"
428 );
429
430 let dark = theme.dark.as_ref().unwrap();
431 assert!(
432 dark.widget_metrics.is_some(),
433 "dark widget_metrics should be Some"
434 );
435 }
436}