Skip to main content

kui_core/
env.rs

1//! Host environment facts pushed into the core by the frame driver — the
2//! inbound mirror of events-as-data. The core never touches a window; the
3//! driver (runner, FFI host) reports what it knows and views read it.
4//!
5//! The reading a view gets — this struct, [`SystemEnv`], [`WindowEnv`], the
6//! derived budget and the frame facts beside them, under each binding's
7//! spelling — is written down once in `schema::ENV_FIELDS` and every
8//! binding is pinned to that table; a field added here fails `schema`'s
9//! tests until it has a row.
10//!
11//! Every fact under [`SystemEnv`] can also be *unknown*, and unknown is the
12//! default. A driver that cannot ask the OS says so rather than guessing,
13//! because the guess a view would make from a wrong answer (paint the dark
14//! palette, skip the animation) is worse than the one it makes from a
15//! missing one.
16
17use crate::color::Color;
18use crate::window::WindowEnv;
19
20/// What the host knows about the display/window. Defaults are safe for
21/// headless drivers (tests, benches) that never set anything.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct Env {
24    /// Display refresh rate in Hz; `None` when the host can't tell.
25    pub refresh_hz: Option<f32>,
26    /// Whether the window has keyboard focus.
27    pub focused: bool,
28    /// What the OS is set to: appearance, accent, motion, locale — and
29    /// whether assistive technology is listening.
30    pub system: SystemEnv,
31    /// Window chrome facts (custom chrome, maximized, native control rect).
32    pub window: WindowEnv,
33    /// The output device's state and how many playbacks are live.
34    pub audio: AudioEnv,
35}
36
37impl Default for Env {
38    fn default() -> Self {
39        Self {
40            refresh_hz: None,
41            focused: true,
42            system: SystemEnv::default(),
43            window: WindowEnv::default(),
44            audio: AudioEnv::default(),
45        }
46    }
47}
48
49impl Env {
50    /// Budget fallback when the host doesn't report a refresh rate.
51    pub const DEFAULT_HZ: f32 = 120.0;
52
53    /// Per-frame time budget in ms: one vsync interval at the display's
54    /// refresh rate (120 Hz when unreported).
55    pub fn frame_budget_ms(&self) -> f32 {
56        let hz = self
57            .refresh_hz
58            .filter(|hz| *hz > 0.0)
59            .unwrap_or(Self::DEFAULT_HZ);
60        1000.0 / hz
61    }
62}
63
64/// The user's OS settings, as the host reports them. Not window facts and
65/// not display facts: things the person chose once, in a settings app, that
66/// a view is expected to honour. The core acts on two of them in one way:
67/// `appearance` and `accent` derive the theme (ADR 0019), so the stock
68/// widgets and a `<text>` with no colour follow the OS — and nothing else
69/// moves. Reduced motion does not shorten an animation and a dark
70/// appearance repaints none of the app's own colours: the view decides,
71/// because only it knows which of its colours is the background and which
72/// of its animations carries meaning.
73///
74/// Each field defaults to "the host cannot tell", which is what a headless
75/// core reports and what any driver reports for a fact its platform gives
76/// it no way to ask. The `kui-native` runner asks the OS for all four on macOS and
77/// Windows (the appearance through winit, the rest in its `system_env`);
78/// elsewhere it answers what it can and leaves the rest unknown. The fifth,
79/// [`Assistive`], is not a setting but a fact of the same shape — the
80/// user chose to run a screen reader — and comes from the accessibility
81/// bridge rather than a settings query.
82#[derive(Clone, Copy, Debug, Default, PartialEq)]
83pub struct SystemEnv {
84    /// Light or dark, when the host can tell.
85    pub appearance: Appearance,
86    /// The OS accent/highlight colour, `None` when the host can't tell.
87    pub accent: Option<Color>,
88    /// Whether the user asked for reduced motion.
89    pub motion: MotionPref,
90    /// The UI language as a BCP-47 tag, `None` when the host can't tell.
91    pub locale: Option<Locale>,
92    /// Whether assistive technology has asked for the access tree.
93    pub assistive: Assistive,
94}
95
96impl SystemEnv {
97    /// `self` laid over `base`: every field `self` knows wins, every field
98    /// it left at "cannot tell" is `base`'s. The merge behind a launcher's
99    /// pinned reading (`kui_native::Launcher::system`, Node's `runWindowed(..,
100    /// {system})`, the context a C host hands `kui_run_with`): the app's
101    /// partial over what the OS answered, applied every frame where the
102    /// runner writes the real reading — so a pinned `motion` survives the
103    /// write, and a real change to the accent still arrives, because that
104    /// field was left unknown here and `base` is the OS's (backlog F47).
105    ///
106    /// Unknown *means* not pinned, which is why there is no separate
107    /// override type: the four "cannot tell" readings are the defaults, so
108    /// `SystemEnv { motion: MotionPref::Reduced, ..Default::default() }` is
109    /// the whole of "as if this user asked for less motion". What it
110    /// cannot say is "pin this to unknown" — a window on a platform that
111    /// answers has no test that needs it.
112    pub fn over(self, base: SystemEnv) -> SystemEnv {
113        SystemEnv {
114            appearance: match self.appearance {
115                Appearance::Unknown => base.appearance,
116                pinned => pinned,
117            },
118            accent: self.accent.or(base.accent),
119            motion: match self.motion {
120                MotionPref::Unknown => base.motion,
121                pinned => pinned,
122            },
123            locale: self.locale.or(base.locale),
124            assistive: match self.assistive {
125                Assistive::Unknown => base.assistive,
126                pinned => pinned,
127            },
128        }
129    }
130}
131
132/// The OS light/dark setting. `Unknown` is a real answer — a host with no
133/// way to ask says it, and a view that has one palette per appearance picks
134/// its own default for it rather than being handed a guess.
135#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
136pub enum Appearance {
137    #[default]
138    Unknown,
139    Light,
140    Dark,
141}
142
143impl Appearance {
144    /// Wire order: the index is the code C passes and the position in
145    /// `schema::APPEARANCES`, so `unknown` is 0 and a zeroed C host means
146    /// what it says.
147    pub const ALL: &'static [Appearance] =
148        &[Appearance::Unknown, Appearance::Light, Appearance::Dark];
149
150    pub fn name(self) -> &'static str {
151        match self {
152            Appearance::Unknown => "unknown",
153            Appearance::Light => "light",
154            Appearance::Dark => "dark",
155        }
156    }
157
158    pub fn parse(name: &str) -> Option<Self> {
159        Self::ALL.iter().copied().find(|a| a.name() == name)
160    }
161
162    /// The code a C host passes; `unknown` is 0.
163    pub fn code(self) -> u32 {
164        Self::ALL.iter().position(|a| *a == self).unwrap() as u32
165    }
166
167    /// A code past the end is `None` — ignored rather than folded onto a
168    /// real appearance, the way an unknown role code is.
169    pub fn from_code(code: u32) -> Option<Self> {
170        Self::ALL.get(code as usize).copied()
171    }
172}
173
174/// The OS reduce-motion setting: `Reduced` is "the user asked for less
175/// animation", `Full` is "the user did not", `Unknown` is "nobody asked the
176/// OS". Spelled as what the user wants rather than as a `reduce_motion`
177/// boolean because the third reading has no place in a boolean, and a
178/// missing answer is not the same as a "no".
179///
180/// `Pref` because `edit` already means cosmic-text's caret `Motion` by that
181/// name, and a crate with two of them would be one letter of ambiguity in
182/// every use. The field is `system.motion` and every binding spells it
183/// `motion`; only Rust sees the longer type name.
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
185pub enum MotionPref {
186    #[default]
187    Unknown,
188    Full,
189    Reduced,
190}
191
192impl MotionPref {
193    /// Wire order, as [`Appearance::ALL`].
194    pub const ALL: &'static [MotionPref] =
195        &[MotionPref::Unknown, MotionPref::Full, MotionPref::Reduced];
196
197    pub fn name(self) -> &'static str {
198        match self {
199            MotionPref::Unknown => "unknown",
200            MotionPref::Full => "full",
201            MotionPref::Reduced => "reduced",
202        }
203    }
204
205    pub fn parse(name: &str) -> Option<Self> {
206        Self::ALL.iter().copied().find(|m| m.name() == name)
207    }
208
209    /// The code a C host passes; `unknown` is 0.
210    pub fn code(self) -> u32 {
211        Self::ALL.iter().position(|m| *m == self).unwrap() as u32
212    }
213
214    pub fn from_code(code: u32) -> Option<Self> {
215        Self::ALL.get(code as usize).copied()
216    }
217
218    /// Whether a view should skip or shorten decorative motion. `Unknown`
219    /// answers `false`: a host that cannot tell gets the animations it
220    /// would have had before this field existed.
221    pub fn is_reduced(self) -> bool {
222        self == MotionPref::Reduced
223    }
224}
225
226/// Whether assistive technology is listening: the difference between an
227/// alert that blinks and one that announces (backlog F48). `Listening` is
228/// "an accessibility client has asked this window for its tree", which is
229/// the one signal the platform adapters give and the moment the runner
230/// starts deriving trees (ADR 0016 measures its cache from there).
231/// `None` is "the bridge is up and nobody has asked"; `Unknown` is "there
232/// is no bridge" — a headless core, a driver built without the
233/// `accesskit` feature, a C host that never called the setter.
234///
235/// Two limits are the reading's, not the row's. *Any* client counts: a
236/// probe, an accessibility inspector, a test harness driving the AX API
237/// and VoiceOver alike all ask for the tree, and nothing tells them apart.
238/// And whether it ever falls back to `None` is the platform's: only the
239/// AT-SPI adapter (Unix) reports deactivation, when the session's
240/// accessibility bus goes away; on macOS and Windows the adapters never
241/// call the deactivation handler, so once a client has asked the reading
242/// stays `Listening` for the window's life.
243#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
244pub enum Assistive {
245    #[default]
246    Unknown,
247    None,
248    Listening,
249}
250
251impl Assistive {
252    /// Wire order, as [`Appearance::ALL`]: `unknown` is 0.
253    pub const ALL: &'static [Assistive] =
254        &[Assistive::Unknown, Assistive::None, Assistive::Listening];
255
256    pub fn name(self) -> &'static str {
257        match self {
258            Assistive::Unknown => "unknown",
259            Assistive::None => "none",
260            Assistive::Listening => "listening",
261        }
262    }
263
264    pub fn parse(name: &str) -> Option<Self> {
265        Self::ALL.iter().copied().find(|a| a.name() == name)
266    }
267
268    /// The code a C host passes; `unknown` is 0.
269    pub fn code(self) -> u32 {
270        Self::ALL.iter().position(|a| *a == self).unwrap() as u32
271    }
272
273    pub fn from_code(code: u32) -> Option<Self> {
274        Self::ALL.get(code as usize).copied()
275    }
276
277    /// Whether something is listening. `Unknown` answers `false`, as
278    /// [`MotionPref::is_reduced`] does: a host that cannot tell gets the
279    /// blink it would have had before this field existed.
280    pub fn is_listening(self) -> bool {
281        self == Assistive::Listening
282    }
283}
284
285/// What the driver's audio output is doing, for views to read. A fact,
286/// not a verb: nothing here lets a view close the device, which stays the
287/// driver's decision (it closes an idle one itself, after a while).
288///
289/// Worth a row because an open output stream is a real-time thread that
290/// runs whether or not anything plays — ~94 buffer callbacks a second at
291/// the usual period — which is the whole of an idle app's CPU once a
292/// session has held a sound. A view that shows `device` still `Open` ten
293/// seconds after its last click is showing a bug that otherwise only
294/// `top` can see. Headless drivers leave it at the default, which is the
295/// truth for them: no device, nothing playing.
296#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
297pub struct AudioEnv {
298    /// Whether the output device is open, and so costing something.
299    pub device: AudioDevice,
300    /// Playbacks started and not yet ended, plus the ones waiting on the
301    /// device to open. A play that waits counts from the frame it was
302    /// asked until the open answers; if the device refuses, the play is
303    /// refused on the next apply and leaves the count with it (F63).
304    pub live: u32,
305}
306
307/// The output device's state. `Closed` is the default and what a headless
308/// driver reports; `Opening` is the ~90 ms the open takes on its own
309/// thread; `Failed` is a device that refused to open, after which commands
310/// are dropped.
311#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
312pub enum AudioDevice {
313    #[default]
314    Closed,
315    Opening,
316    Open,
317    Failed,
318}
319
320impl AudioDevice {
321    /// Wire order, as [`Appearance::ALL`]: `closed` is 0, so a zeroed C
322    /// call means what it says.
323    pub const ALL: &'static [AudioDevice] = &[
324        AudioDevice::Closed,
325        AudioDevice::Opening,
326        AudioDevice::Open,
327        AudioDevice::Failed,
328    ];
329
330    pub fn name(self) -> &'static str {
331        match self {
332            AudioDevice::Closed => "closed",
333            AudioDevice::Opening => "opening",
334            AudioDevice::Open => "open",
335            AudioDevice::Failed => "failed",
336        }
337    }
338
339    pub fn parse(name: &str) -> Option<Self> {
340        Self::ALL.iter().copied().find(|d| d.name() == name)
341    }
342
343    /// The code a C host passes; `closed` is 0.
344    pub fn code(self) -> u32 {
345        Self::ALL.iter().position(|d| *d == self).unwrap() as u32
346    }
347
348    pub fn from_code(code: u32) -> Option<Self> {
349        Self::ALL.get(code as usize).copied()
350    }
351}
352
353/// A language tag as the host reports it — `"en"`, `"en-US"`,
354/// `"zh-Hans-CN"`. Carried inline rather than as a `String` so [`Env`]
355/// stays `Copy`: a view reads `ui.env()` every frame, and a tag that
356/// allocated would allocate on every one of them.
357///
358/// The core does not parse it. It is passed through to the view, which
359/// hands it to whatever formats dates and numbers — kui has no opinion
360/// about what is a language and what is a region.
361#[derive(Clone, Copy, PartialEq, Eq)]
362pub struct Locale {
363    /// ASCII, zero-padded past `len` so the derived `Eq` compares tags and
364    /// not whatever was in the tail.
365    bytes: [u8; Locale::CAP],
366    len: u8,
367}
368
369impl Locale {
370    /// Longest tag that fits. RFC 5646 allows longer in principle; 31
371    /// holds every tag anyone ships, including the script-and-region ones
372    /// (`zh-Hant-HK`) and a private-use suffix.
373    pub const CAP: usize = 31;
374
375    /// `None` for a tag that is empty, longer than [`Locale::CAP`], or not
376    /// ASCII — the three things a well-formed language tag is not. A host
377    /// that hands one of those over reads back "the host cannot tell",
378    /// which is true: what it said was not a tag.
379    pub fn new(tag: &str) -> Option<Self> {
380        if tag.is_empty() || tag.len() > Self::CAP || !tag.is_ascii() {
381            return None;
382        }
383        let mut bytes = [0u8; Self::CAP];
384        bytes[..tag.len()].copy_from_slice(tag.as_bytes());
385        Some(Self {
386            bytes,
387            len: tag.len() as u8,
388        })
389    }
390
391    pub fn as_str(&self) -> &str {
392        // ASCII by construction in `new`, the only constructor.
393        std::str::from_utf8(&self.bytes[..self.len as usize]).unwrap_or("")
394    }
395}
396
397impl std::fmt::Display for Locale {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        f.write_str(self.as_str())
400    }
401}
402
403impl std::fmt::Debug for Locale {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        // The tag, not 31 bytes of padding.
406        write!(f, "Locale({:?})", self.as_str())
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn budget_follows_reported_rate() {
416        let env = Env {
417            refresh_hz: Some(60.0),
418            ..Default::default()
419        };
420        assert!((env.frame_budget_ms() - 16.666).abs() < 1e-2);
421    }
422
423    #[test]
424    fn budget_falls_back_when_unreported_or_bogus() {
425        assert!((Env::default().frame_budget_ms() - 8.333).abs() < 1e-2);
426        let bogus = Env {
427            refresh_hz: Some(0.0),
428            ..Default::default()
429        };
430        assert!((bogus.frame_budget_ms() - 8.333).abs() < 1e-2);
431    }
432
433    /// Nobody asked the OS anything, and the reading says exactly that
434    /// rather than "light, unreduced, English".
435    #[test]
436    fn the_system_facts_default_to_unknown() {
437        let s = Env::default().system;
438        assert_eq!(s.appearance, Appearance::Unknown);
439        assert_eq!(s.accent, None);
440        assert_eq!(s.motion, MotionPref::Unknown);
441        assert_eq!(s.locale, None);
442        assert_eq!(s.assistive, Assistive::Unknown);
443        assert!(!s.motion.is_reduced(), "unknown is not a request to reduce");
444        assert!(!s.assistive.is_listening(), "unknown is not a listener");
445        // And a headless driver holds no device: closed, nothing live.
446        assert_eq!(Env::default().audio, AudioEnv::default());
447        assert_eq!(AudioEnv::default().device, AudioDevice::Closed);
448    }
449
450    /// The schema's name lists are the wire order: an index means the same
451    /// setting in every binding, so the two cannot drift. Zero is
452    /// `unknown` in both, which is what makes a zeroed C call honest.
453    #[test]
454    fn schema_names_are_all_in_order() {
455        let appearances: Vec<&str> = Appearance::ALL.iter().map(|a| a.name()).collect();
456        assert_eq!(appearances, crate::schema::APPEARANCES);
457        let motions: Vec<&str> = MotionPref::ALL.iter().map(|m| m.name()).collect();
458        assert_eq!(motions, crate::schema::MOTIONS);
459        let devices: Vec<&str> = AudioDevice::ALL.iter().map(|d| d.name()).collect();
460        assert_eq!(devices, crate::schema::AUDIO_DEVICES);
461        let assistive: Vec<&str> = Assistive::ALL.iter().map(|a| a.name()).collect();
462        assert_eq!(assistive, crate::schema::ASSISTIVE);
463        assert_eq!(Appearance::default().code(), 0);
464        assert_eq!(MotionPref::default().code(), 0);
465        assert_eq!(AudioDevice::default().code(), 0);
466        assert_eq!(Assistive::default().code(), 0);
467    }
468
469    #[test]
470    fn codes_and_names_round_trip_and_reject_the_rest() {
471        for a in Appearance::ALL {
472            assert_eq!(Appearance::from_code(a.code()), Some(*a));
473            assert_eq!(Appearance::parse(a.name()), Some(*a));
474        }
475        for m in MotionPref::ALL {
476            assert_eq!(MotionPref::from_code(m.code()), Some(*m));
477            assert_eq!(MotionPref::parse(m.name()), Some(*m));
478        }
479        for d in AudioDevice::ALL {
480            assert_eq!(AudioDevice::from_code(d.code()), Some(*d));
481            assert_eq!(AudioDevice::parse(d.name()), Some(*d));
482        }
483        for a in Assistive::ALL {
484            assert_eq!(Assistive::from_code(a.code()), Some(*a));
485            assert_eq!(Assistive::parse(a.name()), Some(*a));
486        }
487        assert_eq!(Appearance::from_code(3), None);
488        assert_eq!(MotionPref::from_code(3), None);
489        assert_eq!(AudioDevice::from_code(4), None);
490        assert_eq!(Assistive::from_code(3), None);
491        assert_eq!(Appearance::parse("Dark"), None, "spelling is exact");
492    }
493
494    /// A tag is carried whole and compares as a tag; the three things that
495    /// are not a tag read back as "the host cannot tell".
496    #[test]
497    fn a_locale_is_the_tag_it_was_given() {
498        let tag = Locale::new("en-US").unwrap();
499        assert_eq!(tag.as_str(), "en-US");
500        assert_eq!(tag.to_string(), "en-US");
501        assert_eq!(Locale::new("zh-Hant-HK").unwrap().as_str(), "zh-Hant-HK");
502        // Two tags of different lengths cannot compare equal through the
503        // padding, and one built twice is the same tag.
504        assert_eq!(Locale::new("en"), Locale::new("en"));
505        assert_ne!(Locale::new("en"), Locale::new("en-US"));
506
507        assert_eq!(Locale::new(""), None);
508        assert_eq!(Locale::new(&"x".repeat(Locale::CAP + 1)), None);
509        assert_eq!(Locale::new("ру-RU"), None, "a tag is ASCII");
510        assert!(Locale::new(&"x".repeat(Locale::CAP)).is_some(), "CAP fits");
511    }
512
513    /// `Env` is `Copy` and small enough to read every frame — the reason
514    /// a locale is an inline tag and not a `String`.
515    #[test]
516    fn env_is_copy() {
517        fn takes_copy<T: Copy>(_: T) {}
518        takes_copy(Env::default());
519    }
520}