Skip to main content

repose_core/
locals.rs

1//! # Theming and locals
2//!
3//! Repose uses thread‑local “composition locals” for global UI parameters:
4//!
5//! - `Theme` - colors for surfaces, text, controls, etc.
6//! - `Density` - dp→px device scale factor (platform sets this).
7//! - `UiScale` - app-controlled UI scale multiplier (defaults to 1.0).
8//! - `TextScale` - user text scaling (defaults to 1.0).
9//! - `TextDirection` - LTR or RTL (defaults to LTR).
10//!
11//! Locals can be overridden for a subtree with `with_*`. If no local is set,
12//! getters fall back to global defaults (which an app can set each frame).
13
14use std::ops::Deref;
15
16use std::any::{Any, TypeId};
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::sync::OnceLock;
20
21use parking_lot::RwLock;
22
23use std::rc::Rc;
24
25use crate::Color;
26use crate::animation::{AnimationSpec, Easing};
27use crate::indication::IndicationNodeFactory;
28use web_time::Duration;
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
31pub enum TextDirection {
32    #[default]
33    Ltr,
34    Rtl,
35}
36
37thread_local! {
38    static LOCALS_STACK: RefCell<Vec<HashMap<TypeId, Box<dyn Any>>>> = RefCell::new(Vec::new());
39}
40
41#[derive(Clone, Copy, Debug, Default)]
42struct Defaults {
43    theme: Theme,
44    text_direction: TextDirection,
45    ui_scale: UiScale,
46    text_scale: TextScale,
47    density: Density,
48    window_insets: WindowInsets,
49    window_size_class: WindowSizeClass,
50}
51
52static DEFAULTS: OnceLock<RwLock<Defaults>> = OnceLock::new();
53
54fn defaults() -> &'static RwLock<Defaults> {
55    DEFAULTS.get_or_init(|| RwLock::new(Defaults::default()))
56}
57
58/// Set the global default theme used when no local Theme is active.
59pub fn set_theme_default(t: Theme) {
60    defaults().write().theme = t;
61}
62
63/// Set the global default text direction used when no local TextDirection is active.
64pub fn set_text_direction_default(d: TextDirection) {
65    defaults().write().text_direction = d;
66}
67
68/// Set the global default UI scale used when no local UiScale is active.
69pub fn set_ui_scale_default(s: UiScale) {
70    defaults().write().ui_scale = UiScale(s.0.max(0.0));
71}
72
73/// Set the global default text scale used when no local TextScale is active.
74pub fn set_text_scale_default(s: TextScale) {
75    defaults().write().text_scale = TextScale(s.0.max(0.0));
76}
77
78/// Set the global default device density (dp→px) used when no local Density is active.
79/// Platform runners should call this whenever the window scale factor changes.
80pub fn set_density_default(d: Density) {
81    defaults().write().density = Density {
82        scale: d.scale.max(0.0),
83    };
84}
85
86/// density‑independent pixels (dp)
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct Dp(pub f32);
89
90impl Dp {
91    /// Converts this dp value into physical pixels using current Density * UiScale.
92    pub fn to_px(self) -> f32 {
93        self.0 * density().scale * ui_scale().0
94    }
95}
96
97/// Convenience: convert a raw dp scalar into px using current Density * UiScale.
98pub fn dp_to_px(dp: f32) -> f32 {
99    Dp(dp).to_px()
100}
101
102/// Convenience: convert a raw px scalar into dp using current Density * UiScale.
103pub fn px_to_dp(px: f32) -> f32 {
104    let scale = density().scale * ui_scale().0;
105    if scale <= 0.0 { 0.0 } else { px / scale }
106}
107
108fn with_locals_frame<R>(f: impl FnOnce() -> R) -> R {
109    struct Guard;
110    impl Drop for Guard {
111        fn drop(&mut self) {
112            let _ = LOCALS_STACK.try_with(|st| {
113                st.borrow_mut().pop();
114            });
115        }
116    }
117    LOCALS_STACK.with(|st| st.borrow_mut().push(HashMap::new()));
118    let _guard = Guard;
119    f()
120}
121
122fn set_local_boxed(t: TypeId, v: Box<dyn Any>) {
123    LOCALS_STACK.with(|st| {
124        if let Some(top) = st.borrow_mut().last_mut() {
125            top.insert(t, v);
126        } else {
127            // no frame: create a temporary one
128            let mut m = HashMap::new();
129            m.insert(t, v);
130            st.borrow_mut().push(m);
131        }
132    });
133}
134
135fn get_local<T: 'static + Copy>() -> Option<T> {
136    LOCALS_STACK.with(|st| {
137        for frame in st.borrow().iter().rev() {
138            if let Some(v) = frame.get(&TypeId::of::<T>())
139                && let Some(t) = v.downcast_ref::<T>()
140            {
141                return Some(*t);
142            }
143        }
144        None
145    })
146}
147
148#[derive(Clone, Copy, Debug)]
149#[must_use]
150pub struct ColorScheme {
151    pub primary: Color,
152    pub on_primary: Color,
153    pub primary_container: Color,
154    pub on_primary_container: Color,
155
156    pub secondary: Color,
157    pub on_secondary: Color,
158    pub secondary_container: Color,
159    pub on_secondary_container: Color,
160
161    pub tertiary: Color,
162    pub on_tertiary: Color,
163    pub tertiary_container: Color,
164    pub on_tertiary_container: Color,
165
166    pub error: Color,
167    pub on_error: Color,
168    pub error_container: Color,
169    pub on_error_container: Color,
170
171    pub background: Color,
172    pub on_background: Color,
173    pub surface: Color,
174    pub on_surface: Color,
175    pub surface_variant: Color,
176    pub on_surface_variant: Color,
177    pub surface_container_lowest: Color,
178    pub surface_container_low: Color,
179    pub surface_container: Color,
180    pub surface_container_high: Color,
181    pub surface_container_highest: Color,
182    pub surface_bright: Color,
183    pub surface_dim: Color,
184    pub surface_tint: Color,
185
186    pub inverse_surface: Color,
187    pub inverse_on_surface: Color,
188    pub inverse_primary: Color,
189
190    pub outline: Color,
191    pub outline_variant: Color,
192
193    pub scrim: Color,
194    pub shadow: Color,
195    pub focus: Color,
196}
197
198impl ColorScheme {
199    pub fn dark() -> Self {
200        Self {
201            primary: Color::from_hex("#69FDBE"),
202            on_primary: Color::from_hex("#003020"),
203            primary_container: Color::from_hex("#004D40"),
204            on_primary_container: Color::from_hex("#6FF7F6"),
205
206            secondary: Color::from_hex("#B3C9A7"),
207            on_secondary: Color::from_hex("#1C3519"),
208            secondary_container: Color::from_hex("#334D2E"),
209            on_secondary_container: Color::from_hex("#CCE8B3"),
210
211            tertiary: Color::from_hex("#FFC9C1"),
212            on_tertiary: Color::from_hex("#3F1619"),
213            tertiary_container: Color::from_hex("#5D1F22"),
214            on_tertiary_container: Color::from_hex("#FFDBD8"),
215
216            error: Color::from_hex("#F2B8B5"),
217            on_error: Color::from_hex("#601410"),
218            error_container: Color::from_hex("#8C1D18"),
219            on_error_container: Color::from_hex("#F9DEDC"),
220
221            background: Color::from_hex("#1A1C1E"),
222            on_background: Color::from_hex("#E6E1E5"),
223            surface: Color::from_hex("#1A1C1E"),
224            on_surface: Color::from_hex("#E6E1E5"),
225            surface_variant: Color::from_hex("#44474E"),
226            on_surface_variant: Color::from_hex("#C4C6CE"),
227            surface_container_lowest: Color::from_hex("#0A0A0C"),
228            surface_container_low: Color::from_hex("#141115"),
229            surface_container: Color::from_hex("#19131A"),
230            surface_container_high: Color::from_hex("#1F1B22"),
231            surface_container_highest: Color::from_hex("#2A2930"),
232            surface_bright: Color::from_hex("#26292F"),
233            surface_dim: Color::from_hex("#1A1C1E"),
234            surface_tint: Color::from_hex("#69FDBE"),
235
236            inverse_surface: Color::from_hex("#E6E1E5"),
237            inverse_on_surface: Color::from_hex("#2A2930"),
238            inverse_primary: Color::from_hex("#005048"),
239
240            outline: Color::from_hex("#74777F"),
241            outline_variant: Color::from_hex("#44474E"),
242
243            scrim: Color::from_hex("#000000"),
244            shadow: Color::from_hex("#000000"),
245            focus: Color::from_hex("#006A6A"),
246        }
247    }
248
249    pub fn light() -> Self {
250        Self {
251            primary: Color::from_hex("#006A6A"),
252            on_primary: Color::WHITE,
253            primary_container: Color::from_hex("#9EF0EC"),
254            on_primary_container: Color::from_hex("#002020"),
255
256            secondary: Color::from_hex("#586146"),
257            on_secondary: Color::WHITE,
258            secondary_container: Color::from_hex("#D8E3B8"),
259            on_secondary_container: Color::from_hex("#161C0A"),
260
261            tertiary: Color::from_hex("#744639"),
262            on_tertiary: Color::WHITE,
263            tertiary_container: Color::from_hex("#FFD9CD"),
264            on_tertiary_container: Color::from_hex("#2C0E07"),
265
266            error: Color::from_hex("#BA1A1A"),
267            on_error: Color::WHITE,
268            error_container: Color::from_hex("#FFDAD6"),
269            on_error_container: Color::from_hex("#410002"),
270
271            background: Color::from_hex("#FEF7FF"),
272            on_background: Color::from_hex("#1A1C1E"),
273            surface: Color::from_hex("#FEF7FF"),
274            on_surface: Color::from_hex("#1A1C1E"),
275            surface_variant: Color::from_hex("#E1E3DE"),
276            on_surface_variant: Color::from_hex("#44474E"),
277            surface_container_lowest: Color::WHITE,
278            surface_container_low: Color::from_hex("#F4F5F0"),
279            surface_container: Color::from_hex("#EEF0E9"),
280            surface_container_high: Color::from_hex("#E9EAE4"),
281            surface_container_highest: Color::from_hex("#E3E5DF"),
282            surface_bright: Color::from_hex("#FEF7FF"),
283            surface_dim: Color::from_hex("#DEDAD0"),
284            surface_tint: Color::from_hex("#006A6A"),
285
286            inverse_surface: Color::from_hex("#2F3033"),
287            inverse_on_surface: Color::from_hex("#F1F0F4"),
288            inverse_primary: Color::from_hex("#69FDBE"),
289
290            outline: Color::from_hex("#74777F"),
291            outline_variant: Color::from_hex("#C4C6CE"),
292
293            scrim: Color::from_hex("#000000"),
294            shadow: Color::from_hex("#000000"),
295            focus: Color::from_hex("#1D4ED8"),
296        }
297    }
298}
299
300impl Default for ColorScheme {
301    fn default() -> Self {
302        Self::dark()
303    }
304}
305
306#[derive(Clone, Copy, Debug)]
307#[must_use]
308pub struct Typography {
309    pub display_large: f32,
310    pub display_medium: f32,
311    pub display_small: f32,
312    pub headline_large: f32,
313    pub headline_medium: f32,
314    pub headline_small: f32,
315    pub title_large: f32,
316    pub title_medium: f32,
317    pub title_small: f32,
318    pub body_large: f32,
319    pub body_medium: f32,
320    pub body_small: f32,
321    pub label_large: f32,
322    pub label_medium: f32,
323    pub label_small: f32,
324}
325
326impl Default for Typography {
327    fn default() -> Self {
328        Self {
329            display_large: 57.0,
330            display_medium: 45.0,
331            display_small: 36.0,
332            headline_large: 32.0,
333            headline_medium: 28.0,
334            headline_small: 24.0,
335            title_large: 22.0,
336            title_medium: 16.0,
337            title_small: 14.0,
338            body_large: 16.0,
339            body_medium: 14.0,
340            body_small: 12.0,
341            label_large: 14.0,
342            label_medium: 12.0,
343            label_small: 11.0,
344        }
345    }
346}
347
348#[derive(Clone, Copy, Debug)]
349#[must_use]
350pub struct Shapes {
351    pub extra_small: f32,
352    pub small: f32,
353    pub medium: f32,
354    pub large: f32,
355    pub extra_large: f32,
356}
357
358impl Default for Shapes {
359    fn default() -> Self {
360        Self {
361            extra_small: 4.0,
362            small: 8.0,
363            medium: 12.0,
364            large: 16.0,
365            extra_large: 28.0,
366        }
367    }
368}
369
370#[derive(Clone, Copy, Debug)]
371#[must_use]
372pub struct Spacing {
373    pub xs: f32,
374    pub sm: f32,
375    pub md: f32,
376    pub lg: f32,
377    pub xl: f32,
378    pub xxl: f32,
379}
380
381impl Default for Spacing {
382    fn default() -> Self {
383        Self {
384            xs: 4.0,
385            sm: 8.0,
386            md: 12.0,
387            lg: 16.0,
388            xl: 24.0,
389            xxl: 32.0,
390        }
391    }
392}
393
394#[derive(Clone, Copy, Debug)]
395#[must_use]
396pub struct Elevation {
397    pub level0: f32,
398    pub level1: f32,
399    pub level2: f32,
400    pub level3: f32,
401    pub level4: f32,
402    pub level5: f32,
403}
404
405impl Default for Elevation {
406    fn default() -> Self {
407        Self {
408            level0: 0.0,
409            level1: 1.0,
410            level2: 3.0,
411            level3: 6.0,
412            level4: 8.0,
413            level5: 12.0,
414        }
415    }
416}
417
418/// Centralized animation specs for M3 motion design.
419#[derive(Clone, Copy, Debug)]
420#[must_use]
421pub struct MotionScheme {
422    /// Shape / size / bounds transitions (e.g., indicator position, elevation).
423    /// M3 standard: 200 ms FastOutSlowIn.
424    pub shape: AnimationSpec,
425    /// Color state transitions (e.g., label, tab text, selection).
426    /// M3 standard: 150 ms FastOutSlowIn.
427    pub color: AnimationSpec,
428    /// Quick color changes (e.g., checkbox fill, switch track, radio ring).
429    /// M3 standard: 100 ms FastOutSlowIn.
430    pub color_fast: AnimationSpec,
431    /// Overlay / popup enter‑exit (menus, dialogs, tooltips).
432    /// M3 standard: 120 ms FastOutSlowIn.
433    pub overlay: AnimationSpec,
434    /// Spring‑based positional animation (sheets, drawers, swipe‑to‑dismiss).
435    /// M3 standard: gentle spring (ζ = 0.5, k = 200).
436    pub spring: AnimationSpec,
437    /// Expanding containers (search bar, docked search suggestions).
438    /// M3 standard: 250 ms FastOutSlowIn.
439    pub expand: AnimationSpec,
440    /// Large layout transitions (bottom sheet height, scaffold reflow).
441    /// M3 standard: 300 ms EaseOut.
442    pub layout: AnimationSpec,
443}
444
445impl Default for MotionScheme {
446    fn default() -> Self {
447        Self {
448            shape: AnimationSpec::tween(Duration::from_millis(200), Easing::FastOutSlowIn),
449            color: AnimationSpec::tween(Duration::from_millis(150), Easing::FastOutSlowIn),
450            color_fast: AnimationSpec::tween(Duration::from_millis(100), Easing::FastOutSlowIn),
451            overlay: AnimationSpec::tween(Duration::from_millis(120), Easing::FastOutSlowIn),
452            spring: AnimationSpec::spring_gentle(),
453            expand: AnimationSpec::tween(Duration::from_millis(250), Easing::FastOutSlowIn),
454            layout: AnimationSpec::tween(Duration::from_millis(300), Easing::EaseOut),
455        }
456    }
457}
458
459#[derive(Clone, Copy, Debug)]
460#[must_use]
461pub struct Theme {
462    pub colors: ColorScheme,
463    pub typography: Typography,
464    pub shapes: Shapes,
465    pub spacing: Spacing,
466    pub elevation: Elevation,
467    pub motion: MotionScheme,
468
469    pub focus: Color,
470    pub scrollbar_track: Color,
471    pub scrollbar_thumb: Color,
472    pub button_bg: Color,
473    pub button_bg_hover: Color,
474    pub button_bg_pressed: Color,
475}
476
477impl Deref for Theme {
478    type Target = ColorScheme;
479    fn deref(&self) -> &Self::Target {
480        &self.colors
481    }
482}
483
484impl Default for Theme {
485    fn default() -> Self {
486        let colors = ColorScheme::default();
487        Self {
488            colors,
489            typography: Typography::default(),
490            shapes: Shapes::default(),
491            spacing: Spacing::default(),
492            elevation: Elevation::default(),
493            motion: MotionScheme::default(),
494            focus: colors.focus,
495            scrollbar_track: Color::TRANSPARENT,
496            scrollbar_thumb: colors.outline.with_alpha(179),
497            button_bg: colors.primary,
498            button_bg_hover: colors.primary_container,
499            button_bg_pressed: colors.on_primary_container,
500        }
501    }
502}
503
504impl Theme {
505    pub fn with_colors(mut self, colors: ColorScheme) -> Self {
506        self.colors = colors;
507        self
508    }
509}
510
511/// Platform/device scale (dp→px multiplier). Platform runner should set this.
512#[derive(Clone, Copy, Debug)]
513pub struct Density {
514    pub scale: f32,
515}
516impl Default for Density {
517    fn default() -> Self {
518        Self { scale: 1.0 }
519    }
520}
521
522/// Additional UI scale multiplier (app-controlled).
523#[derive(Clone, Copy, Debug)]
524pub struct UiScale(pub f32);
525impl Default for UiScale {
526    fn default() -> Self {
527        Self(1.0)
528    }
529}
530
531#[derive(Clone, Copy, Debug)]
532pub struct TextScale(pub f32);
533impl Default for TextScale {
534    fn default() -> Self {
535        Self(1.0)
536    }
537}
538
539pub fn with_theme<R>(theme: Theme, f: impl FnOnce() -> R) -> R {
540    with_locals_frame(|| {
541        set_local_boxed(TypeId::of::<Theme>(), Box::new(theme));
542        f()
543    })
544}
545
546pub fn with_density<R>(density: Density, f: impl FnOnce() -> R) -> R {
547    with_locals_frame(|| {
548        set_local_boxed(TypeId::of::<Density>(), Box::new(density));
549        f()
550    })
551}
552
553pub fn with_ui_scale<R>(s: UiScale, f: impl FnOnce() -> R) -> R {
554    with_locals_frame(|| {
555        set_local_boxed(TypeId::of::<UiScale>(), Box::new(s));
556        f()
557    })
558}
559
560pub fn with_text_scale<R>(ts: TextScale, f: impl FnOnce() -> R) -> R {
561    with_locals_frame(|| {
562        set_local_boxed(TypeId::of::<TextScale>(), Box::new(ts));
563        f()
564    })
565}
566
567pub fn with_text_direction<R>(dir: TextDirection, f: impl FnOnce() -> R) -> R {
568    with_locals_frame(|| {
569        set_local_boxed(TypeId::of::<TextDirection>(), Box::new(dir));
570        f()
571    })
572}
573
574pub fn with_window_insets<R>(insets: WindowInsets, f: impl FnOnce() -> R) -> R {
575    with_locals_frame(|| {
576        set_local_boxed(TypeId::of::<WindowInsets>(), Box::new(insets));
577        f()
578    })
579}
580
581#[derive(Clone, Copy, Debug)]
582pub struct ContentColor(pub Color);
583
584pub fn with_content_color<R>(color: Color, f: impl FnOnce() -> R) -> R {
585    with_locals_frame(|| {
586        set_local_boxed(TypeId::of::<ContentColor>(), Box::new(ContentColor(color)));
587        f()
588    })
589}
590
591pub fn content_color() -> Color {
592    get_local::<ContentColor>()
593        .map(|c| c.0)
594        .unwrap_or_else(|| theme().on_surface)
595}
596
597/// Composition-local default indication (ripple/highlight) factory.
598/// Components like `Button` read this to get the default press feedback.
599/// Mirrors Compose's `LocalIndication`.
600#[derive(Clone, Debug)]
601pub struct LocalIndication(pub Option<Rc<dyn IndicationNodeFactory>>);
602
603impl Default for LocalIndication {
604    fn default() -> Self {
605        Self(None)
606    }
607}
608
609pub fn with_local_indication<R>(
610    indication: Option<Rc<dyn IndicationNodeFactory>>,
611    f: impl FnOnce() -> R,
612) -> R {
613    with_locals_frame(|| {
614        set_local_boxed(
615            std::any::TypeId::of::<LocalIndication>(),
616            Box::new(LocalIndication(indication)),
617        );
618        f()
619    })
620}
621
622pub fn local_indication() -> Option<Rc<dyn IndicationNodeFactory>> {
623    // Manual stack walk (get_local requires Copy, which LocalIndication is not).
624    let result = LOCALS_STACK.with(|st| {
625        for frame in st.borrow().iter().rev() {
626            if let Some(v) = frame.get(&TypeId::of::<LocalIndication>())
627                && let Some(li) = v.downcast_ref::<LocalIndication>()
628            {
629                return li.0.clone();
630            }
631        }
632        None::<Rc<dyn IndicationNodeFactory>>
633    });
634    result.or_else(|| {
635        // Fall back to a default debug indication
636        Some(Rc::new(crate::indication::DebugIndication::default()) as Rc<dyn IndicationNodeFactory>)
637    })
638}
639
640/// System window insets (status bar, navigation bar, IME keyboard, etc.)
641#[derive(Clone, Copy, Debug, Default, PartialEq)]
642pub struct WindowInsets {
643    pub top: f32,
644    pub bottom: f32,
645    pub left: f32,
646    pub right: f32,
647    /// Soft keyboard (IME) inset from bottom of screen. Set by platform runner
648    /// when the keyboard opens/closes. Used by `imePadding()` modifier.
649    pub ime_bottom: f32,
650}
651
652/// Set the global default window insets (platform should call this when insets change).
653pub fn set_window_insets_default(insets: WindowInsets) {
654    defaults().write().window_insets = insets;
655    set_local_boxed(TypeId::of::<WindowInsets>(), Box::new(insets));
656}
657
658/// Update just the IME bottom inset (keyboard height in px). Platform runners
659/// call this when the soft keyboard opens/closes.
660pub fn set_ime_inset(height_px: f32) {
661    let mut insets = defaults().write().window_insets;
662    insets.ime_bottom = height_px;
663    // Also immediately set the thread-local so it's visible to the current frame
664    set_local_boxed(TypeId::of::<WindowInsets>(), Box::new(insets));
665}
666
667/// Query current window insets.
668pub fn window_insets() -> WindowInsets {
669    get_local::<WindowInsets>().unwrap_or_else(|| defaults().read().window_insets)
670}
671
672/// Coarse width category for a window, computed from its current size.
673///
674/// Thresholds (in dp) match the Material 3 adaptive spec:
675///
676/// - [`WidthClass::Compact`]  : width < 600 dp
677/// - [`WidthClass::Medium`]   : 600 dp <= width < 840 dp
678/// - [`WidthClass::Expanded`] : width >= 840 dp
679#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
680pub enum WidthClass {
681    #[default]
682    Compact,
683    Medium,
684    Expanded,
685}
686
687/// Coarse height category for a window, computed from its current size.
688///
689/// Thresholds (in dp) match the Material 3 adaptive spec:
690///
691/// - [`HeightClass::Compact`]  : height < 480 dp
692/// - [`HeightClass::Medium`]   : 480 dp <= height < 900 dp
693/// - [`HeightClass::Expanded`] : height >= 900 dp
694#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
695pub enum HeightClass {
696    #[default]
697    Compact,
698    Medium,
699    Expanded,
700}
701
702/// Snapshot of the current window's size category.
703///
704/// The `LayoutEngine` updates the `WindowSizeClass` default local every time
705/// the window is resized, so UI can read it via [`window_size_class()`] during
706/// composition.
707#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
708pub struct WindowSizeClass {
709    pub width: WidthClass,
710    pub height: HeightClass,
711}
712
713impl WindowSizeClass {
714    /// `true` when there is enough horizontal space for multi-pane layouts.
715    pub fn is_expanded_width(&self) -> bool {
716        matches!(self.width, WidthClass::Expanded)
717    }
718    /// `true` when there is enough horizontal space for a two-pane layout
719    /// (list + detail). Per M3 this is Medium or wider.
720    pub fn is_at_least_medium_width(&self) -> bool {
721        matches!(self.width, WidthClass::Medium | WidthClass::Expanded)
722    }
723}
724
725/// Compute a [`WindowSizeClass`] from a window size in physical pixels and
726/// the current dp→px density scale (`Density.scale * UiScale.0`).
727pub fn calculate_window_size_class(
728    width_px: u32,
729    height_px: u32,
730    density_scale: f32,
731) -> WindowSizeClass {
732    let density = density_scale.max(0.0001);
733    let width_dp = (width_px as f32) / density;
734    let height_dp = (height_px as f32) / density;
735
736    let width = if width_dp < 600.0 {
737        WidthClass::Compact
738    } else if width_dp < 840.0 {
739        WidthClass::Medium
740    } else {
741        WidthClass::Expanded
742    };
743    let height = if height_dp < 480.0 {
744        HeightClass::Compact
745    } else if height_dp < 900.0 {
746        HeightClass::Medium
747    } else {
748        HeightClass::Expanded
749    };
750
751    WindowSizeClass { width, height }
752}
753
754/// Set the global default window size class used when no local is active.
755/// Called by the `LayoutEngine` on resize.
756pub fn set_window_size_class_default(class: WindowSizeClass) {
757    defaults().write().window_size_class = class;
758}
759
760/// Override the window size class for a subtree of the composition.
761pub fn with_window_size_class<R>(class: WindowSizeClass, f: impl FnOnce() -> R) -> R {
762    with_locals_frame(|| {
763        set_local_boxed(TypeId::of::<WindowSizeClass>(), Box::new(class));
764        f()
765    })
766}
767
768/// Query current window size class. Returns a default-initialized
769/// `WindowSizeClass` (Compact/Compact) if nothing has been set yet.
770pub fn window_size_class() -> WindowSizeClass {
771    get_local::<WindowSizeClass>().unwrap_or_else(|| defaults().read().window_size_class)
772}
773
774macro_rules! def_local_getter {
775    ($fn_name:ident, $ty:ty, $default_field:ident) => {
776        pub fn $fn_name() -> $ty {
777            get_local::<$ty>().unwrap_or_else(|| defaults().read().$default_field)
778        }
779    };
780}
781
782def_local_getter!(theme, Theme, theme);
783def_local_getter!(density, Density, density);
784def_local_getter!(ui_scale, UiScale, ui_scale);
785def_local_getter!(text_scale, TextScale, text_scale);
786def_local_getter!(text_direction, TextDirection, text_direction);
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn width_class_thresholds_match_m3() {
794        // Density 1.0 (1 dp = 1 px) for clarity.
795        assert_eq!(
796            calculate_window_size_class(100, 100, 1.0).width,
797            WidthClass::Compact
798        );
799        assert_eq!(
800            calculate_window_size_class(599, 100, 1.0).width,
801            WidthClass::Compact
802        );
803        assert_eq!(
804            calculate_window_size_class(600, 100, 1.0).width,
805            WidthClass::Medium
806        );
807        assert_eq!(
808            calculate_window_size_class(839, 100, 1.0).width,
809            WidthClass::Medium
810        );
811        assert_eq!(
812            calculate_window_size_class(840, 100, 1.0).width,
813            WidthClass::Expanded
814        );
815        assert_eq!(
816            calculate_window_size_class(2000, 100, 1.0).width,
817            WidthClass::Expanded
818        );
819    }
820
821    #[test]
822    fn height_class_thresholds_match_m3() {
823        assert_eq!(
824            calculate_window_size_class(100, 100, 1.0).height,
825            HeightClass::Compact
826        );
827        assert_eq!(
828            calculate_window_size_class(100, 479, 1.0).height,
829            HeightClass::Compact
830        );
831        assert_eq!(
832            calculate_window_size_class(100, 480, 1.0).height,
833            HeightClass::Medium
834        );
835        assert_eq!(
836            calculate_window_size_class(100, 899, 1.0).height,
837            HeightClass::Medium
838        );
839        assert_eq!(
840            calculate_window_size_class(100, 900, 1.0).height,
841            HeightClass::Expanded
842        );
843    }
844
845    #[test]
846    fn density_scales_thresholds() {
847        // 2.0x density: 600 dp = 1200 px.
848        let c = calculate_window_size_class(1199, 100, 2.0);
849        assert_eq!(c.width, WidthClass::Compact);
850        let c = calculate_window_size_class(1200, 100, 2.0);
851        assert_eq!(c.width, WidthClass::Medium);
852    }
853
854    #[test]
855    fn is_at_least_medium_width() {
856        let c = WindowSizeClass {
857            width: WidthClass::Compact,
858            height: HeightClass::Compact,
859        };
860        assert!(!c.is_at_least_medium_width());
861        let c = WindowSizeClass {
862            width: WidthClass::Medium,
863            height: HeightClass::Compact,
864        };
865        assert!(c.is_at_least_medium_width());
866        let c = WindowSizeClass {
867            width: WidthClass::Expanded,
868            height: HeightClass::Compact,
869        };
870        assert!(c.is_at_least_medium_width());
871    }
872}