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::hash::{Hash, Hasher};
20use std::sync::OnceLock;
21
22use parking_lot::RwLock;
23
24use std::rc::Rc;
25
26use crate::Color;
27use crate::animation::{AnimationSpec, Easing};
28use crate::indication::IndicationNodeFactory;
29use web_time::Duration;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
32pub enum TextDirection {
33    #[default]
34    Ltr,
35    Rtl,
36}
37
38thread_local! {
39    static LOCALS_STACK: RefCell<Vec<HashMap<TypeId, Box<dyn Any>>>> = RefCell::new(Vec::new());
40}
41
42#[derive(Clone, Copy)]
43pub(crate) enum LocalId {
44    Theme,
45    Density,
46    UiScale,
47    TextScale,
48    TextDirection,
49    WindowInsets,
50    WindowSizeClass,
51    ContainerWidth,
52    ContainerHeight,
53    ContentColor,
54    TextSize,
55    Indication,
56    InputMode,
57}
58
59impl LocalId {
60    fn index(self) -> usize {
61        self as usize
62    }
63}
64
65#[derive(Clone, Copy, Debug, Default)]
66struct Defaults {
67    theme: Theme,
68    text_direction: TextDirection,
69    ui_scale: UiScale,
70    text_scale: TextScale,
71    density: Density,
72    window_insets: WindowInsets,
73    window_size_class: WindowSizeClass,
74    /// Logical window container size in dp (defaults match a phone portrait).
75    container_width: f32,
76    container_height: f32,
77}
78
79static DEFAULTS: OnceLock<RwLock<Defaults>> = OnceLock::new();
80
81fn defaults() -> &'static RwLock<Defaults> {
82    DEFAULTS.get_or_init(|| {
83        RwLock::new(Defaults {
84            container_width: 360.0,
85            container_height: 800.0,
86            ..Default::default()
87        })
88    })
89}
90
91/// Set the global default theme used when no local Theme is active.
92pub fn set_theme_default(t: Theme) {
93    let changed = {
94        let mut defaults = defaults().write();
95        let changed = fingerprint_theme(&defaults.theme) != fingerprint_theme(&t);
96        defaults.theme = t;
97        changed
98    };
99    if changed {
100        crate::request_frame();
101    }
102}
103
104/// Set the global default text direction used when no local TextDirection is active.
105pub fn set_text_direction_default(d: TextDirection) {
106    let changed = {
107        let mut defaults = defaults().write();
108        let changed = defaults.text_direction != d;
109        defaults.text_direction = d;
110        changed
111    };
112    if changed {
113        crate::request_frame();
114    }
115}
116
117/// Set the global default UI scale used when no local UiScale is active.
118pub fn set_ui_scale_default(s: UiScale) {
119    let value = UiScale(s.0.max(0.0));
120    let changed = {
121        let mut defaults = defaults().write();
122        let changed = defaults.ui_scale.0.to_bits() != value.0.to_bits();
123        defaults.ui_scale = value;
124        changed
125    };
126    if changed {
127        crate::request_frame();
128    }
129}
130
131/// Set the global default text scale used when no local TextScale is active.
132pub fn set_text_scale_default(s: TextScale) {
133    let value = TextScale(s.0.max(0.0));
134    let changed = {
135        let mut defaults = defaults().write();
136        let changed = defaults.text_scale.0.to_bits() != value.0.to_bits();
137        defaults.text_scale = value;
138        changed
139    };
140    if changed {
141        crate::request_frame();
142    }
143}
144
145/// Set the global default device density (dp->px) used when no local Density is active.
146/// Platform runners should call this whenever the window scale factor changes.
147pub fn set_density_default(d: Density) {
148    let value = Density {
149        scale: d.scale.max(0.0),
150    };
151    let changed = {
152        let mut defaults = defaults().write();
153        let changed = defaults.density.scale.to_bits() != value.scale.to_bits();
154        defaults.density = value;
155        changed
156    };
157    if changed {
158        crate::request_frame();
159    }
160}
161
162pub use crate::units::{Dp, DpOffset, DpRect, DpSize, Px, Sp, UnitExt};
163
164/// Effective dp→px scale: device density × app UI scale.
165#[inline]
166pub fn effective_density_scale() -> f32 {
167    (density().scale * ui_scale().0).max(0.0001)
168}
169
170/// Convert [`Dp`] into [`Px`] using current Density * UiScale.
171#[inline]
172pub fn dp_to_px(dp: Dp) -> Px {
173    dp.to_px()
174}
175
176/// Convert [`Px`] into [`Dp`] using current Density * UiScale.
177#[inline]
178pub fn px_to_dp(px: Px) -> Dp {
179    px.to_dp()
180}
181
182fn with_locals_frame<R>(f: impl FnOnce() -> R) -> R {
183    struct Guard;
184    impl Drop for Guard {
185        fn drop(&mut self) {
186            let frame = LOCALS_STACK
187                .try_with(|stack| {
188                    stack
189                        .try_borrow_mut()
190                        .ok()
191                        .and_then(|mut stack| stack.pop())
192                })
193                .ok()
194                .flatten();
195            drop(frame);
196        }
197    }
198    LOCALS_STACK.with(|stack| stack.borrow_mut().push(HashMap::new()));
199    let _guard = Guard;
200    f()
201}
202
203fn set_local_boxed(t: TypeId, value: Box<dyn Any>) {
204    let old = LOCALS_STACK
205        .try_with(|stack| {
206            let mut stack = stack.try_borrow_mut().ok()?;
207            let old = stack.last_mut()?.insert(t, value);
208            Some(old)
209        })
210        .ok()
211        .flatten();
212    drop(old);
213}
214
215fn get_local<T: 'static + Copy>() -> Option<T> {
216    LOCALS_STACK.with(|stack| {
217        for frame in stack.borrow().iter().rev() {
218            if let Some(value) = frame.get(&TypeId::of::<T>())
219                && let Some(value) = value.downcast_ref::<T>()
220            {
221                return Some(*value);
222            }
223        }
224        None
225    })
226}
227
228fn record_local<T: 'static>(id: LocalId, value: &T, fingerprint: impl FnOnce(&T) -> u64) {
229    crate::scope_cache::record_scope_local_read(id.index(), fingerprint(value));
230}
231
232fn fingerprint_color(value: &Color) -> u64 {
233    let mut hasher = std::collections::hash_map::DefaultHasher::new();
234    value.0.hash(&mut hasher);
235    value.1.hash(&mut hasher);
236    value.2.hash(&mut hasher);
237    value.3.hash(&mut hasher);
238    hasher.finish()
239}
240
241fn fingerprint_theme(value: &Theme) -> u64 {
242    let mut hasher = std::collections::hash_map::DefaultHasher::new();
243    format!("{value:?}").hash(&mut hasher);
244    hasher.finish()
245}
246
247fn fingerprint_text_size(value: Option<Sp>) -> u64 {
248    value.map(|size| size.0.to_bits() as u64).unwrap_or(0)
249}
250
251fn fingerprint_input_mode(value: crate::input::InputMode) -> u64 {
252    let mut hasher = std::collections::hash_map::DefaultHasher::new();
253    std::mem::discriminant(&value).hash(&mut hasher);
254    hasher.finish()
255}
256
257fn fingerprint_f32(value: f32) -> u64 {
258    value.to_bits() as u64
259}
260
261fn fingerprint_window_insets(value: &WindowInsets) -> u64 {
262    let mut hasher = std::collections::hash_map::DefaultHasher::new();
263    value.top.to_bits().hash(&mut hasher);
264    value.bottom.to_bits().hash(&mut hasher);
265    value.left.to_bits().hash(&mut hasher);
266    value.right.to_bits().hash(&mut hasher);
267    value.ime_bottom.to_bits().hash(&mut hasher);
268    hasher.finish()
269}
270
271fn fingerprint_window_size_class(value: &WindowSizeClass) -> u64 {
272    let mut hasher = std::collections::hash_map::DefaultHasher::new();
273    value.width.hash(&mut hasher);
274    value.height.hash(&mut hasher);
275    hasher.finish()
276}
277
278fn fingerprint_text_direction(value: &TextDirection) -> u64 {
279    let mut hasher = std::collections::hash_map::DefaultHasher::new();
280    std::mem::discriminant(value).hash(&mut hasher);
281    hasher.finish()
282}
283
284fn fingerprint_indication(value: &Option<Rc<dyn IndicationNodeFactory>>) -> u64 {
285    value
286        .as_ref()
287        .map(|value| Rc::as_ptr(value) as *const () as usize as u64)
288        .unwrap_or(0)
289}
290
291#[derive(Clone, Copy, Debug)]
292#[must_use]
293pub struct ColorScheme {
294    pub primary: Color,
295    pub on_primary: Color,
296    pub primary_container: Color,
297    pub on_primary_container: Color,
298
299    pub secondary: Color,
300    pub on_secondary: Color,
301    pub secondary_container: Color,
302    pub on_secondary_container: Color,
303
304    pub tertiary: Color,
305    pub on_tertiary: Color,
306    pub tertiary_container: Color,
307    pub on_tertiary_container: Color,
308
309    pub error: Color,
310    pub on_error: Color,
311    pub error_container: Color,
312    pub on_error_container: Color,
313
314    pub background: Color,
315    pub on_background: Color,
316    pub surface: Color,
317    pub on_surface: Color,
318    pub surface_variant: Color,
319    pub on_surface_variant: Color,
320    pub surface_container_lowest: Color,
321    pub surface_container_low: Color,
322    pub surface_container: Color,
323    pub surface_container_high: Color,
324    pub surface_container_highest: Color,
325    pub surface_bright: Color,
326    pub surface_dim: Color,
327    pub surface_tint: Color,
328
329    pub inverse_surface: Color,
330    pub inverse_on_surface: Color,
331    pub inverse_primary: Color,
332
333    pub outline: Color,
334    pub outline_variant: Color,
335
336    pub scrim: Color,
337    pub shadow: Color,
338    pub focus: Color,
339}
340
341impl ColorScheme {
342    pub fn dark() -> Self {
343        Self {
344            primary: Color::from_hex("#69FDBE"),
345            on_primary: Color::from_hex("#003020"),
346            primary_container: Color::from_hex("#004D40"),
347            on_primary_container: Color::from_hex("#6FF7F6"),
348
349            secondary: Color::from_hex("#B3C9A7"),
350            on_secondary: Color::from_hex("#1C3519"),
351            secondary_container: Color::from_hex("#334D2E"),
352            on_secondary_container: Color::from_hex("#CCE8B3"),
353
354            tertiary: Color::from_hex("#FFC9C1"),
355            on_tertiary: Color::from_hex("#3F1619"),
356            tertiary_container: Color::from_hex("#5D1F22"),
357            on_tertiary_container: Color::from_hex("#FFDBD8"),
358
359            error: Color::from_hex("#F2B8B5"),
360            on_error: Color::from_hex("#601410"),
361            error_container: Color::from_hex("#8C1D18"),
362            on_error_container: Color::from_hex("#F9DEDC"),
363
364            background: Color::from_hex("#1A1C1E"),
365            on_background: Color::from_hex("#E6E1E5"),
366            surface: Color::from_hex("#1A1C1E"),
367            on_surface: Color::from_hex("#E6E1E5"),
368            surface_variant: Color::from_hex("#44474E"),
369            on_surface_variant: Color::from_hex("#C4C6CE"),
370            surface_container_lowest: Color::from_hex("#0A0A0C"),
371            surface_container_low: Color::from_hex("#141115"),
372            surface_container: Color::from_hex("#19131A"),
373            surface_container_high: Color::from_hex("#1F1B22"),
374            surface_container_highest: Color::from_hex("#2A2930"),
375            surface_bright: Color::from_hex("#26292F"),
376            surface_dim: Color::from_hex("#1A1C1E"),
377            surface_tint: Color::from_hex("#69FDBE"),
378
379            inverse_surface: Color::from_hex("#E6E1E5"),
380            inverse_on_surface: Color::from_hex("#2A2930"),
381            inverse_primary: Color::from_hex("#005048"),
382
383            outline: Color::from_hex("#74777F"),
384            outline_variant: Color::from_hex("#44474E"),
385
386            scrim: Color::from_hex("#000000"),
387            shadow: Color::from_hex("#000000"),
388            focus: Color::from_hex("#006A6A"),
389        }
390    }
391
392    pub fn light() -> Self {
393        Self {
394            primary: Color::from_hex("#006A6A"),
395            on_primary: Color::WHITE,
396            primary_container: Color::from_hex("#9EF0EC"),
397            on_primary_container: Color::from_hex("#002020"),
398
399            secondary: Color::from_hex("#586146"),
400            on_secondary: Color::WHITE,
401            secondary_container: Color::from_hex("#D8E3B8"),
402            on_secondary_container: Color::from_hex("#161C0A"),
403
404            tertiary: Color::from_hex("#744639"),
405            on_tertiary: Color::WHITE,
406            tertiary_container: Color::from_hex("#FFD9CD"),
407            on_tertiary_container: Color::from_hex("#2C0E07"),
408
409            error: Color::from_hex("#BA1A1A"),
410            on_error: Color::WHITE,
411            error_container: Color::from_hex("#FFDAD6"),
412            on_error_container: Color::from_hex("#410002"),
413
414            background: Color::from_hex("#FEF7FF"),
415            on_background: Color::from_hex("#1A1C1E"),
416            surface: Color::from_hex("#FEF7FF"),
417            on_surface: Color::from_hex("#1A1C1E"),
418            surface_variant: Color::from_hex("#E1E3DE"),
419            on_surface_variant: Color::from_hex("#44474E"),
420            surface_container_lowest: Color::WHITE,
421            surface_container_low: Color::from_hex("#F4F5F0"),
422            surface_container: Color::from_hex("#EEF0E9"),
423            surface_container_high: Color::from_hex("#E9EAE4"),
424            surface_container_highest: Color::from_hex("#E3E5DF"),
425            surface_bright: Color::from_hex("#FEF7FF"),
426            surface_dim: Color::from_hex("#DEDAD0"),
427            surface_tint: Color::from_hex("#006A6A"),
428
429            inverse_surface: Color::from_hex("#2F3033"),
430            inverse_on_surface: Color::from_hex("#F1F0F4"),
431            inverse_primary: Color::from_hex("#69FDBE"),
432
433            outline: Color::from_hex("#74777F"),
434            outline_variant: Color::from_hex("#C4C6CE"),
435
436            scrim: Color::from_hex("#000000"),
437            shadow: Color::from_hex("#000000"),
438            focus: Color::from_hex("#1D4ED8"),
439        }
440    }
441
442    /// Compose `ColorScheme.contentColorFor(backgroundColor)`.
443    /// Maps a scheme container/background role to its paired on-* content color.
444    pub fn content_color_for(self, background: Color) -> Option<Color> {
445        if background == self.primary {
446            Some(self.on_primary)
447        } else if background == self.primary_container {
448            Some(self.on_primary_container)
449        } else if background == self.secondary {
450            Some(self.on_secondary)
451        } else if background == self.secondary_container {
452            Some(self.on_secondary_container)
453        } else if background == self.tertiary {
454            Some(self.on_tertiary)
455        } else if background == self.tertiary_container {
456            Some(self.on_tertiary_container)
457        } else if background == self.background {
458            Some(self.on_background)
459        } else if background == self.surface
460            || background == self.surface_bright
461            || background == self.surface_dim
462            || background == self.surface_container
463            || background == self.surface_container_low
464            || background == self.surface_container_high
465            || background == self.surface_container_highest
466        {
467            Some(self.on_surface)
468        } else if background == self.inverse_surface {
469            Some(self.inverse_on_surface)
470        } else if background == self.error {
471            Some(self.on_error)
472        } else if background == self.error_container {
473            Some(self.on_error_container)
474        } else {
475            None
476        }
477    }
478}
479
480impl Default for ColorScheme {
481    fn default() -> Self {
482        Self::dark()
483    }
484}
485
486/// Material type scale. All sizes are [`Sp`] (Compose `Typography` uses `TextUnit`).
487#[derive(Clone, Copy, Debug)]
488#[must_use]
489pub struct Typography {
490    pub display_large: Sp,
491    pub display_medium: Sp,
492    pub display_small: Sp,
493    pub headline_large: Sp,
494    pub headline_medium: Sp,
495    pub headline_small: Sp,
496    pub title_large: Sp,
497    pub title_medium: Sp,
498    pub title_small: Sp,
499    pub body_large: Sp,
500    pub body_medium: Sp,
501    pub body_small: Sp,
502    pub label_large: Sp,
503    pub label_medium: Sp,
504    pub label_small: Sp,
505}
506
507impl Default for Typography {
508    fn default() -> Self {
509        Self {
510            display_large: Sp(57.0),
511            display_medium: Sp(45.0),
512            display_small: Sp(36.0),
513            headline_large: Sp(32.0),
514            headline_medium: Sp(28.0),
515            headline_small: Sp(24.0),
516            title_large: Sp(22.0),
517            title_medium: Sp(16.0),
518            title_small: Sp(14.0),
519            body_large: Sp(16.0),
520            body_medium: Sp(14.0),
521            body_small: Sp(12.0),
522            label_large: Sp(14.0),
523            label_medium: Sp(12.0),
524            label_small: Sp(11.0),
525        }
526    }
527}
528
529/// Corner radii in [`Dp`].
530#[derive(Clone, Copy, Debug)]
531#[must_use]
532pub struct Shapes {
533    pub extra_small: Dp,
534    pub small: Dp,
535    pub medium: Dp,
536    pub large: Dp,
537    pub extra_large: Dp,
538}
539
540impl Default for Shapes {
541    fn default() -> Self {
542        Self {
543            extra_small: Dp(4.0),
544            small: Dp(8.0),
545            medium: Dp(12.0),
546            large: Dp(16.0),
547            extra_large: Dp(28.0),
548        }
549    }
550}
551
552/// Layout gaps in [`Dp`].
553#[derive(Clone, Copy, Debug)]
554#[must_use]
555pub struct Spacing {
556    pub xs: Dp,
557    pub sm: Dp,
558    pub md: Dp,
559    pub lg: Dp,
560    pub xl: Dp,
561    pub xxl: Dp,
562}
563
564impl Default for Spacing {
565    fn default() -> Self {
566        Self {
567            xs: Dp(4.0),
568            sm: Dp(8.0),
569            md: Dp(12.0),
570            lg: Dp(16.0),
571            xl: Dp(24.0),
572            xxl: Dp(32.0),
573        }
574    }
575}
576
577/// Elevation levels in [`Dp`].
578#[derive(Clone, Copy, Debug)]
579#[must_use]
580pub struct Elevation {
581    pub level0: Dp,
582    pub level1: Dp,
583    pub level2: Dp,
584    pub level3: Dp,
585    pub level4: Dp,
586    pub level5: Dp,
587}
588
589impl Default for Elevation {
590    fn default() -> Self {
591        Self {
592            level0: Dp(0.0),
593            level1: Dp(1.0),
594            level2: Dp(3.0),
595            level3: Dp(6.0),
596            level4: Dp(8.0),
597            level5: Dp(12.0),
598        }
599    }
600}
601
602/// Centralized animation specs for M3 motion design.
603#[derive(Clone, Copy, Debug)]
604#[must_use]
605pub struct MotionScheme {
606    /// Shape / size / bounds transitions (e.g., indicator position, elevation).
607    /// M3 standard: 200 ms FastOutSlowIn.
608    pub shape: AnimationSpec,
609    /// Color state transitions (e.g., label, tab text, selection).
610    /// M3 standard: 150 ms FastOutSlowIn.
611    pub color: AnimationSpec,
612    /// Quick color changes (e.g., checkbox fill, switch track, radio ring).
613    /// M3 standard: 100 ms FastOutSlowIn.
614    pub color_fast: AnimationSpec,
615    /// Overlay / popup enter‑exit (menus, dialogs, tooltips).
616    /// M3 standard: 120 ms FastOutSlowIn.
617    pub overlay: AnimationSpec,
618    /// Spring‑based positional animation (sheets, drawers, swipe‑to‑dismiss).
619    /// M3 standard: gentle spring (ζ = 0.5, k = 200).
620    pub spring: AnimationSpec,
621    /// Expanding containers (search bar, docked search suggestions).
622    /// M3 standard: 250 ms FastOutSlowIn.
623    pub expand: AnimationSpec,
624    /// Large layout transitions (bottom sheet height, scaffold reflow).
625    /// M3 standard: 300 ms EaseOut.
626    pub layout: AnimationSpec,
627}
628
629impl Default for MotionScheme {
630    fn default() -> Self {
631        Self {
632            shape: AnimationSpec::tween(Duration::from_millis(200), Easing::FastOutSlowIn),
633            color: AnimationSpec::tween(Duration::from_millis(150), Easing::FastOutSlowIn),
634            color_fast: AnimationSpec::tween(Duration::from_millis(100), Easing::FastOutSlowIn),
635            overlay: AnimationSpec::tween(Duration::from_millis(120), Easing::FastOutSlowIn),
636            spring: AnimationSpec::spring_gentle(),
637            expand: AnimationSpec::tween(Duration::from_millis(250), Easing::FastOutSlowIn),
638            layout: AnimationSpec::tween(Duration::from_millis(300), Easing::EaseOut),
639        }
640    }
641}
642
643#[derive(Clone, Copy, Debug)]
644#[must_use]
645pub struct Theme {
646    pub colors: ColorScheme,
647    pub typography: Typography,
648    pub shapes: Shapes,
649    pub spacing: Spacing,
650    pub elevation: Elevation,
651    pub motion: MotionScheme,
652
653    pub focus: Color,
654    pub scrollbar_track: Color,
655    pub scrollbar_thumb: Color,
656    pub button_bg: Color,
657    pub button_bg_hover: Color,
658    pub button_bg_pressed: Color,
659}
660
661impl Deref for Theme {
662    type Target = ColorScheme;
663    fn deref(&self) -> &Self::Target {
664        &self.colors
665    }
666}
667
668impl Default for Theme {
669    fn default() -> Self {
670        let colors = ColorScheme::default();
671        Self {
672            colors,
673            typography: Typography::default(),
674            shapes: Shapes::default(),
675            spacing: Spacing::default(),
676            elevation: Elevation::default(),
677            motion: MotionScheme::default(),
678            focus: colors.focus,
679            scrollbar_track: Color::TRANSPARENT,
680            scrollbar_thumb: colors.outline.with_alpha(179),
681            button_bg: colors.primary,
682            button_bg_hover: colors.primary_container,
683            button_bg_pressed: colors.secondary_container,
684        }
685    }
686}
687
688impl Theme {
689    pub fn with_colors(mut self, colors: ColorScheme) -> Self {
690        self.colors = colors;
691        self
692    }
693
694    /// A dark theme around the default dark color scheme.
695    pub fn dark() -> Self {
696        Self::default().with_colors(ColorScheme::dark())
697    }
698
699    /// A light theme: default colors swapped for the light scheme plus light
700    /// chrome/button derivatives.
701    pub fn light() -> Self {
702        let colors = ColorScheme::light();
703        Self {
704            focus: colors.focus,
705            scrollbar_thumb: colors.outline.with_alpha(179),
706            button_bg: colors.primary,
707            button_bg_hover: colors.primary_container,
708            button_bg_pressed: colors.secondary_container,
709            colors,
710            ..Self::default()
711        }
712    }
713
714    /// Whether the theme's background reads as dark, for syncing OS window
715    /// chrome (titlebar / caption buttons) to the app theme.
716    pub fn is_dark(&self) -> bool {
717        self.colors.background.is_dark()
718    }
719}
720
721/// Platform/device scale (dp->px multiplier). Platform runner should set this.
722#[derive(Clone, Copy, Debug)]
723pub struct Density {
724    pub scale: f32,
725}
726impl Default for Density {
727    fn default() -> Self {
728        Self { scale: 1.0 }
729    }
730}
731
732/// Additional UI scale multiplier (app-controlled).
733#[derive(Clone, Copy, Debug)]
734pub struct UiScale(pub f32);
735impl Default for UiScale {
736    fn default() -> Self {
737        Self(1.0)
738    }
739}
740
741#[derive(Clone, Copy, Debug)]
742pub struct TextScale(pub f32);
743impl Default for TextScale {
744    fn default() -> Self {
745        Self(1.0)
746    }
747}
748
749pub fn with_theme<R>(theme: Theme, f: impl FnOnce() -> R) -> R {
750    with_locals_frame(|| {
751        set_local_boxed(TypeId::of::<Theme>(), Box::new(theme));
752        f()
753    })
754}
755
756pub fn with_density<R>(density: Density, f: impl FnOnce() -> R) -> R {
757    with_locals_frame(|| {
758        set_local_boxed(TypeId::of::<Density>(), Box::new(density));
759        f()
760    })
761}
762
763pub fn with_ui_scale<R>(s: UiScale, f: impl FnOnce() -> R) -> R {
764    with_locals_frame(|| {
765        set_local_boxed(TypeId::of::<UiScale>(), Box::new(s));
766        f()
767    })
768}
769
770pub fn with_text_scale<R>(ts: TextScale, f: impl FnOnce() -> R) -> R {
771    with_locals_frame(|| {
772        set_local_boxed(TypeId::of::<TextScale>(), Box::new(ts));
773        f()
774    })
775}
776
777pub fn with_text_direction<R>(dir: TextDirection, f: impl FnOnce() -> R) -> R {
778    with_locals_frame(|| {
779        set_local_boxed(TypeId::of::<TextDirection>(), Box::new(dir));
780        f()
781    })
782}
783
784pub fn with_window_insets<R>(insets: WindowInsets, f: impl FnOnce() -> R) -> R {
785    with_locals_frame(|| {
786        set_local_boxed(TypeId::of::<WindowInsets>(), Box::new(insets));
787        f()
788    })
789}
790
791#[derive(Clone, Copy, Debug)]
792pub struct ContentColor(pub Color);
793
794pub fn with_content_color<R>(color: Color, f: impl FnOnce() -> R) -> R {
795    with_locals_frame(|| {
796        set_local_boxed(TypeId::of::<ContentColor>(), Box::new(ContentColor(color)));
797        f()
798    })
799}
800
801pub fn content_color() -> Color {
802    let value = get_local::<ContentColor>()
803        .map(|color| color.0)
804        .unwrap_or_else(|| {
805            get_local::<Theme>()
806                .unwrap_or_else(|| defaults().read().theme)
807                .on_surface
808        });
809    record_local(LocalId::ContentColor, &value, fingerprint_color);
810    value
811}
812
813/// Compose `@Composable contentColorFor(backgroundColor)`.
814/// Scheme role pair if known. Otherwise current `LocalContentColor`, then a
815/// luminance fallback so custom fills never inherit a same-luma icon color.
816pub fn content_color_for(background: Color) -> Color {
817    if let Some(c) = theme().colors.content_color_for(background) {
818        return c;
819    }
820    let local = content_color();
821    // If ambient content would sit on a similarly light/dark fill, flip.
822    if (background.relative_luminance() - local.relative_luminance()).abs() < 0.25 {
823        if background.is_dark() {
824            Color::WHITE
825        } else {
826            Color::BLACK
827        }
828    } else {
829        local
830    }
831}
832
833/// Composition-local default text size ([`Sp`]). Bare `Text(...)`
834/// children inherit the container's typography instead of the global default.
835#[derive(Clone, Copy, Debug)]
836pub struct TextSize(pub Sp);
837
838pub fn with_text_size<R>(size: Sp, f: impl FnOnce() -> R) -> R {
839    with_locals_frame(|| {
840        set_local_boxed(TypeId::of::<TextSize>(), Box::new(TextSize(size)));
841        f()
842    })
843}
844
845pub fn text_size() -> Option<Sp> {
846    let value = get_local::<TextSize>().map(|size| size.0);
847    record_local(LocalId::TextSize, &value, |value| {
848        fingerprint_text_size(*value)
849    });
850    value
851}
852
853/// Composition-local default indication (ripple/highlight) factory.
854/// Components like `Button` read this to get the default press feedback.
855/// Mirrors Compose's `LocalIndication`.
856#[derive(Clone, Debug, Default)]
857pub struct LocalIndication(pub Option<Rc<dyn IndicationNodeFactory>>);
858
859pub fn with_local_indication<R>(
860    indication: Option<Rc<dyn IndicationNodeFactory>>,
861    f: impl FnOnce() -> R,
862) -> R {
863    with_locals_frame(|| {
864        set_local_boxed(
865            std::any::TypeId::of::<LocalIndication>(),
866            Box::new(LocalIndication(indication)),
867        );
868        f()
869    })
870}
871
872/// Optional composition-local override for [`crate::input::InputMode`].
873/// Mirrors Compose `LocalInputModeManager` for tests and nested hosts.
874#[derive(Clone, Copy, Debug)]
875struct LocalInputMode(pub crate::input::InputMode);
876
877/// Override input mode for a composition subtree.
878pub fn with_input_mode<R>(mode: crate::input::InputMode, f: impl FnOnce() -> R) -> R {
879    with_locals_frame(|| {
880        set_local_boxed(
881            TypeId::of::<LocalInputMode>(),
882            Box::new(LocalInputMode(mode)),
883        );
884        f()
885    })
886}
887
888/// Read a composition-local input mode override, if any.
889pub(crate) fn local_input_mode() -> Option<crate::input::InputMode> {
890    let value = get_local::<LocalInputMode>().map(|mode| mode.0);
891    let effective = value.unwrap_or_else(crate::input::default_input_mode);
892    record_local(LocalId::InputMode, &effective, |value| {
893        fingerprint_input_mode(*value)
894    });
895    value
896}
897
898fn raw_local_indication() -> Option<Rc<dyn IndicationNodeFactory>> {
899    LOCALS_STACK.with(|stack| {
900        for frame in stack.borrow().iter().rev() {
901            if let Some(value) = frame.get(&TypeId::of::<LocalIndication>())
902                && let Some(indication) = value.downcast_ref::<LocalIndication>()
903            {
904                return indication.0.clone();
905            }
906        }
907        None
908    })
909}
910
911pub fn local_indication() -> Option<Rc<dyn IndicationNodeFactory>> {
912    let value = raw_local_indication();
913    record_local(LocalId::Indication, &value, fingerprint_indication);
914    value
915}
916
917/// System window insets (status bar, navigation bar, IME keyboard, etc.)
918#[derive(Clone, Copy, Debug, Default, PartialEq)]
919pub struct WindowInsets {
920    pub top: f32,
921    pub bottom: f32,
922    pub left: f32,
923    pub right: f32,
924    /// Soft keyboard (IME) inset from bottom of screen. Set by platform runner
925    /// when the keyboard opens/closes. Used by `imePadding()` modifier.
926    pub ime_bottom: f32,
927}
928
929/// Set the global default window insets (platform should call this when insets change).
930pub fn set_window_insets_default(insets: WindowInsets) {
931    let changed = {
932        let mut defaults = defaults().write();
933        let old = defaults.window_insets;
934        let changed = old.top.to_bits() != insets.top.to_bits()
935            || old.bottom.to_bits() != insets.bottom.to_bits()
936            || old.left.to_bits() != insets.left.to_bits()
937            || old.right.to_bits() != insets.right.to_bits()
938            || old.ime_bottom.to_bits() != insets.ime_bottom.to_bits();
939        defaults.window_insets = insets;
940        changed
941    };
942    set_local_boxed(TypeId::of::<WindowInsets>(), Box::new(insets));
943    if changed {
944        crate::request_frame();
945    }
946}
947
948/// Update just the IME bottom inset (keyboard height in px). Platform runners
949/// call this when the soft keyboard opens/closes.
950pub fn set_ime_inset(height_px: f32) {
951    let insets = {
952        let mut defaults = defaults().write();
953        let changed = defaults.window_insets.ime_bottom.to_bits() != height_px.to_bits();
954        defaults.window_insets.ime_bottom = height_px;
955        (changed, defaults.window_insets)
956    };
957    set_local_boxed(TypeId::of::<WindowInsets>(), Box::new(insets.1));
958    if insets.0 {
959        crate::request_frame();
960    }
961}
962
963/// Query current window insets.
964pub fn window_insets() -> WindowInsets {
965    let value = get_local::<WindowInsets>().unwrap_or_else(|| defaults().read().window_insets);
966    record_local(LocalId::WindowInsets, &value, fingerprint_window_insets);
967    value
968}
969
970/// Set the logical window container size (in dp). The `LayoutEngine` calls
971/// this on every layout from the physical viewport + density.
972pub fn set_window_container_size(width_dp: f32, height_dp: f32) {
973    let changed = {
974        let mut defaults = defaults().write();
975        let changed = defaults.container_width.to_bits() != width_dp.to_bits()
976            || defaults.container_height.to_bits() != height_dp.to_bits();
977        defaults.container_width = width_dp;
978        defaults.container_height = height_dp;
979        changed
980    };
981    if changed {
982        crate::request_frame();
983    }
984}
985
986/// Set just the logical window container width (in dp). Prefer
987/// [`set_window_container_size`]. Kept for hosts that update one axis at a time.
988pub fn set_window_container_width(w_dp: f32) {
989    let changed = {
990        let mut defaults = defaults().write();
991        let changed = defaults.container_width.to_bits() != w_dp.to_bits();
992        defaults.container_width = w_dp;
993        changed
994    };
995    if changed {
996        crate::request_frame();
997    }
998}
999
1000/// Set just the logical window container height (in dp). Prefer
1001/// [`set_window_container_size`]. Kept for hosts that update one axis at a time.
1002pub fn set_window_container_height(h_dp: f32) {
1003    let changed = {
1004        let mut defaults = defaults().write();
1005        let changed = defaults.container_height.to_bits() != h_dp.to_bits();
1006        defaults.container_height = h_dp;
1007        changed
1008    };
1009    if changed {
1010        crate::request_frame();
1011    }
1012}
1013
1014/// The logical window container width in dp (used by Material dropdowns).
1015pub fn get_window_container_width() -> f32 {
1016    let value = defaults().read().container_width;
1017    record_local(LocalId::ContainerWidth, &value, |value| {
1018        fingerprint_f32(*value)
1019    });
1020    value
1021}
1022
1023/// The logical window container height in dp (used by Material search bars).
1024pub fn get_window_container_height() -> f32 {
1025    let value = defaults().read().container_height;
1026    record_local(LocalId::ContainerHeight, &value, |value| {
1027        fingerprint_f32(*value)
1028    });
1029    value
1030}
1031
1032/// Coarse width category for a window, computed from its current size.
1033///
1034/// Thresholds (in dp) match the Material 3 adaptive spec:
1035///
1036/// - [`WidthClass::Compact`]  : width < 600 dp
1037/// - [`WidthClass::Medium`]   : 600 dp <= width < 840 dp
1038/// - [`WidthClass::Expanded`] : width >= 840 dp
1039#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
1040pub enum WidthClass {
1041    #[default]
1042    Compact,
1043    Medium,
1044    Expanded,
1045}
1046
1047/// Coarse height category for a window, computed from its current size.
1048///
1049/// Thresholds (in dp) match the Material 3 adaptive spec:
1050///
1051/// - [`HeightClass::Compact`]  : height < 480 dp
1052/// - [`HeightClass::Medium`]   : 480 dp <= height < 900 dp
1053/// - [`HeightClass::Expanded`] : height >= 900 dp
1054#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
1055pub enum HeightClass {
1056    #[default]
1057    Compact,
1058    Medium,
1059    Expanded,
1060}
1061
1062/// Snapshot of the current window's size category.
1063///
1064/// The `LayoutEngine` updates the `WindowSizeClass` default local every time
1065/// the window is resized, so UI can read it via [`window_size_class()`] during
1066/// composition.
1067#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
1068pub struct WindowSizeClass {
1069    pub width: WidthClass,
1070    pub height: HeightClass,
1071}
1072
1073impl WindowSizeClass {
1074    /// `true` when there is enough horizontal space for multi-pane layouts.
1075    pub fn is_expanded_width(&self) -> bool {
1076        matches!(self.width, WidthClass::Expanded)
1077    }
1078    /// `true` when there is enough horizontal space for a two-pane layout
1079    /// (list + detail). Per M3 this is Medium or wider.
1080    pub fn is_at_least_medium_width(&self) -> bool {
1081        matches!(self.width, WidthClass::Medium | WidthClass::Expanded)
1082    }
1083}
1084
1085/// Compute a [`WindowSizeClass`] from a window size in physical pixels and
1086/// the current dp->px density scale (`Density.scale * UiScale.0`).
1087pub fn calculate_window_size_class(
1088    width_px: u32,
1089    height_px: u32,
1090    density_scale: f32,
1091) -> WindowSizeClass {
1092    let density = density_scale.max(0.0001);
1093    let width_dp = (width_px as f32) / density;
1094    let height_dp = (height_px as f32) / density;
1095
1096    let width = if width_dp < 600.0 {
1097        WidthClass::Compact
1098    } else if width_dp < 840.0 {
1099        WidthClass::Medium
1100    } else {
1101        WidthClass::Expanded
1102    };
1103    let height = if height_dp < 480.0 {
1104        HeightClass::Compact
1105    } else if height_dp < 900.0 {
1106        HeightClass::Medium
1107    } else {
1108        HeightClass::Expanded
1109    };
1110
1111    WindowSizeClass { width, height }
1112}
1113
1114/// Set the global default window size class used when no local is active.
1115/// Called by the `LayoutEngine` on resize.
1116pub fn set_window_size_class_default(class: WindowSizeClass) {
1117    let changed = {
1118        let mut defaults = defaults().write();
1119        let changed = defaults.window_size_class != class;
1120        defaults.window_size_class = class;
1121        changed
1122    };
1123    if changed {
1124        crate::request_frame();
1125    }
1126}
1127
1128/// Override the window size class for a subtree of the composition.
1129pub fn with_window_size_class<R>(class: WindowSizeClass, f: impl FnOnce() -> R) -> R {
1130    with_locals_frame(|| {
1131        set_local_boxed(TypeId::of::<WindowSizeClass>(), Box::new(class));
1132        f()
1133    })
1134}
1135
1136/// Query current window size class. Returns a default-initialized
1137/// `WindowSizeClass` (Compact/Compact) if nothing has been set yet.
1138pub fn window_size_class() -> WindowSizeClass {
1139    let value =
1140        get_local::<WindowSizeClass>().unwrap_or_else(|| defaults().read().window_size_class);
1141    record_local(
1142        LocalId::WindowSizeClass,
1143        &value,
1144        fingerprint_window_size_class,
1145    );
1146    value
1147}
1148
1149pub fn theme() -> Theme {
1150    let value = get_local::<Theme>().unwrap_or_else(|| defaults().read().theme);
1151    record_local(LocalId::Theme, &value, fingerprint_theme);
1152    value
1153}
1154
1155pub fn theme_fingerprint() -> u64 {
1156    fingerprint_theme(&theme())
1157}
1158
1159/// Same as [`theme_fingerprint`], but reuses a [`Theme`] the caller already
1160/// read. Fingerprinting formats the whole theme, so caching code should take
1161/// this overload rather than reading the local twice.
1162pub fn theme_fingerprint_of(theme: &Theme) -> u64 {
1163    fingerprint_theme(theme)
1164}
1165
1166pub fn density() -> Density {
1167    let value = get_local::<Density>().unwrap_or_else(|| defaults().read().density);
1168    record_local(LocalId::Density, &value, |value| {
1169        fingerprint_f32(value.scale)
1170    });
1171    value
1172}
1173
1174pub fn ui_scale() -> UiScale {
1175    let value = get_local::<UiScale>().unwrap_or_else(|| defaults().read().ui_scale);
1176    record_local(LocalId::UiScale, &value, |value| fingerprint_f32(value.0));
1177    value
1178}
1179
1180pub fn text_scale() -> TextScale {
1181    let value = get_local::<TextScale>().unwrap_or_else(|| defaults().read().text_scale);
1182    record_local(LocalId::TextScale, &value, |value| fingerprint_f32(value.0));
1183    value
1184}
1185
1186pub fn text_direction() -> TextDirection {
1187    let value = get_local::<TextDirection>().unwrap_or_else(|| defaults().read().text_direction);
1188    record_local(LocalId::TextDirection, &value, fingerprint_text_direction);
1189    value
1190}
1191
1192pub(crate) fn local_fingerprint(id: usize) -> u64 {
1193    match id {
1194        id if id == LocalId::Theme.index() => {
1195            fingerprint_theme(&get_local::<Theme>().unwrap_or_else(|| defaults().read().theme))
1196        }
1197        id if id == LocalId::Density.index() => fingerprint_f32(
1198            get_local::<Density>()
1199                .unwrap_or_else(|| defaults().read().density)
1200                .scale,
1201        ),
1202        id if id == LocalId::UiScale.index() => fingerprint_f32(
1203            get_local::<UiScale>()
1204                .unwrap_or_else(|| defaults().read().ui_scale)
1205                .0,
1206        ),
1207        id if id == LocalId::TextScale.index() => fingerprint_f32(
1208            get_local::<TextScale>()
1209                .unwrap_or_else(|| defaults().read().text_scale)
1210                .0,
1211        ),
1212        id if id == LocalId::TextDirection.index() => fingerprint_text_direction(
1213            &get_local::<TextDirection>().unwrap_or_else(|| defaults().read().text_direction),
1214        ),
1215        id if id == LocalId::WindowInsets.index() => fingerprint_window_insets(
1216            &get_local::<WindowInsets>().unwrap_or_else(|| defaults().read().window_insets),
1217        ),
1218        id if id == LocalId::WindowSizeClass.index() => fingerprint_window_size_class(
1219            &get_local::<WindowSizeClass>().unwrap_or_else(|| defaults().read().window_size_class),
1220        ),
1221        id if id == LocalId::ContainerWidth.index() => {
1222            fingerprint_f32(defaults().read().container_width)
1223        }
1224        id if id == LocalId::ContainerHeight.index() => {
1225            fingerprint_f32(defaults().read().container_height)
1226        }
1227        id if id == LocalId::ContentColor.index() => fingerprint_color(
1228            &get_local::<ContentColor>()
1229                .map(|value| value.0)
1230                .unwrap_or_else(|| {
1231                    get_local::<Theme>()
1232                        .unwrap_or_else(|| defaults().read().theme)
1233                        .on_surface
1234                }),
1235        ),
1236        id if id == LocalId::TextSize.index() => {
1237            fingerprint_text_size(get_local::<TextSize>().map(|value| value.0))
1238        }
1239        id if id == LocalId::Indication.index() => fingerprint_indication(&raw_local_indication()),
1240        id if id == LocalId::InputMode.index() => {
1241            fingerprint_input_mode(crate::input::input_mode())
1242        }
1243        _ => 0,
1244    }
1245}
1246
1247pub fn locals_stamp() -> u64 {
1248    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1249    for id in 0..=LocalId::InputMode.index() {
1250        id.hash(&mut hasher);
1251        local_fingerprint(id).hash(&mut hasher);
1252    }
1253    hasher.finish()
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259
1260    #[test]
1261    fn width_class_thresholds_match_m3() {
1262        // Density 1.0 (1 dp = 1 px) for clarity.
1263        assert_eq!(
1264            calculate_window_size_class(100, 100, 1.0).width,
1265            WidthClass::Compact
1266        );
1267        assert_eq!(
1268            calculate_window_size_class(599, 100, 1.0).width,
1269            WidthClass::Compact
1270        );
1271        assert_eq!(
1272            calculate_window_size_class(600, 100, 1.0).width,
1273            WidthClass::Medium
1274        );
1275        assert_eq!(
1276            calculate_window_size_class(839, 100, 1.0).width,
1277            WidthClass::Medium
1278        );
1279        assert_eq!(
1280            calculate_window_size_class(840, 100, 1.0).width,
1281            WidthClass::Expanded
1282        );
1283        assert_eq!(
1284            calculate_window_size_class(2000, 100, 1.0).width,
1285            WidthClass::Expanded
1286        );
1287    }
1288
1289    #[test]
1290    fn height_class_thresholds_match_m3() {
1291        assert_eq!(
1292            calculate_window_size_class(100, 100, 1.0).height,
1293            HeightClass::Compact
1294        );
1295        assert_eq!(
1296            calculate_window_size_class(100, 479, 1.0).height,
1297            HeightClass::Compact
1298        );
1299        assert_eq!(
1300            calculate_window_size_class(100, 480, 1.0).height,
1301            HeightClass::Medium
1302        );
1303        assert_eq!(
1304            calculate_window_size_class(100, 899, 1.0).height,
1305            HeightClass::Medium
1306        );
1307        assert_eq!(
1308            calculate_window_size_class(100, 900, 1.0).height,
1309            HeightClass::Expanded
1310        );
1311    }
1312
1313    #[test]
1314    fn density_scales_thresholds() {
1315        // 2.0x density: 600 dp = 1200 px.
1316        let c = calculate_window_size_class(1199, 100, 2.0);
1317        assert_eq!(c.width, WidthClass::Compact);
1318        let c = calculate_window_size_class(1200, 100, 2.0);
1319        assert_eq!(c.width, WidthClass::Medium);
1320    }
1321
1322    #[test]
1323    fn is_at_least_medium_width() {
1324        let c = WindowSizeClass {
1325            width: WidthClass::Compact,
1326            height: HeightClass::Compact,
1327        };
1328        assert!(!c.is_at_least_medium_width());
1329        let c = WindowSizeClass {
1330            width: WidthClass::Medium,
1331            height: HeightClass::Compact,
1332        };
1333        assert!(c.is_at_least_medium_width());
1334        let c = WindowSizeClass {
1335            width: WidthClass::Expanded,
1336            height: HeightClass::Compact,
1337        };
1338        assert!(c.is_at_least_medium_width());
1339    }
1340}