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