1use crate::color::{
2 color_alpha, color_blue, color_green, color_red, mix_color, rgb, rgba, with_alpha,
3};
4use crate::generated::framework_host_services;
5use crate::signal::{Callback, Signal, SubscriptionGuard};
6use crate::typography::{FontFamily, FontStack};
7use std::cell::RefCell;
8use std::rc::Rc;
9
10const DEFAULT_ACCENT_COLOR: u32 = rgb(0x25, 0x63, 0xeb);
11const WHITE: u32 = rgb(0xff, 0xff, 0xff);
12const BLACK: u32 = rgb(0x00, 0x00, 0x00);
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct Colors {
16 pub background: u32,
17 pub surface: u32,
18 pub text_primary: u32,
19 pub text_muted: u32,
20 pub text_on_accent: u32,
21 pub accent: u32,
22 pub accent_pressed: u32,
23 pub accent_hovered: u32,
24 pub border: u32,
25 pub selection: u32,
26 pub scrollbar_track: u32,
27 pub scrollbar_thumb: u32,
28 pub dialog_backdrop: u32,
29 pub dialog_shadow: u32,
30 pub panel_shadow: u32,
31 pub focus_ring: u32,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct Spacing {
36 pub xs: f32,
37 pub sm: f32,
38 pub md: f32,
39 pub lg: f32,
40 pub xl: f32,
41}
42
43#[derive(Clone, Debug, PartialEq)]
44pub struct Fonts {
45 pub body_stack: FontStack,
46 pub heading_stack: FontStack,
47 pub mono_stack: FontStack,
48 pub mono_bold_stack: FontStack,
49 pub body_family: FontFamily,
50 pub heading_family: FontFamily,
51 pub mono_family: FontFamily,
52 pub size_body: f32,
53 pub size_heading: f32,
54 pub size_mono: f32,
55}
56
57#[derive(Clone, Debug, PartialEq)]
58pub struct ContextMenuItemTheme {
59 pub background: u32,
60 pub hover_background: u32,
61 pub text_color: u32,
62 pub corner_radius: f32,
63 pub font_family: FontFamily,
64 pub font_size: f32,
65 pub height: f32,
66 pub padding_left: f32,
67 pub padding_top: f32,
68 pub padding_right: f32,
69 pub padding_bottom: f32,
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct ContextMenuTheme {
74 pub panel_background: u32,
75 pub panel_border_color: u32,
76 pub panel_shadow_color: u32,
77 pub panel_corner_radius: f32,
78 pub separator_color: u32,
79 pub shadow_offset_y: f32,
80 pub shadow_blur: f32,
81 pub shadow_spread: f32,
82 pub item: ContextMenuItemTheme,
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub struct ToolTipTheme {
87 pub panel_background: u32,
88 pub panel_border_color: u32,
89 pub panel_shadow_color: u32,
90 pub panel_corner_radius: f32,
91 pub text_color: u32,
92 pub font_family: FontFamily,
93 pub font_size: f32,
94 pub max_width: f32,
95 pub padding_left: f32,
96 pub padding_top: f32,
97 pub padding_right: f32,
98 pub padding_bottom: f32,
99 pub shadow_offset_y: f32,
100 pub shadow_blur: f32,
101 pub shadow_spread: f32,
102}
103
104#[derive(Clone, Debug, PartialEq)]
105pub struct Theme {
106 pub colors: Colors,
107 pub spacing: Spacing,
108 pub fonts: Fonts,
109 pub context_menu: ContextMenuTheme,
110 pub tool_tip: ToolTipTheme,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114enum ThemeSource {
115 System,
116 Custom,
117}
118
119struct ThemeState {
120 signal: Signal<Theme>,
121 theme_source: ThemeSource,
122 system_dark_mode: bool,
123 system_accent_color: u32,
124 current_dark_mode: bool,
125}
126
127thread_local! {
128 static THEME_STATE: RefCell<ThemeState> = RefCell::new(ThemeState {
129 signal: Signal::new(default_dark_theme()),
130 theme_source: ThemeSource::System,
131 system_dark_mode: true,
132 system_accent_color: DEFAULT_ACCENT_COLOR,
133 current_dark_mode: true,
134 });
135}
136
137const DEFAULT_SPACING: Spacing = Spacing {
138 xs: 4.0,
139 sm: 8.0,
140 md: 16.0,
141 lg: 24.0,
142 xl: 32.0,
143};
144fn default_fonts() -> Fonts {
145 let body_stack = FontStack::from_id(1).fallback_face(crate::typography::FontFace::new(3));
146 let heading_stack = FontStack::from_id(2).fallback_face(crate::typography::FontFace::new(3));
147 let mono_stack = FontStack::from_id(7)
148 .fallback_face(crate::typography::FontFace::new(4))
149 .fallback_face(crate::typography::FontFace::new(3));
150 let mono_bold_stack = FontStack::from_id(8)
151 .fallback_face(crate::typography::FontFace::new(4))
152 .fallback_face(crate::typography::FontFace::new(3));
153 Fonts {
154 body_stack: body_stack.clone(),
155 heading_stack: heading_stack.clone(),
156 mono_stack: mono_stack.clone(),
157 mono_bold_stack: mono_bold_stack.clone(),
158 body_family: FontFamily::regular_bold_stacks(body_stack.clone(), heading_stack.clone())
159 .italic_stack(FontStack::from_id(5))
160 .bold_italic_stack(FontStack::from_id(6)),
161 heading_family: FontFamily::regular_bold_stacks(
162 heading_stack.clone(),
163 heading_stack.clone(),
164 ),
165 mono_family: FontFamily::regular_bold_stacks(mono_stack.clone(), mono_bold_stack.clone()),
166 size_body: 16.0,
167 size_heading: 24.0,
168 size_mono: 15.0,
169 }
170}
171
172fn normalize_accent_color(color: u32) -> u32 {
173 if color == 0 {
174 return DEFAULT_ACCENT_COLOR;
175 }
176 let alpha = color_alpha(color);
177 if alpha == 0 {
178 return with_alpha(color, 0xff);
179 }
180 color
181}
182
183fn pick_accent_foreground(accent: u32) -> u32 {
184 let brightness = color_red(accent) as f32 * 0.2126
185 + color_green(accent) as f32 * 0.7152
186 + color_blue(accent) as f32 * 0.0722;
187 if brightness < 160.0 {
188 WHITE
189 } else {
190 BLACK
191 }
192}
193
194fn estimate_theme_dark(theme: &Theme) -> bool {
195 let background = theme.colors.background;
196 let luminance = color_red(background) as f32 * 0.2126
197 + color_green(background) as f32 * 0.7152
198 + color_blue(background) as f32 * 0.0722;
199 luminance < 128.0
200}
201
202pub fn generate_theme(is_dark: bool, accent_color: u32) -> Theme {
203 let fonts = default_fonts();
204 let accent = normalize_accent_color(accent_color);
205 let background = if is_dark {
206 rgba(0x04, 0x0a, 0x14, 0xff)
207 } else {
208 rgba(0xf8, 0xfa, 0xfc, 0xff)
209 };
210 let surface = if is_dark {
211 rgba(0x0f, 0x17, 0x28, 0xff)
212 } else {
213 WHITE
214 };
215 let text_primary = if is_dark {
216 rgba(0xf8, 0xfa, 0xfc, 0xff)
217 } else {
218 rgba(0x0f, 0x17, 0x2a, 0xff)
219 };
220 let text_muted = if is_dark {
221 rgba(0x94, 0xa3, 0xb8, 0xff)
222 } else {
223 rgba(0x47, 0x55, 0x69, 0xff)
224 };
225 let text_on_accent = pick_accent_foreground(accent);
226 let border = if is_dark {
227 rgba(0x24, 0x3b, 0x53, 0xff)
228 } else {
229 rgba(0xcb, 0xd5, 0xe1, 0xff)
230 };
231 let accent_hovered = if is_dark {
232 mix_color(accent, WHITE, 0.14)
233 } else {
234 mix_color(accent, WHITE, 0.10)
235 };
236 let accent_pressed = if is_dark {
237 mix_color(accent, BLACK, 0.24)
238 } else {
239 mix_color(accent, BLACK, 0.16)
240 };
241 let selection = with_alpha(accent, if is_dark { 0x40 } else { 0x33 });
242 let scrollbar_track = if is_dark {
243 rgba(0x12, 0x21, 0x33, 0xff)
244 } else {
245 rgba(0xe2, 0xe8, 0xf0, 0xff)
246 };
247 let scrollbar_thumb = if is_dark {
248 mix_color(accent, surface, 0.55)
249 } else {
250 mix_color(accent, surface, 0.40)
251 };
252 let dialog_backdrop = if is_dark {
253 rgba(0x00, 0x00, 0x00, 0x24)
254 } else {
255 rgba(0x00, 0x00, 0x00, 0x18)
256 };
257 let dialog_shadow = if is_dark {
258 rgba(0x00, 0x00, 0x00, 0xd8)
259 } else {
260 rgba(0x00, 0x00, 0x00, 0x88)
261 };
262 let panel_shadow = with_alpha(
263 dialog_shadow,
264 (color_alpha(dialog_shadow) as f32 * 0.30).round() as u32,
265 );
266 let context_menu_panel_background = if is_dark {
267 rgba(0x18, 0x1d, 0x26, 0xd8)
268 } else {
269 rgba(0xff, 0xff, 0xff, 0xdc)
270 };
271 let context_menu_panel_border_color = if is_dark {
272 rgba(0xff, 0xff, 0xff, 0x10)
273 } else {
274 rgba(0x0f, 0x17, 0x2a, 0x14)
275 };
276 let context_menu_item_hover = if is_dark {
277 rgba(0xff, 0xff, 0xff, 0x0c)
278 } else {
279 rgba(0x0f, 0x17, 0x2a, 0x08)
280 };
281 let context_menu_separator_color = if is_dark {
282 rgba(0xff, 0xff, 0xff, 0x10)
283 } else {
284 rgba(0x0f, 0x17, 0x2a, 0x12)
285 };
286 let tool_tip_panel_background = if is_dark {
287 rgba(0x11, 0x17, 0x20, 0xf0)
288 } else {
289 rgba(0xff, 0xff, 0xff, 0xf8)
290 };
291 let tool_tip_panel_border_color = if is_dark {
292 rgba(0xff, 0xff, 0xff, 0x12)
293 } else {
294 rgba(0x0f, 0x17, 0x2a, 0x12)
295 };
296
297 Theme {
298 colors: Colors {
299 background,
300 surface,
301 text_primary,
302 text_muted,
303 text_on_accent,
304 accent,
305 accent_pressed,
306 accent_hovered,
307 border,
308 selection,
309 scrollbar_track,
310 scrollbar_thumb,
311 dialog_backdrop,
312 dialog_shadow,
313 panel_shadow,
314 focus_ring: accent,
315 },
316 spacing: DEFAULT_SPACING,
317 fonts: fonts.clone(),
318 context_menu: ContextMenuTheme {
319 panel_background: context_menu_panel_background,
320 panel_border_color: context_menu_panel_border_color,
321 panel_shadow_color: panel_shadow,
322 panel_corner_radius: if is_dark { 16.0 } else { 14.0 },
323 separator_color: context_menu_separator_color,
324 shadow_offset_y: 12.0,
325 shadow_blur: 28.0,
326 shadow_spread: 0.0,
327 item: ContextMenuItemTheme {
328 background: rgba(0, 0, 0, 0),
329 hover_background: context_menu_item_hover,
330 text_color: text_primary,
331 corner_radius: if is_dark { 10.0 } else { 9.0 },
332 font_family: fonts.body_family.clone(),
333 font_size: 13.0,
334 height: 30.0,
335 padding_left: 12.0,
336 padding_top: 6.0,
337 padding_right: 12.0,
338 padding_bottom: 6.0,
339 },
340 },
341 tool_tip: ToolTipTheme {
342 panel_background: tool_tip_panel_background,
343 panel_border_color: tool_tip_panel_border_color,
344 panel_shadow_color: panel_shadow,
345 panel_corner_radius: if is_dark { 12.0 } else { 10.0 },
346 text_color: text_primary,
347 font_family: fonts.body_family.clone(),
348 font_size: 13.0,
349 max_width: 280.0,
350 padding_left: 10.0,
351 padding_top: 7.0,
352 padding_right: 10.0,
353 padding_bottom: 7.0,
354 shadow_offset_y: 10.0,
355 shadow_blur: 24.0,
356 shadow_spread: 0.0,
357 },
358 }
359}
360
361pub fn default_dark_theme() -> Theme {
362 generate_theme(true, DEFAULT_ACCENT_COLOR)
363}
364
365pub fn default_light_theme() -> Theme {
366 generate_theme(false, DEFAULT_ACCENT_COLOR)
367}
368
369pub fn current_theme() -> Theme {
370 THEME_STATE.with(|slot| slot.borrow().signal.get())
371}
372
373pub fn subscribe(handler: impl Fn(Theme) + 'static) -> SubscriptionGuard {
374 handler(current_theme());
375 let guard = THEME_STATE.with(|slot| {
376 let callback: Callback = Rc::new(move || handler(current_theme()));
377 slot.borrow_mut().signal.subscribe(callback)
378 });
379 guard
380}
381
382pub fn bind_theme(handler: impl Fn(Theme) + 'static) -> SubscriptionGuard {
383 subscribe(handler)
384}
385
386fn apply_theme(theme: Theme, source: ThemeSource, is_dark: bool) -> Theme {
387 let callbacks = THEME_STATE.with(|slot| {
388 let mut state = slot.borrow_mut();
389 state.theme_source = source;
390 state.current_dark_mode = is_dark;
391 state.signal.set(theme.clone())
392 });
393 if let Some(callbacks) = callbacks {
394 for callback in callbacks {
395 callback();
396 }
397 }
398 theme
399}
400
401fn apply_system_theme() -> Theme {
402 THEME_STATE
403 .with(|slot| {
404 let state = slot.borrow();
405 generate_theme(state.system_dark_mode, state.system_accent_color)
406 })
407 .pipe(|theme| {
408 let is_dark = estimate_theme_dark(&theme);
409 apply_theme(theme, ThemeSource::System, is_dark)
410 })
411}
412
413pub fn use_system_theme() -> Theme {
414 THEME_STATE.with(|slot| {
415 let mut state = slot.borrow_mut();
416 state.system_dark_mode = framework_host_services::fui_is_dark_mode();
417 state.system_accent_color =
418 normalize_accent_color(framework_host_services::fui_get_accent_color());
419 });
420 apply_system_theme()
421}
422
423pub fn use_custom_theme(theme: Theme) -> Theme {
424 apply_theme(
425 theme.clone(),
426 ThemeSource::Custom,
427 estimate_theme_dark(&theme),
428 )
429}
430
431pub fn set_accent_color(color: u32) -> Theme {
432 use_custom_theme(generate_theme(is_dark_mode(), color))
433}
434
435pub fn is_dark_mode() -> bool {
436 THEME_STATE.with(|slot| slot.borrow().current_dark_mode)
437}
438
439pub fn is_using_system_theme() -> bool {
440 THEME_STATE.with(|slot| slot.borrow().theme_source == ThemeSource::System)
441}
442
443pub fn handle_system_dark_mode_changed(is_dark: bool) -> Theme {
444 THEME_STATE.with(|slot| {
445 let mut state = slot.borrow_mut();
446 state.system_dark_mode = is_dark;
447 if state.theme_source != ThemeSource::System {
448 return state.signal.get();
449 }
450 state.system_accent_color =
451 normalize_accent_color(framework_host_services::fui_get_accent_color());
452 let theme = generate_theme(state.system_dark_mode, state.system_accent_color);
453 drop(state);
454 apply_theme(theme, ThemeSource::System, is_dark)
455 })
456}
457
458pub fn handle_system_accent_color_changed(color: u32) -> Theme {
459 THEME_STATE.with(|slot| {
460 let mut state = slot.borrow_mut();
461 state.system_accent_color = normalize_accent_color(color);
462 if state.theme_source != ThemeSource::System {
463 return state.signal.get();
464 }
465 let theme = generate_theme(state.system_dark_mode, state.system_accent_color);
466 let is_dark = estimate_theme_dark(&theme);
467 drop(state);
468 apply_theme(theme, ThemeSource::System, is_dark)
469 })
470}
471
472trait Pipe: Sized {
473 fn pipe<T>(self, callback: impl FnOnce(Self) -> T) -> T {
474 callback(self)
475 }
476}
477
478impl<T> Pipe for T {}
479
480#[cfg_attr(not(feature = "worker-runtime"), no_mangle)]
481pub extern "C" fn __fui_on_system_dark_mode_changed(is_dark: bool) {
482 handle_system_dark_mode_changed(is_dark);
483}
484
485#[cfg_attr(not(feature = "worker-runtime"), no_mangle)]
486pub extern "C" fn __fui_on_system_accent_color_changed(color: u32) {
487 handle_system_accent_color_changed(color);
488}
489
490#[cfg(test)]
491mod tests {
492 use super::{
493 current_theme, handle_system_accent_color_changed, handle_system_dark_mode_changed,
494 is_dark_mode, subscribe, use_system_theme,
495 };
496 use crate::ffi;
497 use std::cell::Cell;
498 use std::rc::Rc;
499
500 #[test]
501 fn uses_host_system_theme_values() {
502 ffi::test::reset();
503 ffi::test::set_system_dark_mode(false);
504 ffi::test::set_system_accent_color(0xFF0000FF);
505 let theme = use_system_theme();
506 assert!(!is_dark_mode());
507 assert_eq!(theme.colors.accent, 0xFF0000FF);
508 }
509
510 #[test]
511 fn system_callbacks_update_active_theme() {
512 ffi::test::reset();
513 ffi::test::set_system_accent_color(0x00FF00FF);
514 handle_system_dark_mode_changed(false);
515 let theme = handle_system_accent_color_changed(0x112233FF);
516 assert!(!is_dark_mode());
517 assert_eq!(theme.colors.accent, 0x112233FF);
518 assert_eq!(current_theme().colors.accent, 0x112233FF);
519 }
520
521 #[test]
522 fn subscribe_invokes_immediately() {
523 ffi::test::reset();
524 let count = Rc::new(Cell::new(0));
525 let counter = count.clone();
526 let _guard = subscribe(move |_theme| {
527 counter.set(counter.get() + 1);
528 });
529 assert_eq!(count.get(), 1);
530 }
531}