Skip to main content

fui/
theme.rs

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}