Skip to main content

rosace_core/
media_query.rs

1//! Global "environment" query provider: `use_media_query()` and
2//! `set_media_query()` (D126) — mirrors `safe_area.rs` exactly, same
3//! reasoning: the platform layer measures an OS-level environment value
4//! once and publishes it here, so widgets read it as ordinary state instead
5//! of every widget branching on platform.
6//!
7//! # Text scale
8//! iOS and Android both let the user scale system-wide text size up or
9//! down for accessibility (iOS: Settings → Accessibility → Display & Text
10//! Size → Larger Text, surfaced via `UIContentSizeCategory`; Android:
11//! Settings → Display → Font size, surfaced via `Configuration.fontScale`).
12//! `text_scale` carries that multiplier; `1.0` is the system default.
13//! Desktop/web have no equivalent OS-wide accessibility text-scale concept
14//! distinct from DPI (DPI/pixel density is already handled separately via
15//! `rosace_state::render_scale`), so it stays `1.0` there — not a gap, an
16//! intentional platform difference.
17//!
18//! Applied automatically wherever text size is resolved
19//! (`FontCache::measure_text`/`measure_text_weighted` and
20//! `PaintCtx::draw_text_at`/`text`/`text_styled`), so every existing
21//! widget respects it with zero per-widget code changes — a widget that
22//! asks to draw at `14.0`px actually renders (and is measured/laid out,
23//! so surrounding containers size correctly around it) at `14.0 *
24//! text_scale`px.
25//!
26//! # Other fields
27//! `is_dark`, `bold_text`, `reduce_motion`, and `always_24_hour_format`
28//! mirror `text_scale`'s reasoning — each is an OS accessibility/appearance
29//! signal, sourced per-platform where the OS actually exposes it, and left
30//! at its documented default (`false`) on platforms with no clean source
31//! for it (see `rosace-platform`/`rosace-ffi` native push call sites for
32//! exactly what's wired per platform). `is_dark` drives
33//! `rosace_theme::sync_system_theme`; `bold_text` and `reduce_motion` are
34//! applied at the same text/animation choke points `text_scale` uses;
35//! `always_24_hour_format` is detection-only today — no widget consumes it
36//! yet (`TimePicker` has no 24-hour dial mode).
37
38use rosace_state::GlobalAtom;
39use rosace_trace::event::AtomId;
40
41/// Reserved atom ID for the media-query atom (must not collide with other
42/// reserved IDs — see `rosace_theme::provider::THEME_ATOM_ID` at 0xFFFF).
43const MEDIA_QUERY_ATOM_ID: AtomId = AtomId(0xFFF4);
44
45/// OS/environment values a widget might want to adapt to, beyond what
46/// `use_platform()`/`use_safe_area()` already cover.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct MediaQuery {
49    /// OS accessibility text-size multiplier — see this module's own doc.
50    pub text_scale: f32,
51    /// System-wide light/dark appearance — `true` when the OS is in dark
52    /// mode. Drives `rosace_theme::sync_system_theme` unless the app has
53    /// locked a `ThemeMode`.
54    pub is_dark: bool,
55    /// OS accessibility "bold text everywhere" toggle.
56    pub bold_text: bool,
57    /// OS accessibility "reduce motion" toggle — when set, ROSACE's default
58    /// animations snap instead of easing (see `PaintCtx::animate_to`).
59    pub reduce_motion: bool,
60    /// Whether the OS locale/settings prefer a 24-hour clock over 12-hour
61    /// + AM/PM. Detection-only today — see this module's own doc.
62    pub always_24_hour_format: bool,
63}
64
65impl Default for MediaQuery {
66    fn default() -> Self {
67        Self {
68            text_scale: 1.0,
69            is_dark: false,
70            bold_text: false,
71            reduce_motion: false,
72            always_24_hour_format: false,
73        }
74    }
75}
76
77static CURRENT_MEDIA_QUERY: GlobalAtom<MediaQuery> = GlobalAtom::new(MEDIA_QUERY_ATOM_ID, MediaQuery::default);
78
79/// Returns the currently active environment values (`text_scale: 1.0` on
80/// platforms that don't have an OS-wide accessibility text-scale setting).
81pub fn use_media_query() -> MediaQuery {
82    CURRENT_MEDIA_QUERY.get()
83}
84
85/// Replaces the active environment values. Called by the platform layer on
86/// startup and whenever the OS setting changes (iOS:
87/// `UIContentSizeCategory.didChangeNotification`; Android:
88/// `onConfigurationChanged`); app code should not normally call this.
89pub fn set_media_query(mq: MediaQuery) {
90    CURRENT_MEDIA_QUERY.set(mq);
91    // A `GlobalAtom` write has no per-component subscribers in the
92    // dirty-tracking graph, so `mark_dirty` is a silent no-op here — and a
93    // push from OUTSIDE input dispatch (native OS callback) has no other
94    // event to ride along on to trigger a real repaint (root-caused live,
95    // see `rosace_theme::provider::sync_system_theme`'s matching comment).
96    // This used to work by accident because every native call site paired
97    // `set_media_query` with `sync_system_theme`, which did this same
98    // reset for `is_dark`; doing it here directly makes `text_scale`,
99    // `bold_text`, `reduce_motion`, and `always_24_hour_format` correct on
100    // their own, independent of whatever else a caller happens to invoke.
101    rosace_state::reset_to_global_dirty();
102}
103
104// ---------------------------------------------------------------------------
105// Tests
106//
107// This is the one platform-agnostic choke point every native push (desktop
108// `WindowEvent::ThemeChanged`, web `matchMedia` "change", iOS
109// `traitCollectionDidChange`, Android `onConfigurationChanged`, all four in
110// `rosace-platform`/`rosace-ffi`) funnels through — so a test here covers
111// the push→cache→repaint contract for every platform's native call site at
112// once, without needing a live OS on each one to prove it.
113// ---------------------------------------------------------------------------
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn use_media_query_reads_back_what_was_set() {
121        let mq = MediaQuery { text_scale: 1.3, is_dark: true, bold_text: true, reduce_motion: true, always_24_hour_format: true };
122        set_media_query(mq);
123        assert_eq!(use_media_query(), mq);
124        set_media_query(MediaQuery::default()); // restore, other tests share this global
125    }
126
127    #[test]
128    fn set_media_query_forces_a_real_repaint() {
129        // Simulate "some frame already ran and settled" — not globally dirty.
130        rosace_state::reset_to_global_dirty();
131        let _ = rosace_state::take_dirty_components();
132        assert!(!rosace_state::is_global_dirty());
133
134        // A push from OUTSIDE input dispatch (native OS callback) must force
135        // the next frame to repaint on its own, not depend on some other
136        // event happening to also mark something dirty — this is the exact
137        // bug class that shipped a "changes apply after a random multi-
138        // second delay" regression before `set_media_query` did this itself.
139        set_media_query(MediaQuery { text_scale: 1.5, ..MediaQuery::default() });
140        assert!(rosace_state::is_global_dirty());
141
142        set_media_query(MediaQuery::default()); // restore
143    }
144}