abstracttui 0.2.13

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
//! COMPOSER wave: TextArea (backlog 0120) + the anchored passive-panel
//! completion dropdown (0500 slice), hardened through the REAL frame
//! loop — `Driver::turn` against `CaptureTerm`, wire bytes in (legacy,
//! kitty CSI-u, SGR mouse, bracketed paste), modeled VT screen out.
//!
//! Pins, by spec:
//! - submit vs newline chords on BOTH wires: plain Enter submits,
//!   legacy Alt+Enter (ESC CR) and kitty Shift+Enter (CSI 13;2u)
//!   insert; the buffer clears through the app's submit handler and
//!   history recall replays it (0120 §3/§4);
//! - grow-to-cap through the real layout loop (0120 §2);
//! - bracketed paste inserts newlines whole and never submits (§5);
//! - the completion dropdown opens ANCHORED at the caret (flipped
//!   above a bottom composer), navigates/accepts/dismisses via wire
//!   bytes, and closing repaints the vacated region from below;
//! - damage containment: with the panel open, a highlight move emits
//!   bytes bounded to the panel region — static chrome rows stay
//!   byte-identical (measured numbers printed);
//! - every emitted byte is modeled (`unknown_seq_count == 0`).

use std::cell::RefCell;
use std::rc::Rc;

use abstracttui::app::anchored::{Completion, CompletionCandidate};
use abstracttui::app::{App, Driver, RunConfig};
use abstracttui::base::Size;
use abstracttui::prelude::*;
use abstracttui::term::Capabilities;
use abstracttui::testing::CaptureTerm;
use abstracttui::ui::text;

const W: i32 = 44;
const H: i32 = 12;

fn config() -> RunConfig {
    RunConfig {
        // ADR-0003: `Capabilities` is `#[non_exhaustive]`; this file
        // compiles as a downstream crate, so construction goes
        // through `with`.
        caps: Some(Capabilities::with(|c| {
            c.truecolor = true;
            c.colors_256 = true;
        })),
        enter: None,
        probe: false,
    }
}

/// Drive turns until idle (bounded).
fn settle(driver: &mut Driver, app: &mut App, term: &mut CaptureTerm) {
    for _ in 0..64 {
        let turn = driver.turn(app, term).expect("turn");
        if turn.idle {
            return;
        }
    }
    panic!("loop failed to settle within 64 turns");
}

/// A transcript-shaped app: chrome line, content pane, bottom composer
/// with '/'+'@' completion, status line. Returns the composer state and
/// the submit log.
fn composer_app(app: &mut App) -> (TextAreaState, Rc<RefCell<Vec<String>>>) {
    let overlays = app.overlays();
    let submitted: Rc<RefCell<Vec<String>>> = Default::default();
    let s2 = submitted.clone();
    let holder: Rc<RefCell<Option<TextAreaState>>> = Default::default();
    let h2 = holder.clone();
    app.mount(move |cx| {
        let t = use_theme(cx).get().tokens;
        let state = TextAreaState::new(cx);
        *h2.borrow_mut() = Some(state.clone());
        let submit_state = state.clone();
        let composer = TextArea::new()
            .state(&state)
            .rows(1, 3)
            .placeholder("message")
            .on_submit(move |v| {
                s2.borrow_mut().push(v.to_string());
                submit_state.push_history(v);
                submit_state.clear();
            })
            .element(cx, &t)
            .autofocus()
            .build();
        let wrapped = Completion::new()
            .trigger('/', |q| {
                ["help", "theme", "clear", "quit"]
                    .iter()
                    .filter(|c| c.starts_with(q))
                    .map(|c| {
                        CompletionCandidate::new(format!("/{c}"), format!("/{c} ")).detail("cmd")
                    })
                    .collect()
            })
            .trigger('@', |q| {
                ["alice", "bob"]
                    .iter()
                    .filter(|c| c.starts_with(q))
                    .map(|c| CompletionCandidate::new(format!("@{c}"), format!("@{c} ")))
                    .collect()
            })
            .max_visible(4)
            .attach(cx, &overlays, &state, composer);
        Element::new()
            .style(LayoutStyle::column())
            .child(text("== transcript chrome =="))
            .child(
                Element::new()
                    .style(LayoutStyle::column().grow(1.0))
                    .child(text("pane row alpha"))
                    .child(text("pane row beta"))
                    .child(text("pane row gamma"))
                    .build(),
            )
            .child(wrapped)
            .child(text(" status: ready"))
            .build()
    })
    .expect("mount");
    let state = holder.borrow().clone().expect("state");
    (state, submitted)
}

fn screen_lines(term: &CaptureTerm) -> Vec<String> {
    term.screen()
        .to_text()
        .lines()
        .map(str::to_string)
        .collect()
}

#[test]
fn submit_vs_newline_chords_on_both_wires_and_history_recall() {
    let mut app = App::new(Size::new(W, H));
    let (state, submitted) = composer_app(&mut app);
    let mut term = CaptureTerm::new(Size::new(W, H));
    let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
    settle(&mut driver, &mut app, &mut term);

    term.push_input(b"hi");
    term.push_input(b"\x1b[13;2u"); // kitty Shift+Enter: newline
    term.push_input(b"there");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "hi\nthere");
    term.push_input(b"\x1b\r"); // legacy Alt+Enter: newline
    term.push_input(b"end");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "hi\nthere\nend");
    // Ctrl+J, the UNIVERSAL newline chord (0295): 0x0a IS Ctrl+J on the
    // legacy wire — works even where Shift+Enter cannot be reported.
    term.push_input(b"\n");
    term.push_input(b"tail");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "hi\nthere\nend\ntail");
    // Grow-to-cap: content rows at rows(1, 3); the composer's
    // frame-stroke rows are on screen along with the visible lines.
    let lines = screen_lines(&term);
    assert!(lines.iter().any(|l| l.contains("there")));
    assert!(lines.iter().any(|l| l.contains("end")));
    assert!(lines.iter().any(|l| l.contains("tail")));

    term.push_input(b"\r"); // plain Enter: submit
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(
        *submitted.borrow(),
        vec!["hi\nthere\nend\ntail".to_string()]
    );
    assert_eq!(state.text(), "", "submit handler cleared the buffer");

    // History recall through the wire: empty buffer, Up recalls.
    term.push_input(b"\x1b[A");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(
        state.text(),
        "hi\nthere\nend\ntail",
        "Up recalled the entry"
    );
    // Down at the end: forward past the newest restores the draft ("").
    term.push_input(b"\x1b[B");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "", "the (empty) draft returned");

    driver.finish(&mut term).expect("leave");
    assert_eq!(term.screen().unknown_seq_count(), 0, "all bytes modeled");
}

#[test]
fn bracketed_paste_inserts_multiline_and_never_submits() {
    let mut app = App::new(Size::new(W, H));
    let (state, submitted) = composer_app(&mut app);
    let mut term = CaptureTerm::new(Size::new(W, H));
    let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
    settle(&mut driver, &mut app, &mut term);

    term.push_input(b"\x1b[200~first line\r\nsecond line\x1b[201~");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "first line\nsecond line");
    assert!(submitted.borrow().is_empty(), "paste never submits");
    let lines = screen_lines(&term);
    assert!(lines.iter().any(|l| l.contains("first line")));
    assert!(lines.iter().any(|l| l.contains("second line")));
}

#[test]
fn completion_dropdown_full_round_trip_with_damage_containment() {
    let mut app = App::new(Size::new(W, H));
    let (state, submitted) = composer_app(&mut app);
    let mut term = CaptureTerm::new(Size::new(W, H));
    let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
    settle(&mut driver, &mut app, &mut term);
    let before_open = screen_lines(&term);
    assert!(
        before_open.iter().any(|l| l.contains("pane row beta")),
        "content pane visible before the dropdown"
    );

    // '/' opens the dropdown, anchored at the caret and flipped ABOVE
    // the bottom composer (no room below).
    term.push_input(b"/");
    settle(&mut driver, &mut app, &mut term);
    let open_bytes = term.take_bytes().len();
    let with_panel = screen_lines(&term);
    let help_row = with_panel
        .iter()
        .position(|l| l.contains("/help"))
        .expect("dropdown visible");
    let composer_row = with_panel
        .iter()
        .position(|l| l.contains('▐'))
        .expect("composer frame");
    assert!(help_row < composer_row, "panel sits above the composer");
    assert!(
        with_panel.iter().any(|l| l.contains("/quit")),
        "all four candidates offered: {with_panel:?}"
    );

    // Damage containment: a highlight move repaints the PANEL region
    // only — chrome/status/composer rows stay byte-identical, and the
    // emitted bytes stay far below a full-frame repaint.
    let panel_rows: Vec<usize> = with_panel
        .iter()
        .enumerate()
        .filter(|(_, l)| l.contains('/'))
        .map(|(i, _)| i)
        .collect();
    term.push_input(b"\x1b[B"); // Down: highlight row 1
    let turn = driver.turn(&mut app, &mut term).expect("turn");
    assert!(turn.rendered);
    settle(&mut driver, &mut app, &mut term);
    let nav_bytes = term.take_bytes();
    let after_nav = screen_lines(&term);
    for (i, (before, after)) in with_panel.iter().zip(&after_nav).enumerate() {
        if !panel_rows.contains(&i) {
            assert_eq!(before, after, "row {i} outside the panel changed");
        }
    }
    let nav_text = String::from_utf8_lossy(&nav_bytes);
    assert!(
        !nav_text.contains("chrome") && !nav_text.contains("status"),
        "static chrome must not re-emit: {nav_text:?}"
    );
    assert!(
        nav_bytes.len() < open_bytes,
        "highlight flip ({} bytes) cheaper than panel open ({} bytes)",
        nav_bytes.len(),
        open_bytes
    );
    println!(
        "measured: panel-open frame {} bytes; highlight-move frame {} bytes",
        open_bytes,
        nav_bytes.len()
    );

    // Enter accepts the highlighted candidate ("/theme": row 1).
    term.push_input(b"\r");
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "/theme ");
    assert!(submitted.borrow().is_empty(), "accept is not a submit");
    let after_accept = screen_lines(&term);
    assert!(
        !after_accept.iter().any(|l| l.contains("/help")),
        "dropdown closed"
    );
    assert!(
        after_accept.iter().any(|l| l.contains("pane row beta")),
        "vacated region repainted from below"
    );
    assert!(
        after_accept.iter().any(|l| l.contains("/theme")),
        "accepted text in the composer"
    );

    // Esc dismisses a reopened dropdown (kitty CSI-u escape byte form),
    // and typing inside the dismissed token stays calm.
    term.push_input(b"x"); // "/theme x" -> token "x…" no trigger, closed
    term.push_input(b" /q");
    settle(&mut driver, &mut app, &mut term);
    assert!(
        screen_lines(&term).iter().any(|l| l.contains("/quit")),
        "fresh trigger reopened"
    );
    term.push_input(b"\x1b[27u"); // kitty Escape
    settle(&mut driver, &mut app, &mut term);
    assert!(
        !screen_lines(&term).iter().any(|l| l.contains("/quit")),
        "Escape dismissed"
    );
    term.push_input(b"u");
    settle(&mut driver, &mut app, &mut term);
    assert!(
        !screen_lines(&term).iter().any(|l| l.contains("/quit")),
        "same token stays muted"
    );

    driver.finish(&mut term).expect("leave");
    assert_eq!(term.screen().unknown_seq_count(), 0, "all bytes modeled");
}

/// SGR mouse press+release at 0-based cell (x, y), as a terminal emits it.
fn sgr_click_bytes(x: i32, y: i32) -> (Vec<u8>, Vec<u8>) {
    (
        format!("\x1b[<0;{};{}M", x + 1, y + 1).into_bytes(),
        format!("\x1b[<0;{};{}m", x + 1, y + 1).into_bytes(),
    )
}

#[test]
fn mouse_click_accepts_a_candidate_through_the_wire() {
    let mut app = App::new(Size::new(W, H));
    let (state, _submitted) = composer_app(&mut app);
    let mut term = CaptureTerm::new(Size::new(W, H));
    let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
    settle(&mut driver, &mut app, &mut term);

    term.push_input(b"@");
    settle(&mut driver, &mut app, &mut term);
    let lines = screen_lines(&term);
    let bob_row = lines
        .iter()
        .position(|l| l.contains("@bob"))
        .expect("mention dropdown open") as i32;
    let bob_col = lines[bob_row as usize].find("@bob").unwrap() as i32;
    let (press, release) = sgr_click_bytes(bob_col, bob_row);
    term.push_input(&press);
    term.push_input(&release);
    settle(&mut driver, &mut app, &mut term);
    assert_eq!(state.text(), "@bob ", "click accepted the row");
    assert!(
        !screen_lines(&term).iter().any(|l| l.contains("@alice")),
        "dropdown closed after the click"
    );
}

/// first-app/0291 through the real frame loop: the field failure was
/// an `.autofocus()`ed composer whose placeholder NEVER painted one
/// pixel (focused from boot; the classic rule paints it only when
/// unfocused). With the opt-in, the VT screen must show the caret
/// block (cursor-token bg) at the composer's first text cell and the
/// hint beside it in `text_faint`; one typed character removes it.
#[test]
fn autofocused_composer_paints_placeholder_beside_caret_on_screen() {
    let size = Size::new(W, H);
    let mut app = App::new(size);
    app.mount(move |cx| {
        let t = use_theme(cx).get().tokens;
        let composer = TextArea::new()
            .placeholder("describe a task")
            .placeholder_while_focused(true)
            .rows(1, 3)
            .element(cx, &t)
            .autofocus()
            .build();
        Element::new()
            .style(LayoutStyle::column())
            .child(text("== chrome =="))
            .child(composer)
            .build()
    })
    .expect("mount");
    let mut term = CaptureTerm::new(size);
    let mut driver = Driver::new(&mut app, &mut term, config()).expect("driver");
    settle(&mut driver, &mut app, &mut term);

    let tokens = current_theme().tokens;
    let row = 1; // chrome line above; composer's single row below it
    let hint_row = screen_lines(&term)[row as usize].clone();
    assert!(
        hint_row.contains("describe a task"),
        "autofocused composer must paint its hint: {hint_row:?}"
    );
    // Caret block visible at the first text cell (x=1, past the
    // stroke), cursor-token bg; the hint starts one cell past it.
    let caret = term.screen().cell(1, row).expect("caret cell");
    assert_eq!(
        caret.paint.bg,
        Some(tokens.cursor),
        "caret bg is the cursor token"
    );
    let hint = term.screen().cell(2, row).expect("hint cell");
    assert_eq!(hint.ch(), 'd');
    assert_eq!(
        hint.paint.fg,
        Some(tokens.text_faint),
        "hint ink is text_faint"
    );

    // One typed character hides the hint; the glyph takes the cell.
    term.push_input(b"h");
    settle(&mut driver, &mut app, &mut term);
    let typed_row = screen_lines(&term)[row as usize].clone();
    assert!(
        !typed_row.contains("describe a task"),
        "typing hides the focused placeholder: {typed_row:?}"
    );
    assert_eq!(term.screen().cell(1, row).expect("cell").ch(), 'h');
    driver.finish(&mut term).expect("leave");
    assert_eq!(term.screen().unknown_seq_count(), 0, "all bytes modeled");
}