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