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