Skip to main content

kui_core/
env.rs

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