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}