abstracttui 0.6.0

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
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
//! Runtime theme registration (RT1-9a): user/app themes enter the registry
//! through the same contrast audit the built-in family passes in CI.
//!
//! ## Refuse vs label — both, caller's choice
//!
//! [`register`] always RUNS the full audit (contrast floors + role
//! hygiene + decisive ground). What happens to findings is the caller's
//! declared policy:
//!
//! - [`RegisterMode::Strict`] — findings refuse the registration
//!   ([`RegisterError::Rejected`] carries the structured violation list;
//!   nothing is stored). For apps that treat their theme file as code.
//! - [`RegisterMode::Labeled`] — the theme registers anyway and the
//!   returned [`Registration::warnings`] carries one `#FALLBACK:`-prefixed
//!   line per finding. For user-supplied themes where refusing would strand
//!   the user; callers surface the warnings, never swallow them.
//!
//! Identity problems (empty/malformed id, shadowing a built-in id or an
//! upstream alias) refuse in BOTH modes: a user theme silently replacing
//! `nord` is spoofing, not a preference.
//!
//! ## Why leak-to-'static
//!
//! The damage contract (`docs/design/01-damage-contract.md` §5) fixes the
//! app-level theme handle to `Signal<&'static Theme>`. Accepted
//! registrations are therefore `Box::leak`ed:
//!
//! - a handle captured by any view/signal can never dangle, even if the
//!   same id is re-registered later (the old allocation stays valid);
//! - the cost is bounded and small (~300 bytes per accepted registration:
//!   28 tokens + id/label strings); a live theme editor re-registering on
//!   every tweak leaks per *changed* candidate only — re-registering a
//!   byte-identical candidate returns the existing handle without leaking
//!   (dedup below);
//! - the alternative (`Arc<Theme>` payloads) would ripple a contract
//!   amendment through REACT/RENDER for a problem measured in kilobytes.
//!
//! Storage is a `RwLock<Vec<&'static Theme>>` in registration order;
//! lookups scan newest-first so re-registering an id replaces it for all
//! FUTURE lookups while old handles stay alive.
//!
//! OWNER: DESIGN.

use std::sync::RwLock;

use crate::render::color::PairIntent;
use crate::theme::contrast::{audit, Violation};
use crate::theme::registry::{hygiene_violations, themes, Theme};
use crate::theme::seeds::UPSTREAM_ALIASES;
use crate::theme::tokens::{TokenId, TokenSet};

/// A theme proposed for runtime registration. Owned strings: nothing leaks
/// unless the candidate is accepted.
#[derive(Clone, Debug)]
pub struct ThemeCandidate {
    /// Kebab-case machine id: `[a-z0-9]` and `-`/`_`, non-empty.
    pub id: String,
    /// Human label for pickers (empty label falls back to the id).
    pub label: String,
    /// Declared polarity; audited against the measured ground luminance.
    pub dark: bool,
    pub tokens: TokenSet,
    /// **Which of this theme's grounds read as one surface** when the
    /// 256-colour palette cannot hold them all apart — per pair, and
    /// additive. An EMPTY vec is SILENCE: every pair falls to
    /// elevation-wins and the theme renders exactly as it would without
    /// this field, so `vec![]` is the correct thing to write when you
    /// have no opinion. See [`Theme::ground_intent`].
    ///
    /// Both tokens of every pair must be opaque grounds
    /// ([`TokenSet::grounds`]); anything else is refused with
    /// [`RegisterError::NotAGround`] in BOTH modes. It is not an audit
    /// finding to be labelled and shipped: a declaration naming a
    /// non-ground protects nothing, and letting it through would leave
    /// the author believing otherwise.
    pub ground_intent: Vec<(TokenId, TokenId, PairIntent)>,
}

/// What to do when the audit finds violations.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RegisterMode {
    /// Violations refuse the registration (nothing stored).
    Strict,
    /// Violations register anyway and come back as `#FALLBACK:` warnings.
    Labeled,
}

/// A successful registration.
#[derive(Debug)]
pub struct Registration {
    pub theme: &'static Theme,
    /// Empty in `Strict` mode by construction; in `Labeled` mode, one
    /// `#FALLBACK:` line per audit finding. Callers surface these.
    pub warnings: Vec<String>,
}

#[derive(Debug)]
pub enum RegisterError {
    /// Id is empty or contains characters outside `[a-z0-9-_]`.
    InvalidId(String),
    /// Id collides with a built-in theme or an upstream alias — refused in
    /// both modes (shadowing `nord` is spoofing, not customization).
    ReservedId(String),
    /// Strict mode: the audit found violations. Structured (RT1-9 demand:
    /// the caller gets the list, not a boolean) plus role-hygiene findings.
    Rejected {
        violations: Vec<Violation>,
        hygiene: Vec<String>,
    },
    /// `ground_intent` names a token that is not an opaque ground.
    /// Refused in BOTH modes, unlike a contrast violation: a labelled
    /// registration still renders, and a declaration over a non-ground
    /// would silently protect nothing while the author believed it did.
    NotAGround(TokenId),
}

impl std::fmt::Display for RegisterError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RegisterError::InvalidId(id) => {
                write!(f, "invalid theme id '{id}': need non-empty [a-z0-9-_]")
            }
            RegisterError::ReservedId(id) => {
                write!(f, "theme id '{id}' is reserved by a built-in theme")
            }
            RegisterError::Rejected {
                violations,
                hygiene,
            } => {
                write!(
                    f,
                    "theme rejected: {} contrast violation(s), {} hygiene finding(s)",
                    violations.len(),
                    hygiene.len()
                )?;
                for v in violations {
                    write!(f, "\n  {v}")?;
                }
                for h in hygiene {
                    write!(f, "\n  {h}")?;
                }
                Ok(())
            }
            RegisterError::NotAGround(id) => write!(
                f,
                "ground_intent names '{}', which is not an opaque ground: \
                 declare pairs of bg, surface, surface_raised, \
                 selection_bg or shadow_ground",
                id.name()
            ),
        }
    }
}

// So `seed -> derive -> register` composes under `?` in a consumer's
// main(): `PaletteError` is a `std::error::Error`, and it would be odd for
// the second half of the same flow not to be.
impl std::error::Error for RegisterError {}

/// Registered user themes, registration order. Newest-first lookup gives
/// replace-on-re-register semantics without ever invalidating old handles.
static USER_THEMES: RwLock<Vec<&'static Theme>> = RwLock::new(Vec::new());

/// Read the user list, surviving a poisoned lock (a panicked registration
/// cannot corrupt an append-only Vec of shared refs — the data is still
/// consistent, so reads continue).
fn read_user() -> Vec<&'static Theme> {
    USER_THEMES
        .read()
        .unwrap_or_else(|e| e.into_inner())
        .clone()
}

/// Register a theme at runtime. Runs the full audit in every mode; see the
/// module docs for the refuse-vs-label contract.
pub fn register(
    candidate: ThemeCandidate,
    mode: RegisterMode,
) -> Result<Registration, RegisterError> {
    let id = candidate.id.trim();
    let id_ok = !id.is_empty()
        && id
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
    if !id_ok {
        return Err(RegisterError::InvalidId(candidate.id));
    }
    let reserved = themes().iter().any(|t| t.id == id)
        || UPSTREAM_ALIASES.iter().any(|(alias, _)| *alias == id);
    if reserved {
        return Err(RegisterError::ReservedId(id.to_string()));
    }

    // Before the audit, because this one is refused in both modes: a
    // declaration over a non-ground carries no protection, and there is
    // no useful thing to label it as.
    TokenSet::resolve_ground_intent(&candidate.ground_intent).map_err(RegisterError::NotAGround)?;

    // Audit the candidate BEFORE anything leaks: rejected strict
    // registrations must cost zero permanent memory.
    let label = if candidate.label.trim().is_empty() {
        id.to_string()
    } else {
        candidate.label.trim().to_string()
    };
    let probe = Theme {
        // Temporary borrow trick is not possible for &'static fields; audit
        // functions take &str/&Theme, so build the audit inputs directly.
        id: "",
        label: "",
        dark: candidate.dark,
        tokens: candidate.tokens,
        // `hygiene_violations` reads colors and polarity only. The
        // declaration IS checked, by `declaration_contradictions` below
        // — it takes the candidate's own `Vec` rather than riding this
        // probe, because putting a `&'static` declaration here would
        // mean leaking for a candidate that may be about to be refused.
        ground_intent: &[],
    };
    let violations = audit(id, &candidate.tokens);
    let mut hygiene = hygiene_probe(id, &probe);
    // A pair declared Distinct whose grounds are the same colour: the
    // author has said "these must read as different surfaces" about one
    // surface, and the bytes win. A hygiene finding rather than a hard
    // error, unlike `NotAGround` — the theme still renders sensibly and
    // the resolution is determinate, so `Labeled` mode surfacing it is
    // the right severity.
    hygiene.extend(crate::theme::contrast::declaration_contradictions(
        id,
        &candidate.tokens,
        &candidate.ground_intent,
    ));
    // The declared polarity must agree with the measured ground — a "dark"
    // theme on a white ground breaks downstream grouping (shadow strength,
    // image dithering, splash vignette).
    let measured_dark = candidate.tokens.bg.luminance() < 0.5;
    if measured_dark != candidate.dark {
        hygiene.push(format!(
            "[{}] declared {} but ground measures {}",
            id,
            if candidate.dark { "dark" } else { "light" },
            if measured_dark { "dark" } else { "light" }
        ));
    }

    let clean = violations.is_empty() && hygiene.is_empty();
    if !clean && mode == RegisterMode::Strict {
        return Err(RegisterError::Rejected {
            violations,
            hygiene,
        });
    }
    let warnings: Vec<String> = violations
        .iter()
        .map(|v| format!("#FALLBACK: registered theme {v}"))
        .chain(
            hygiene
                .iter()
                .map(|h| format!("#FALLBACK: registered theme hygiene {h}")),
        )
        .collect();

    // Dedup: a byte-identical re-registration returns the existing handle
    // (theme-editor loops leak only on change).
    if let Some(existing) = read_user().iter().rev().find(|t| t.id == id) {
        if existing.label == label
            && existing.dark == candidate.dark
            && existing.tokens == candidate.tokens
            // Intent is part of the theme's identity: two candidates
            // with the same colors and different declarations render
            // differently at 256, so returning the cached handle for the
            // second would silently serve the first one's intent.
            && existing.ground_intent == candidate.ground_intent
        {
            return Ok(Registration {
                theme: existing,
                warnings,
            });
        }
    }

    let theme: &'static Theme = Box::leak(Box::new(Theme {
        id: Box::leak(id.to_string().into_boxed_str()),
        label: Box::leak(label.into_boxed_str()),
        dark: candidate.dark,
        tokens: candidate.tokens,
        ground_intent: Box::leak(candidate.ground_intent.into_boxed_slice()),
    }));
    USER_THEMES
        .write()
        .unwrap_or_else(|e| e.into_inner())
        .push(theme);
    Ok(Registration { theme, warnings })
}

/// Hygiene findings for a candidate under its runtime id (the probe Theme
/// carries an empty id; substitute the real one into the messages).
fn hygiene_probe(id: &str, probe: &Theme) -> Vec<String> {
    hygiene_violations(probe)
        .into_iter()
        .map(|line| line.replacen("[]", &format!("[{id}]"), 1))
        .collect()
}

/// Newest matching user theme for `id`, if any.
pub fn user_get(id: &str) -> Option<&'static Theme> {
    let id = id.trim();
    read_user().into_iter().rev().find(|t| t.id == id)
}

/// All visible user themes, registration order, deduped to the newest
/// registration per id (pickers must not show stale replaced entries).
pub fn user_list() -> Vec<&'static Theme> {
    let all = read_user();
    let mut seen: Vec<&str> = Vec::new();
    let mut out: Vec<&'static Theme> = Vec::new();
    for t in all.iter().rev() {
        if !seen.contains(&t.id) {
            seen.push(t.id);
            out.push(t);
        }
    }
    out.reverse();
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::theme::registry::{default_theme, get, resolve};

    /// Unique ids per test: the registry is process-global and tests run
    /// concurrently in one binary.
    fn candidate(id: &str) -> ThemeCandidate {
        let base = default_theme();
        ThemeCandidate {
            id: id.to_string(),
            label: format!("Test {id}"),
            dark: base.dark,
            tokens: base.tokens,
            ground_intent: Vec::new(),
        }
    }

    #[test]
    fn strict_accepts_a_clean_theme_and_lookup_finds_it() {
        let reg = register(candidate("reg-clean"), RegisterMode::Strict).expect("clean");
        assert!(reg.warnings.is_empty());
        assert_eq!(reg.theme.id, "reg-clean");
        assert_eq!(user_get("reg-clean").unwrap().id, "reg-clean");
        // The unified lookup path sees user themes too.
        assert_eq!(get("reg-clean").unwrap().tokens, reg.theme.tokens);
        let (t, warn) = resolve("reg-clean");
        assert_eq!(t.id, "reg-clean");
        assert!(warn.is_none());
    }

    #[test]
    fn strict_refuses_a_sub_contrast_theme_and_stores_nothing() {
        let mut c = candidate("reg-bad-strict");
        c.tokens.text = c.tokens.bg; // unreadable by construction
        match register(c, RegisterMode::Strict) {
            Err(RegisterError::Rejected { violations, .. }) => {
                assert!(violations.iter().any(|v| v.rule == "text/bg"));
                assert!(violations.iter().all(|v| v.theme == "reg-bad-strict"));
            }
            other => panic!("expected Rejected, got {other:?}"),
        }
        assert!(
            user_get("reg-bad-strict").is_none(),
            "rejected must not store"
        );
    }

    #[test]
    fn labeled_registers_with_fallback_warnings() {
        let mut c = candidate("reg-bad-labeled");
        c.tokens.text_muted = c.tokens.bg;
        let reg = register(c, RegisterMode::Labeled).expect("labeled stores");
        assert!(!reg.warnings.is_empty());
        assert!(
            reg.warnings.iter().all(|w| w.starts_with("#FALLBACK")),
            "warnings must be labeled: {:?}",
            reg.warnings
        );
        assert!(user_get("reg-bad-labeled").is_some(), "labeled mode stores");
    }

    #[test]
    fn identity_problems_refuse_in_both_modes() {
        for mode in [RegisterMode::Strict, RegisterMode::Labeled] {
            assert!(matches!(
                register(candidate("nord"), mode),
                Err(RegisterError::ReservedId(_))
            ));
            // Upstream aliases are reserved too.
            assert!(matches!(
                register(candidate("dark"), mode),
                Err(RegisterError::ReservedId(_))
            ));
            assert!(matches!(
                register(candidate("Bad Id!"), mode),
                Err(RegisterError::InvalidId(_))
            ));
            assert!(matches!(
                register(candidate("  "), mode),
                Err(RegisterError::InvalidId(_))
            ));
        }
    }

    #[test]
    fn polarity_lie_is_caught() {
        let mut c = candidate("reg-polarity");
        c.dark = false; // claims light on the abstract-dark ground
        match register(c, RegisterMode::Strict) {
            Err(RegisterError::Rejected { hygiene, .. }) => {
                assert!(hygiene.iter().any(|h| h.contains("declared light")));
            }
            other => panic!("expected polarity rejection, got {other:?}"),
        }
    }

    #[test]
    fn re_register_replaces_lookup_and_dedups_identical() {
        let first = register(candidate("reg-replace"), RegisterMode::Strict).expect("v1");
        // Identical re-registration: same handle, no second entry.
        let again = register(candidate("reg-replace"), RegisterMode::Strict).expect("dedup");
        assert!(
            std::ptr::eq(first.theme, again.theme),
            "identical candidate dedups"
        );

        // Changed candidate: new handle wins lookups, old handle stays valid.
        let mut v2 = candidate("reg-replace");
        v2.label = "Test reg-replace v2".to_string();
        let second = register(v2, RegisterMode::Strict).expect("v2");
        assert!(!std::ptr::eq(first.theme, second.theme));
        assert_eq!(
            user_get("reg-replace").unwrap().label,
            "Test reg-replace v2"
        );
        assert_eq!(first.theme.id, "reg-replace", "old handle never dangles");
        // Pickers see exactly one entry for the id.
        let entries: Vec<_> = user_list()
            .into_iter()
            .filter(|t| t.id == "reg-replace")
            .collect();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].label, "Test reg-replace v2");
    }
}