kui-core 0.1.0-alpha.44

kui contract: flat per-frame tree, clay-style flex layout, text stack, events as data, quad display list
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
//! [`Env`]: the host facts a frame driver pushes into the core, which a
//! view reads back with `ui.env()`.
//!
//! The core never touches a window. The driver (a runner, an FFI host)
//! reports what it knows: the refresh rate and whether the window has
//! focus ([`Env`]), the window's own facts ([`WindowEnv`]), and the user's
//! OS settings ([`SystemEnv`]: appearance, accent, reduced motion, locale,
//! assistive technology). A view reads them and decides; the core acts on
//! none of them except to derive the [`Theme`](crate::theme::Theme) from
//! the appearance and accent.
//!
//! Every fact under [`SystemEnv`] can be *unknown*, and unknown is the
//! default. A driver that cannot ask the OS says so rather than guessing,
//! and a headless [`Core`](crate::runtime::Core) reports unknown for all
//! of them.
//!
//! ```rust
//! use kui_core::{Core, Env, MotionPref, Size};
//!
//! let mut core = Core::new();
//! let ui = core.frame(Size::new(100.0, 100.0), 1.0);
//! let env: Env = ui.env();
//! assert_eq!(env.system.motion, MotionPref::Unknown); // headless: nobody said
//! assert!(env.frame_budget_ms() > 0.0);
//! ui.finish();
//! ```

use crate::color::Color;
use crate::window::WindowEnv;

/// What the host knows about the display/window. Defaults are safe for
/// headless drivers (tests, benches) that never set anything.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Env {
    /// Display refresh rate in Hz; `None` when the host can't tell.
    pub refresh_hz: Option<f32>,
    /// Whether the window has keyboard focus.
    pub focused: bool,
    /// What the OS is set to: appearance, accent, motion, locale — and
    /// whether assistive technology is listening.
    pub system: SystemEnv,
    /// Window chrome facts (custom chrome, maximized, native control rect).
    pub window: WindowEnv,
    /// The output device's state and how many playbacks are live.
    pub audio: AudioEnv,
}

impl Default for Env {
    fn default() -> Self {
        Self {
            refresh_hz: None,
            focused: true,
            system: SystemEnv::default(),
            window: WindowEnv::default(),
            audio: AudioEnv::default(),
        }
    }
}

impl Env {
    /// Budget fallback when the host doesn't report a refresh rate.
    pub const DEFAULT_HZ: f32 = 120.0;

    /// Per-frame time budget in ms: one vsync interval at the display's
    /// refresh rate (120 Hz when unreported).
    pub fn frame_budget_ms(&self) -> f32 {
        let hz = self
            .refresh_hz
            .filter(|hz| *hz > 0.0)
            .unwrap_or(Self::DEFAULT_HZ);
        1000.0 / hz
    }
}

/// The user's OS settings, as the host reports them. Not window facts and
/// not display facts: things the person chose once, in a settings app, that
/// a view is expected to honour. The core acts on two of them in one way:
/// `appearance` and `accent` derive the theme, so the stock
/// widgets and a `<text>` with no colour follow the OS — and nothing else
/// moves. Reduced motion does not shorten an animation and a dark
/// appearance repaints none of the app's own colours: the view decides,
/// because only it knows which of its colours is the background and which
/// of its animations carries meaning.
///
/// Each field defaults to "the host cannot tell", which is what a headless
/// core reports and what any driver reports for a fact its platform gives
/// it no way to ask. The `kui-native` runner asks the OS for all four on macOS and
/// Windows (the appearance through winit, the rest in its `system_env`);
/// elsewhere it answers what it can and leaves the rest unknown. The fifth,
/// [`Assistive`], is not a setting but a fact of the same shape — the
/// user chose to run a screen reader — and comes from the accessibility
/// bridge rather than a settings query.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SystemEnv {
    /// Light or dark, when the host can tell.
    pub appearance: Appearance,
    /// The OS accent/highlight colour, `None` when the host can't tell.
    pub accent: Option<Color>,
    /// Whether the user asked for reduced motion.
    pub motion: MotionPref,
    /// The UI language as a BCP-47 tag, `None` when the host can't tell.
    pub locale: Option<Locale>,
    /// Whether assistive technology has asked for the access tree.
    pub assistive: Assistive,
}

impl SystemEnv {
    /// `self` laid over `base`: every field `self` knows wins, every field
    /// it left at "cannot tell" is `base`'s. The merge behind a launcher's
    /// pinned reading (`kui_native::Launcher::system`, Node's `runWindowed(..,
    /// {system})`, the context a C host hands `kui_run_with`): the app's
    /// partial over what the OS answered, applied every frame where the
    /// runner writes the real reading — so a pinned `motion` survives the
    /// write, and a real change to the accent still arrives, because that
    /// field was left unknown here and `base` is the OS's.
    ///
    /// Unknown *means* not pinned, which is why there is no separate
    /// override type: the four "cannot tell" readings are the defaults, so
    /// `SystemEnv { motion: MotionPref::Reduced, ..Default::default() }` is
    /// the whole of "as if this user asked for less motion". What it
    /// cannot say is "pin this to unknown" — a window on a platform that
    /// answers has no test that needs it.
    pub fn over(self, base: SystemEnv) -> SystemEnv {
        SystemEnv {
            appearance: match self.appearance {
                Appearance::Unknown => base.appearance,
                pinned => pinned,
            },
            accent: self.accent.or(base.accent),
            motion: match self.motion {
                MotionPref::Unknown => base.motion,
                pinned => pinned,
            },
            locale: self.locale.or(base.locale),
            assistive: match self.assistive {
                Assistive::Unknown => base.assistive,
                pinned => pinned,
            },
        }
    }
}

/// The OS light/dark setting. `Unknown` is a real answer — a host with no
/// way to ask says it, and a view that has one palette per appearance picks
/// its own default for it rather than being handed a guess.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Appearance {
    #[default]
    Unknown,
    Light,
    Dark,
}

impl Appearance {
    /// Wire order: the index is the code C passes and the position in
    /// `schema::APPEARANCES`, so `unknown` is 0 and a zeroed C host means
    /// what it says.
    pub const ALL: &'static [Appearance] =
        &[Appearance::Unknown, Appearance::Light, Appearance::Dark];

    pub fn name(self) -> &'static str {
        match self {
            Appearance::Unknown => "unknown",
            Appearance::Light => "light",
            Appearance::Dark => "dark",
        }
    }

    pub fn parse(name: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|a| a.name() == name)
    }

    /// The code a C host passes; `unknown` is 0.
    pub fn code(self) -> u32 {
        Self::ALL.iter().position(|a| *a == self).unwrap() as u32
    }

    /// A code past the end is `None` — ignored rather than folded onto a
    /// real appearance, the way an unknown role code is.
    pub fn from_code(code: u32) -> Option<Self> {
        Self::ALL.get(code as usize).copied()
    }
}

/// The OS reduce-motion setting: `Reduced` is "the user asked for less
/// animation", `Full` is "the user did not", `Unknown` is "nobody asked the
/// OS". Spelled as what the user wants rather than as a `reduce_motion`
/// boolean because the third reading has no place in a boolean, and a
/// missing answer is not the same as a "no".
///
/// `Pref` because `edit` already means cosmic-text's caret `Motion` by that
/// name, and a crate with two of them would be one letter of ambiguity in
/// every use. The field is `system.motion` and every binding spells it
/// `motion`; only Rust sees the longer type name.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MotionPref {
    #[default]
    Unknown,
    Full,
    Reduced,
}

impl MotionPref {
    /// Wire order, as [`Appearance::ALL`].
    pub const ALL: &'static [MotionPref] =
        &[MotionPref::Unknown, MotionPref::Full, MotionPref::Reduced];

    pub fn name(self) -> &'static str {
        match self {
            MotionPref::Unknown => "unknown",
            MotionPref::Full => "full",
            MotionPref::Reduced => "reduced",
        }
    }

    pub fn parse(name: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|m| m.name() == name)
    }

    /// The code a C host passes; `unknown` is 0.
    pub fn code(self) -> u32 {
        Self::ALL.iter().position(|m| *m == self).unwrap() as u32
    }

    pub fn from_code(code: u32) -> Option<Self> {
        Self::ALL.get(code as usize).copied()
    }

    /// Whether a view should skip or shorten decorative motion. `Unknown`
    /// answers `false`: a host that cannot tell gets the animations it
    /// would have had before this field existed.
    pub fn is_reduced(self) -> bool {
        self == MotionPref::Reduced
    }
}

/// Whether assistive technology is listening: the difference between an
/// alert that blinks and one that announces. `Listening` is "an
/// accessibility client has asked this window for its tree", which is the
/// one signal the platform adapters give and the moment the runner starts
/// deriving trees.
/// `None` is "the bridge is up and nobody has asked"; `Unknown` is "there
/// is no bridge" — a headless core, a driver built without the
/// `accesskit` feature, a C host that never called the setter.
///
/// Two limits are the reading's, not the row's. *Any* client counts: a
/// probe, an accessibility inspector, a test harness driving the AX API
/// and VoiceOver alike all ask for the tree, and nothing tells them apart.
/// And whether it ever falls back to `None` is the platform's: only the
/// AT-SPI adapter (Unix) reports deactivation, when the session's
/// accessibility bus goes away; on macOS and Windows the adapters never
/// call the deactivation handler, so once a client has asked the reading
/// stays `Listening` for the window's life.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Assistive {
    #[default]
    Unknown,
    None,
    Listening,
}

impl Assistive {
    /// Wire order, as [`Appearance::ALL`]: `unknown` is 0.
    pub const ALL: &'static [Assistive] =
        &[Assistive::Unknown, Assistive::None, Assistive::Listening];

    pub fn name(self) -> &'static str {
        match self {
            Assistive::Unknown => "unknown",
            Assistive::None => "none",
            Assistive::Listening => "listening",
        }
    }

    pub fn parse(name: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|a| a.name() == name)
    }

    /// The code a C host passes; `unknown` is 0.
    pub fn code(self) -> u32 {
        Self::ALL.iter().position(|a| *a == self).unwrap() as u32
    }

    pub fn from_code(code: u32) -> Option<Self> {
        Self::ALL.get(code as usize).copied()
    }

    /// Whether something is listening. `Unknown` answers `false`, as
    /// [`MotionPref::is_reduced`] does: a host that cannot tell gets the
    /// blink it would have had before this field existed.
    pub fn is_listening(self) -> bool {
        self == Assistive::Listening
    }
}

/// What the driver's audio output is doing, for views to read. A fact,
/// not a verb: nothing here lets a view close the device, which stays the
/// driver's decision (it closes an idle one itself, after a while).
///
/// Worth a row because an open output stream is a real-time thread that
/// runs whether or not anything plays — ~94 buffer callbacks a second at
/// the usual period — which is the whole of an idle app's CPU once a
/// session has held a sound. A view that shows `device` still `Open` ten
/// seconds after its last click is showing a bug that otherwise only
/// `top` can see. Headless drivers leave it at the default, which is the
/// truth for them: no device, nothing playing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AudioEnv {
    /// Whether the output device is open, and so costing something.
    pub device: AudioDevice,
    /// Playbacks started and not yet ended, plus the ones waiting on the
    /// device to open. A play that waits counts from the frame it was
    /// asked until the open answers; if the device refuses, the play is
    /// refused on the next apply and leaves the count with it (F63).
    pub live: u32,
}

/// The output device's state. `Closed` is the default and what a headless
/// driver reports; `Opening` is the ~90 ms the open takes on its own
/// thread; `Failed` is a device that refused to open, after which commands
/// are dropped.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AudioDevice {
    #[default]
    Closed,
    Opening,
    Open,
    Failed,
}

impl AudioDevice {
    /// Wire order, as [`Appearance::ALL`]: `closed` is 0, so a zeroed C
    /// call means what it says.
    pub const ALL: &'static [AudioDevice] = &[
        AudioDevice::Closed,
        AudioDevice::Opening,
        AudioDevice::Open,
        AudioDevice::Failed,
    ];

    pub fn name(self) -> &'static str {
        match self {
            AudioDevice::Closed => "closed",
            AudioDevice::Opening => "opening",
            AudioDevice::Open => "open",
            AudioDevice::Failed => "failed",
        }
    }

    pub fn parse(name: &str) -> Option<Self> {
        Self::ALL.iter().copied().find(|d| d.name() == name)
    }

    /// The code a C host passes; `closed` is 0.
    pub fn code(self) -> u32 {
        Self::ALL.iter().position(|d| *d == self).unwrap() as u32
    }

    pub fn from_code(code: u32) -> Option<Self> {
        Self::ALL.get(code as usize).copied()
    }
}

/// A language tag as the host reports it — `"en"`, `"en-US"`,
/// `"zh-Hans-CN"`. Carried inline rather than as a `String` so [`Env`]
/// stays `Copy`: a view reads `ui.env()` every frame, and a tag that
/// allocated would allocate on every one of them.
///
/// The core does not parse it. It is passed through to the view, which
/// hands it to whatever formats dates and numbers — kui has no opinion
/// about what is a language and what is a region.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Locale {
    /// ASCII, zero-padded past `len` so the derived `Eq` compares tags and
    /// not whatever was in the tail.
    bytes: [u8; Locale::CAP],
    len: u8,
}

impl Locale {
    /// Longest tag that fits. RFC 5646 allows longer in principle; 31
    /// holds every tag anyone ships, including the script-and-region ones
    /// (`zh-Hant-HK`) and a private-use suffix.
    pub const CAP: usize = 31;

    /// `None` for a tag that is empty, longer than [`Locale::CAP`], or not
    /// ASCII — the three things a well-formed language tag is not. A host
    /// that hands one of those over reads back "the host cannot tell",
    /// which is true: what it said was not a tag.
    pub fn new(tag: &str) -> Option<Self> {
        if tag.is_empty() || tag.len() > Self::CAP || !tag.is_ascii() {
            return None;
        }
        let mut bytes = [0u8; Self::CAP];
        bytes[..tag.len()].copy_from_slice(tag.as_bytes());
        Some(Self {
            bytes,
            len: tag.len() as u8,
        })
    }

    pub fn as_str(&self) -> &str {
        // ASCII by construction in `new`, the only constructor.
        std::str::from_utf8(&self.bytes[..self.len as usize]).unwrap_or("")
    }
}

impl std::fmt::Display for Locale {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

impl std::fmt::Debug for Locale {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // The tag, not 31 bytes of padding.
        write!(f, "Locale({:?})", self.as_str())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn budget_follows_reported_rate() {
        let env = Env {
            refresh_hz: Some(60.0),
            ..Default::default()
        };
        assert!((env.frame_budget_ms() - 16.666).abs() < 1e-2);
    }

    #[test]
    fn budget_falls_back_when_unreported_or_bogus() {
        assert!((Env::default().frame_budget_ms() - 8.333).abs() < 1e-2);
        let bogus = Env {
            refresh_hz: Some(0.0),
            ..Default::default()
        };
        assert!((bogus.frame_budget_ms() - 8.333).abs() < 1e-2);
    }

    /// Nobody asked the OS anything, and the reading says exactly that
    /// rather than "light, unreduced, English".
    #[test]
    fn the_system_facts_default_to_unknown() {
        let s = Env::default().system;
        assert_eq!(s.appearance, Appearance::Unknown);
        assert_eq!(s.accent, None);
        assert_eq!(s.motion, MotionPref::Unknown);
        assert_eq!(s.locale, None);
        assert_eq!(s.assistive, Assistive::Unknown);
        assert!(!s.motion.is_reduced(), "unknown is not a request to reduce");
        assert!(!s.assistive.is_listening(), "unknown is not a listener");
        // And a headless driver holds no device: closed, nothing live.
        assert_eq!(Env::default().audio, AudioEnv::default());
        assert_eq!(AudioEnv::default().device, AudioDevice::Closed);
    }

    /// The schema's name lists are the wire order: an index means the same
    /// setting in every binding, so the two cannot drift. Zero is
    /// `unknown` in both, which is what makes a zeroed C call honest.
    #[test]
    fn schema_names_are_all_in_order() {
        let appearances: Vec<&str> = Appearance::ALL.iter().map(|a| a.name()).collect();
        assert_eq!(appearances, crate::schema::APPEARANCES);
        let motions: Vec<&str> = MotionPref::ALL.iter().map(|m| m.name()).collect();
        assert_eq!(motions, crate::schema::MOTIONS);
        let devices: Vec<&str> = AudioDevice::ALL.iter().map(|d| d.name()).collect();
        assert_eq!(devices, crate::schema::AUDIO_DEVICES);
        let assistive: Vec<&str> = Assistive::ALL.iter().map(|a| a.name()).collect();
        assert_eq!(assistive, crate::schema::ASSISTIVE);
        let backdrops: Vec<&str> = crate::window::Backdrop::ALL
            .iter()
            .map(|b| b.name())
            .collect();
        assert_eq!(backdrops, crate::schema::BACKDROPS);
        for b in crate::window::Backdrop::ALL {
            assert_eq!(crate::window::Backdrop::from_name(b.name()), Some(b));
        }
        assert_eq!(
            crate::window::Backdrop::from_code(0),
            Some(crate::window::Backdrop::default())
        );
        assert_eq!(crate::window::Backdrop::from_code(4), None);
        assert_eq!(Appearance::default().code(), 0);
        assert_eq!(MotionPref::default().code(), 0);
        assert_eq!(AudioDevice::default().code(), 0);
        assert_eq!(Assistive::default().code(), 0);
    }

    #[test]
    fn codes_and_names_round_trip_and_reject_the_rest() {
        for a in Appearance::ALL {
            assert_eq!(Appearance::from_code(a.code()), Some(*a));
            assert_eq!(Appearance::parse(a.name()), Some(*a));
        }
        for m in MotionPref::ALL {
            assert_eq!(MotionPref::from_code(m.code()), Some(*m));
            assert_eq!(MotionPref::parse(m.name()), Some(*m));
        }
        for d in AudioDevice::ALL {
            assert_eq!(AudioDevice::from_code(d.code()), Some(*d));
            assert_eq!(AudioDevice::parse(d.name()), Some(*d));
        }
        for a in Assistive::ALL {
            assert_eq!(Assistive::from_code(a.code()), Some(*a));
            assert_eq!(Assistive::parse(a.name()), Some(*a));
        }
        assert_eq!(Appearance::from_code(3), None);
        assert_eq!(MotionPref::from_code(3), None);
        assert_eq!(AudioDevice::from_code(4), None);
        assert_eq!(Assistive::from_code(3), None);
        assert_eq!(Appearance::parse("Dark"), None, "spelling is exact");
    }

    /// A tag is carried whole and compares as a tag; the three things that
    /// are not a tag read back as "the host cannot tell".
    #[test]
    fn a_locale_is_the_tag_it_was_given() {
        let tag = Locale::new("en-US").unwrap();
        assert_eq!(tag.as_str(), "en-US");
        assert_eq!(tag.to_string(), "en-US");
        assert_eq!(Locale::new("zh-Hant-HK").unwrap().as_str(), "zh-Hant-HK");
        // Two tags of different lengths cannot compare equal through the
        // padding, and one built twice is the same tag.
        assert_eq!(Locale::new("en"), Locale::new("en"));
        assert_ne!(Locale::new("en"), Locale::new("en-US"));

        assert_eq!(Locale::new(""), None);
        assert_eq!(Locale::new(&"x".repeat(Locale::CAP + 1)), None);
        assert_eq!(Locale::new("ру-RU"), None, "a tag is ASCII");
        assert!(Locale::new(&"x".repeat(Locale::CAP)).is_some(), "CAP fits");
    }

    /// `Env` is `Copy` and small enough to read every frame — the reason
    /// a locale is an inline tag and not a `String`.
    #[test]
    fn env_is_copy() {
        fn takes_copy<T: Copy>(_: T) {}
        takes_copy(Env::default());
    }
}