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