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}