abstracttui 0.2.19

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
//! ChoicePrompt unit tests, wave-5 cycle-2 half (split file,
//! `#[path]`-included as `choice_prompt::tests::c2` — the file
//! budget): the REVIEWER findings folded in cycle 2 — per-option
//! shortcut letters (F2), must-choose mode (F3), layered Esc from the
//! Other editor (F4, position folded), danger tint (F7), the a11y
//! contract (F1), and the focus affordance (charter A5). Same rig as
//! the parent module.

use super::*;

fn approval(p: ChoicePrompt) -> ChoicePrompt {
    p.option_key("approve", "Approve", 'a')
        .option_key("all", "Approve all", 'A')
        .option_key("deny", "Deny", 'd')
        .danger("deny")
}

// ------------------------------------------------------------- letters

#[test]
fn option_letters_commit_single_and_are_case_sensitive() {
    // Wider rig: the full hint ("a/A/d pick · …") needs ~41 columns;
    // at the default rig width it degrades by design (segments drop
    // from the front — pinned in the narrow-width tests).
    let mut r = rig_sized(Size::new(56, 16));
    let (outcomes, _h) = open_on(&r, approval);
    let text = r.modal_text();
    assert!(text.contains("(a)"), "letter renders in the row: {text}");
    assert!(
        text.contains("a/A/d pick"),
        "hint names the letters (F6/K2): {text}"
    );

    // 'A' (uppercase) commits its OWN option — one resolution.
    r.key(Key::Char('A'));
    assert_eq!(
        outcomes.borrow().as_slice(),
        [ChoiceOutcome::Answered(ChoiceAnswer {
            selected: vec!["all".into()],
            other: None,
        })],
        "uppercase letter is its own key (case-sensitive)"
    );
    assert!(!r.is_open(), "letter commit closed the gate");

    // Lowercase 'a' on a fresh gate commits "approve".
    let (outcomes, _h) = open_on(&r, approval);
    r.key(Key::Char('a'));
    assert_eq!(
        outcomes.borrow().as_slice(),
        [ChoiceOutcome::Answered(ChoiceAnswer {
            selected: vec!["approve".into()],
            other: None,
        })]
    );
}

#[test]
fn option_letters_toggle_in_multiple_and_declared_key_beats_digit() {
    let mut r = rig();
    let (outcomes, _h) = open_on(&r, |p| approval(p).allow_multiple(true));
    r.key(Key::Char('a'));
    r.key(Key::Char('d'));
    let text = r.modal_text();
    assert!(
        text.contains("☑ Approve") && text.contains("☑ Deny"),
        "letters jump-toggle in multiple mode: {text}"
    );
    assert!(outcomes.borrow().is_empty(), "toggles never resolve");
    r.key(Key::Char('d')); // toggle back off
    assert!(r.modal_text().contains("☐ Deny"));
    r.key(Key::Enter);
    assert_eq!(
        outcomes.borrow().as_slice(),
        [ChoiceOutcome::Answered(ChoiceAnswer {
            selected: vec!["approve".into()],
            other: None,
        })]
    );

    // A declared DIGIT key wins over the digit-jump lane: '2' commits
    // its option in single mode instead of merely moving the highlight.
    let mut r = rig();
    let (outcomes, _h) = open_on(&r, |p| {
        p.option("a", "Alpha")
            .option_key("b", "Beta", '2')
            .option("c", "Gamma")
    });
    r.key(Key::Char('2'));
    assert_eq!(
        outcomes.borrow().as_slice(),
        [ChoiceOutcome::Answered(ChoiceAnswer {
            selected: vec!["b".into()],
            other: None,
        })],
        "declared keys outrank the movement-only digit jump"
    );
}

/// first-app 0288: the kitty wire spells Shift+A as `Char('a')` +
/// SHIFT (base identity kept lowercase). The matcher must fold that
/// spelling to the uppercase key — and never let it fire a
/// lowercase-declared key (Shift+A means 'A').
#[test]
fn kitty_shift_spelling_folds_to_the_uppercase_option_key() {
    let mut r = rig_sized(Size::new(56, 16));
    let (outcomes, _h) = open_on(&r, approval);
    r.send(&UiEvent::Key(KeyEvent::new(
        Key::Char('a'),
        crate::ui::Mods::SHIFT,
    )));
    assert_eq!(
        outcomes.borrow().as_slice(),
        [ChoiceOutcome::Answered(ChoiceAnswer {
            selected: vec!["all".into()],
            other: None,
        })],
        "Char('a')+SHIFT is the kitty spelling of the 'A' key (0288)"
    );

    // A gate declaring ONLY lowercase 'a': the shifted spelling means
    // 'A' — undeclared — and must not commit anything.
    let (outcomes, _h) = open_on(&r, |p| {
        p.option_key("approve", "Approve", 'a')
            .option("hold", "Hold")
    });
    r.send(&UiEvent::Key(KeyEvent::new(
        Key::Char('a'),
        crate::ui::Mods::SHIFT,
    )));
    assert!(
        outcomes.borrow().is_empty(),
        "Shift+letter must never fire a lowercase-declared key"
    );
    r.key(Key::Char('a')); // the declared key itself still commits
    assert_eq!(
        outcomes.borrow().as_slice(),
        [ChoiceOutcome::Answered(ChoiceAnswer {
            selected: vec!["approve".into()],
            other: None,
        })]
    );
}

#[test]
fn option_letters_type_into_a_focused_other_editor() {
    let mut r = rig();
    let (outcomes, _h) = open_on(&r, |p| approval(p).allow_other("Other…"));
    r.key(Key::End); // engage Other; the editor autofocuses
    r.type_str("ad"); // letters TYPE — the shield holds (F2 collision rule)
    assert!(outcomes.borrow().is_empty(), "no activation while editing");
    assert!(
        r.modal_text().contains("ad"),
        "letters landed in the draft: {}",
        r.modal_text()
    );
}

// ---------------------------------------------------------- must-choose

#[test]
fn non_dismissable_esc_refuses_visibly_and_clears_on_action() {
    let mut r = rig();
    let (outcomes, handle) = open_on(&r, |p| basic(p).dismissable(false));
    let text = r.modal_text();
    assert!(!text.contains("Cancel"), "no Cancel button: {text}");
    assert!(
        !text.contains("Esc cancels"),
        "no advertised Esc — a dead key would lie: {text}"
    );

    r.key(Key::Escape);
    assert!(outcomes.borrow().is_empty(), "Esc refused — no resolution");
    assert!(r.is_open(), "gate still open");
    assert!(
        r.modal_text().contains("an answer is required"),
        "refusal is VISIBLE (charter G3): {}",
        r.modal_text()
    );

    r.key(Key::Down); // acting clears the note
    assert!(
        !r.modal_text().contains("an answer is required"),
        "note clears once the user acts: {}",
        r.modal_text()
    );

    // Programmatic cancel stays available (timeout/deadline consumers).
    handle.cancel();
    flush_effects();
    assert_eq!(outcomes.borrow().as_slice(), [ChoiceOutcome::Cancelled]);
    assert!(!r.is_open());
}

// ---------------------------------------------------------- layered Esc

#[test]
fn esc_in_other_retreats_first_keeps_draft_then_second_esc_cancels() {
    let mut r = rig();
    let (outcomes, _h) = open_on(&r, |p| basic(p).allow_other("Other…"));
    r.key(Key::End); // engage Other; editor autofocused
    r.type_str("half an answer");
    assert!(
        r.modal_text().contains("Esc back to the list"),
        "hint tells the layered-Esc truth while editing: {}",
        r.modal_text()
    );

    // First Esc: retreat — the gate stays, the draft stays, the
    // highlight stays on Other (the editor merely blurred).
    r.key(Key::Escape);
    assert!(outcomes.borrow().is_empty(), "first Esc never cancels (F4)");
    assert!(r.is_open());
    let text = r.modal_text();
    assert!(text.contains("half an answer"), "draft survives: {text}");
    assert!(text.contains("● Other…"), "still the candidate: {text}");
    assert!(
        !text.contains("Esc back to the list"),
        "editing hint gone after retreat: {text}"
    );

    // Second Esc (focus back on the list): cancels the gate.
    r.key(Key::Escape);
    assert_eq!(outcomes.borrow().as_slice(), [ChoiceOutcome::Cancelled]);
    assert!(!r.is_open());
}

#[test]
fn esc_in_other_retreats_then_refuses_on_a_must_choose_gate() {
    let mut r = rig();
    let (outcomes, _h) = open_on(&r, |p| basic(p).allow_other("Other…").dismissable(false));
    r.key(Key::End);
    r.type_str("draft");
    r.key(Key::Escape); // retreat (layered Esc holds in both modes)
    assert!(outcomes.borrow().is_empty());
    assert!(r.is_open());
    r.key(Key::Escape); // second Esc: the must-choose refusal
    assert!(outcomes.borrow().is_empty(), "must-choose never cancels");
    assert!(
        r.modal_text().contains("an answer is required"),
        "refusal visible: {}",
        r.modal_text()
    );
}

// --------------------------------------------------------------- danger

#[test]
fn danger_option_wears_error_ink_except_under_the_selection_pair() {
    let t = crate::theme::default_theme().tokens;
    let mut r = rig();
    let (_outcomes, _h) = open_on(&r, |p| {
        p.option("keep", "Keep everything")
            .option("del", "Delete them")
            .danger("del")
    });
    // Unhighlighted danger row: Error ink on the modal ground.
    let (fg, bg) = r.ink_at("Delete them").expect("danger row");
    assert_eq!(fg, t.error, "danger label rides the Error token (T3)");
    assert_eq!(bg, t.overlay);
    // Highlighted with the list focused: the audited selection pair
    // outranks the tint (the pair is the audited combination).
    r.key(Key::Down);
    let (fg, bg) = r.ink_at("Delete them").expect("danger row");
    assert_eq!((fg, bg), (t.selection_fg, t.selection_bg));
}

// ----------------------------------------------------------------- a11y

#[test]
fn a11y_tree_names_question_options_and_selection_state() {
    let mut r = rig();
    let (_outcomes, _h) = open_on(&r, |p| basic(p).allow_other("Other…"));
    let snap = r.a11y();
    assert!(
        snap.contains("heading \"Proceed how?\""),
        "the question is in the tree (A1):\n{snap}"
    );
    assert!(
        snap.contains("menu \"options\" = \"Alpha\""),
        "region names the current choice (A2):\n{snap}"
    );
    assert!(
        snap.contains("menuitem \"Alpha\" = \"selected\""),
        "selected option carries its state (A2):\n{snap}"
    );
    assert!(
        snap.contains("menuitem \"Beta\"") && snap.contains("menuitem \"Gamma\""),
        "every option label is enumerable (A2/A3):\n{snap}"
    );
    assert!(
        !snap.contains("input"),
        "no phantom Other editor before engagement (O1/A4):\n{snap}"
    );

    // Selection state tracks movement; the revealed editor is an
    // Input and holds focus (A4).
    r.key(Key::Down);
    let snap = r.a11y();
    assert!(snap.contains("menuitem \"Beta\" = \"selected\""), "{snap}");
    r.key(Key::End);
    let snap = r.a11y();
    assert!(
        snap.contains("input") && snap.contains("[focused]"),
        "revealed editor is an Input with focus truth (A4):\n{snap}"
    );
}

/// first-app 0287: the body must not disturb the QUESTION/OPTIONS a11y
/// contract — every entry of the bare gate's tree survives verbatim
/// (labels, roles, selection state, focus), and the body adds only its
/// own honest display entries (a text row IS a text row to AT), never
/// an input or a second focus stop.
#[test]
fn a11y_unchanged_for_question_and_options_with_a_body() {
    // Strip geometry (`… @ x,y wxh`): the body legitimately moves rows
    // down; the CONTRACT is the semantic entries.
    fn semantic_lines(snap: &str) -> Vec<String> {
        snap.lines()
            .map(|l| match l.find(" @ ") {
                Some(cut) => l[..cut].to_string(),
                None => l.to_string(),
            })
            .collect()
    }

    let r = rig();
    let (_o, _h) = open_on(&r, basic);
    let bare = semantic_lines(&r.a11y());

    let r = rig();
    let (_o, _h) = open_on(&r, |p| {
        basic(p).body(|_| crate::ui::text("three tool calls, tier: safe"))
    });
    let snap = r.a11y();
    let with_body = semantic_lines(&snap);

    for line in &bare {
        assert!(
            with_body.contains(line),
            "bare-gate a11y entry {line:?} must survive a body:\n{snap}"
        );
    }
    for line in &with_body {
        assert!(
            bare.contains(line) || line.trim_start().starts_with("text "),
            "a body adds only display text entries, got {line:?}:\n{snap}"
        );
    }
    assert!(
        snap.contains("menu \"options\" = \"Alpha\" [focused]"),
        "focus stays anchored on the options with a body present:\n{snap}"
    );
    assert!(
        !snap.contains("input"),
        "no phantom editor from the body:\n{snap}"
    );
}

#[test]
fn a11y_multiple_mode_reports_checkbox_state() {
    let mut r = rig();
    let (_outcomes, _h) = open_on(&r, |p| basic(p).allow_multiple(true));
    let snap = r.a11y();
    assert!(
        snap.contains("checkbox \"Alpha\" = \"off\""),
        "unchecked state readable (A2):\n{snap}"
    );
    r.key(Key::Char(' '));
    let snap = r.a11y();
    assert!(
        snap.contains("checkbox \"Alpha\" = \"on\""),
        "checked state tracks toggles (A2):\n{snap}"
    );
}

// ------------------------------------------------------ focus affordance

#[test]
fn region_focus_affordance_visible_and_unfocused_highlight_distinct() {
    let t = crate::theme::default_theme().tokens;
    let mut r = rig();
    let (_outcomes, _h) = open_on(&r, basic);
    // At open the region holds focus: the highlight wears the audited
    // selection pair, and the engine's focus-visible check passes.
    let (fg, bg) = r.ink_at("Alpha").expect("row");
    assert_eq!((fg, bg), (t.selection_fg, t.selection_bg));
    let mut tree = r.modal_tree().expect("modal tree");
    assert!(
        crate::ui::focus_affordance_visible(&mut tree),
        "focused region must change pixels (charter A5)"
    );

    // Tab to the Cancel button: the highlight degrades to accent ink
    // (still distinct from plain rows — T2 holds unfocused), and the
    // focused BUTTON shows its own affordance.
    r.key(Key::Tab);
    let (fg, bg) = r.ink_at("Alpha").expect("row");
    assert_eq!(
        (fg, bg),
        (t.accent, t.overlay),
        "unfocused highlight is accent ink (the RadioGroup precedent)"
    );
    let (plain_fg, _) = r.ink_at("Beta").expect("plain row");
    assert_ne!(fg, plain_fg, "highlight still distinct unfocused (T2)");
    let mut tree = r.modal_tree().expect("modal tree");
    assert!(
        crate::ui::focus_affordance_visible(&mut tree),
        "focused button shows its affordance (charter A5)"
    );
}