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
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
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
//! Allocation budget tests (REDTEAM, doctrine §4): a counting global
//! allocator installed for THIS test binary only, verifying the vision
//! charter's "no heap allocation in the diff/present hot path at steady
//! state".
//!
//! The `unsafe impl GlobalAlloc` below is the one deliberate exception
//! to the no-unsafe rule outside term FFI: the trait cannot be
//! implemented without the keyword. It is confined to this TEST binary,
//! never the library, and does nothing but forward to `System` and bump
//! relaxed counters — auditable in ten lines.
//!
//! Budget tests run single-threaded within the measured region and
//! assert DELTAS. Counters are PER-THREAD (final-audit hardening): the
//! measured regions never spawn threads, so thread-local attribution is
//! exact — and libtest's OWN harness threads (result printing, test
//! spawning) allocate concurrently under default parallelism, which
//! polluted process-wide counters nondeterministically. Per-thread
//! counting makes the binary green under ANY `--test-threads` value
//! while still catching every real hot-path allocation (they happen on
//! the measuring thread by construction).
//! Run: `cargo test --test alloc_budget` (debug is fine — allocation
//! counts are optimization-independent facts, unlike timings).

use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;

use abstracttui::base::{Rgba, Size};
use abstracttui::render::{Cell as RenderCell, FrameDiff, PresentCaps, Presenter, Style, Surface};
use abstracttui::testing::VtScreen;

// ---------------------------------------------------------------------------
// The counting allocator (design: docs/design/testing.md §4)
// ---------------------------------------------------------------------------

// Const-initialized, no-drop thread locals: access never allocates and
// registers no TLS destructor, so bumping them inside the allocator is
// re-entrancy-safe. `try_with` guards the (theoretical) teardown window.
thread_local! {
    static TL_ALLOCS: Cell<u64> = const { Cell::new(0) };
    static TL_REALLOCS: Cell<u64> = const { Cell::new(0) };
    static TL_BYTES: Cell<u64> = const { Cell::new(0) };
}

struct CountingAlloc;

impl CountingAlloc {
    /// Snapshot the CALLING THREAD's counters.
    fn snapshot(&self) -> (u64, u64, u64) {
        (
            TL_ALLOCS.try_with(Cell::get).unwrap_or(0),
            TL_REALLOCS.try_with(Cell::get).unwrap_or(0),
            TL_BYTES.try_with(Cell::get).unwrap_or(0),
        )
    }
}

unsafe impl GlobalAlloc for CountingAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let _ = TL_ALLOCS.try_with(|c| c.set(c.get() + 1));
        let _ = TL_BYTES.try_with(|c| c.set(c.get() + layout.size() as u64));
        System.alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        System.dealloc(ptr, layout)
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        let _ = TL_REALLOCS.try_with(|c| c.set(c.get() + 1));
        let _ =
            TL_BYTES.try_with(|c| c.set(c.get() + new_size.saturating_sub(layout.size()) as u64));
        System.realloc(ptr, layout, new_size)
    }
}

#[global_allocator]
static ALLOC: CountingAlloc = CountingAlloc;

/// Counters are per-thread (see the module doc), so sibling tests can
/// no longer pollute a measured region — this lock is kept anyway so
/// measured regions run without CPU contention from siblings (doctrine
/// §4: measured regions run single-threaded), keeping the measurements
/// themselves quiet.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());

fn serial() -> std::sync::MutexGuard<'static, ()> {
    SERIAL.lock().unwrap_or_else(|poison| poison.into_inner())
}

/// Measure `f`'s allocation activity: (allocs, reallocs, bytes).
fn alloc_delta(f: impl FnOnce()) -> (u64, u64, u64) {
    let before = ALLOC.snapshot();
    f();
    let after = ALLOC.snapshot();
    (after.0 - before.0, after.1 - before.1, after.2 - before.2)
}

// ---------------------------------------------------------------------------
// Sanity: the counter counts.
// ---------------------------------------------------------------------------

#[test]
fn allocator_counts_and_measures_deltas() {
    let _serial = serial();
    let (a, _, bytes) = alloc_delta(|| {
        let v: Vec<u64> = Vec::with_capacity(100);
        std::hint::black_box(&v);
    });
    assert!(a >= 1, "one Vec allocation must be visible");
    assert!(bytes >= 800, "100 u64s = at least 800 bytes, saw {bytes}");
    let (a2, r2, _) = alloc_delta(|| {
        std::hint::black_box(42u64);
    });
    assert_eq!((a2, r2), (0, 0), "an empty region must measure zero");
}

// ---------------------------------------------------------------------------
// THE budget: diff + present steady state allocates nothing.
// ---------------------------------------------------------------------------

/// Build two full-screen frames that differ in every cell's fg color —
/// the animated-full-redraw shape from the charter budget.
fn styled_frame(size: Size, tick: u8) -> Surface {
    let mut s = Surface::new(size, RenderCell::default());
    for y in 0..size.h {
        let style = Style::new().fg(Rgba::rgb(tick, (y * 4) as u8, 255 - tick));
        // 20 chars x 10 columns of text per row.
        for chunk in 0..(size.w / 20).max(1) {
            s.draw_text(chunk * 20, y, "abcdefghij0123456789", style);
        }
    }
    s
}

/// FINDING RT2-1 (reviews/cycle2/redteam-findings.md): steady-state
/// diff+present measured 3,643 allocs/frame at first filing; RENDER's
/// same-cycle rework brought it to zero. This is now the permanent
/// acceptance test — a regression re-opens the finding.
///
/// It also carries the per-stage attribution print, which used to live on
/// a second test asserting `diff <= 8_000` and `present <= 2_000`. Those
/// were the pre-fix numbers, kept as a ratchet "until the real budget
/// lands". The real budget landed here, in this test, and nobody removed
/// the ratchet: measuring the SAME `measure_stages()`, it could not fail
/// unless this stricter assertion had already failed. Measured before
/// deleting rather than argued: both stages report 0, so the ratchet's
/// thresholds sat 8,000x and 2,000x above the value they guarded. The
/// print was the only part still doing work, so it moved here.
#[test]
fn diff_present_steady_state_allocates_nothing() {
    let _serial = serial();
    let (d, p) = measure_stages();
    eprintln!(
        "alloc attribution: diff = {} allocs/{} reallocs/{} B; present = {} allocs/{} reallocs/{} B",
        d.0, d.1, d.2, p.0, p.1, p.2
    );
    assert_eq!(
        (d.0, d.1),
        (0, 0),
        "steady-state DIFF allocated: {} allocs / {} reallocs / {} bytes",
        d.0,
        d.1,
        d.2
    );
    assert_eq!(
        (p.0, p.1),
        (0, 0),
        "steady-state PRESENT allocated: {} allocs / {} reallocs / {} bytes",
        p.0,
        p.1,
        p.2
    );
}

fn measure_stages() -> ((u64, u64, u64), (u64, u64, u64)) {
    let size = Size::new(200, 60);
    let caps = PresentCaps::FULL;
    let mut diff = FrameDiff::new();
    let mut presenter = Presenter::new();
    let mut out: Vec<u8> = Vec::new();

    let frames: Vec<Surface> = (0..6).map(|i| styled_frame(size, i * 40)).collect();

    // Warmup: two full cycles populate diff scratch and the byte buffer.
    for w in [0usize, 1, 2] {
        let runs = diff.compute_full(&frames[w], &frames[w + 1]);
        out.clear();
        presenter.emit(runs, &frames[w + 1], &caps, &mut out);
    }

    // Attribution: measure the stages separately on frame 3->4.
    let prev = &frames[3];
    let next = &frames[4];
    let mut d = (0, 0, 0);
    let mut runs_len = 0;
    let d1 = alloc_delta(|| {
        let runs = diff.compute_full(prev, next);
        runs_len = runs.len();
    });
    d.0 += d1.0;
    d.1 += d1.1;
    d.2 += d1.2;
    let runs = diff.compute_full(prev, next);
    out.clear();
    let p = alloc_delta(|| {
        presenter.emit(runs, next, &caps, &mut out);
    });
    assert!(runs_len > 0, "the measured frames really did change");
    (d, p)
}

/// RT2-8 (CLOSED cycle 3): no-change frames allocated ~16/row at filing;
/// RENDER's fix landed and this is now the permanent acceptance test —
/// identical frames cost zero allocations and zero bytes, forever.
#[test]
fn presenter_no_change_frame_emits_and_allocates_nothing() {
    let _serial = serial();
    let size = Size::new(80, 24);
    let frame = styled_frame(size, 7);
    let caps = PresentCaps::FULL;
    let mut diff = FrameDiff::new();
    let mut presenter = Presenter::new();
    let mut out: Vec<u8> = Vec::new();

    // Warm.
    let runs = diff.compute_full(&frame, &frame);
    presenter.emit(runs, &frame, &caps, &mut out);
    out.clear();

    let (allocs, reallocs, _) = alloc_delta(|| {
        let runs = diff.compute_full(&frame, &frame);
        presenter.emit(runs, &frame, &caps, &mut out);
    });
    assert_eq!((allocs, reallocs), (0, 0), "identical frames must be free");
    assert!(out.is_empty(), "identical frames must emit zero bytes");
}

// ---------------------------------------------------------------------------
// Companion guard: the VT model itself is cheap enough to referee with
// (its feed path may allocate for glyph strings — measured, bounded).
// ---------------------------------------------------------------------------

#[test]
fn vt_model_feed_allocation_is_bounded() {
    let _serial = serial();
    let mut screen = VtScreen::new(Size::new(200, 60));
    let mut frame_bytes = Vec::new();
    for y in 1..=60 {
        frame_bytes.extend_from_slice(format!("\x1b[{y};1H\x1b[38;2;1;2;3m").as_bytes());
        frame_bytes.extend_from_slice("x".repeat(200).as_bytes());
    }
    screen.feed(&frame_bytes); // warm the grid's cell strings
    let (allocs, _, bytes) = alloc_delta(|| {
        screen.feed(&frame_bytes);
    });
    // The model is allowed to allocate, but the budget has to be stated
    // in the unit the model actually allocates in. "2 per CELL" was the
    // original wording and it is 100x looser than the truth: a re-feed of
    // 12,000 cells measures 240 allocs — 4 per ROW, not per cell. At the
    // per-cell budget the very regression the comment feared, a String
    // per printed cell, would have passed with 50% to spare.
    //
    // So: per row, with 2x headroom over the measured 240. A regression
    // to per-cell allocation overshoots this by 25x.
    let cells = 200 * 60;
    let rows = 60;
    assert!(
        allocs <= 8 * rows,
        "VT model allocation blew up: {allocs} allocs / {bytes} bytes for \
         {cells} cells in {rows} rows (budget {} = 8/row)",
        8 * rows
    );
}

// ---------------------------------------------------------------------------
// JPEG decode: a hostile input must never trigger absurd allocation. The
// decoder's pixel guard is supposed to fire BEFORE any plane/bitmap Vec
// is sized from attacker-controlled dimensions. We assert the guard path
// allocates a bounded, tiny amount (the marker walk's small parses), not
// gigabytes for a claimed 65535x65535 image.
// ---------------------------------------------------------------------------

#[test]
fn jpeg_dimension_bomb_allocates_within_budget() {
    let _serial = serial();
    use abstracttui::gfx::jpeg;
    use abstracttui::testing::jpeg_build::FlatJpeg;

    // A valid flat JPEG, then patch its SOF dims to 65535x65535 — 4.29 G
    // pixels, ~17 GB of RGBA if the guard ever failed to fire.
    let mut bytes = FlatJpeg::grayscale(16, 16).build();
    let sof = bytes
        .windows(2)
        .position(|w| w[0] == 0xFF && (w[1] == 0xC0 || w[1] == 0xC1))
        .expect("SOF present");
    // SOF body: len(2) precision(1) h(2) w(2); patch h and w to 0xFFFF.
    for i in 0..4 {
        bytes[sof + 5 + i] = 0xFF;
    }

    let (allocs, _, alloc_bytes) = alloc_delta(|| {
        let r = jpeg::decode(&bytes);
        assert!(r.is_err(), "dimension bomb must be rejected");
        std::hint::black_box(&r);
    });
    // The rejection path parses a couple of small segments and formats an
    // error string; a few KB at most. Anything in the megabytes means the
    // guard fired AFTER a plane allocation.
    //
    // Measured: 56 bytes, against a 64 KiB budget — 1,170x slack, and
    // left alone deliberately. Unlike a performance ratchet this is a
    // SAFETY bound with only one failure mode: the guard firing after a
    // plane is sized from the claimed 65535x65535, which is ~17 GB. Any
    // bound between a few KB and a few MB catches that identically, so
    // tightening buys no detection and only invites a flake on a platform
    // whose error formatting allocates differently. Loose is not the same
    // as vacuous; the question is whether a REALISTIC regression fits in
    // the slack, and here none does.
    assert!(
        alloc_bytes < 64 * 1024,
        "dimension-bomb rejection allocated {alloc_bytes} bytes in {allocs} allocs — guard fired too late"
    );
}

#[test]
fn gltf_animation_sampling_is_allocation_free_per_frame() {
    let _serial = serial();
    use abstracttui::three::animation::{Animation, Interpolation, NodePose, Track, TrackValues};
    use abstracttui::three::Vec3;

    // A multi-track animation (translation + rotation + scale over 8
    // keys) driving 4 nodes — the per-frame work a playing model does.
    let mut tracks = Vec::new();
    for node in 0..4 {
        let times: Vec<f32> = (0..8).map(|k| k as f32 * 0.5).collect();
        tracks.push(Track {
            node,
            times: times.clone(),
            values: TrackValues::Translation(
                (0..8).map(|k| [k as f32, node as f32, 0.0]).collect(),
            ),
            interpolation: Interpolation::Linear,
        });
        tracks.push(Track {
            node,
            times: times.clone(),
            values: TrackValues::Rotation((0..8).map(|_| [0.0, 0.0, 0.0, 1.0]).collect()),
            interpolation: Interpolation::Linear,
        });
    }
    let anim = Animation::new(None, tracks);
    let rest = NodePose {
        translation: Vec3::ZERO,
        rotation: [0.0, 0.0, 0.0, 1.0],
        scale: Vec3::new(1.0, 1.0, 1.0),
    };
    let mut poses = vec![rest; 4]; // pre-grown output scratch

    // Warm (any lazy init happens here).
    anim.sample(1.0, &mut poses);

    // Steady state: sampling at arbitrary times must touch ZERO heap.
    let (allocs, reallocs, _) = alloc_delta(|| {
        for i in 0..240 {
            let t = (i as f32) * 0.01;
            anim.sample(t, &mut poses);
        }
    });
    assert_eq!(
        (allocs, reallocs),
        (0, 0),
        "animation sampling allocated on the hot path: {allocs} allocs, {reallocs} reallocs over 240 frames"
    );
}

// ---------------------------------------------------------------------------
// Idle honesty for the 0.2.x app surfaces: a mounted Feed (streaming
// item open), an ARMED interval (not yet due), a PARKED Select popup,
// and a PARKED byte-channel image (study-2 image review) — the
// always-mounted shapes of a modern transcript app — must cost literal
// zero on idle turns: zero bytes, zero allocations, zero reallocations
// on the UI thread. The byte half is pinned elsewhere (adv_app,
// wave_livedata, adv_selection, adv_image_lifecycle); this is the
// allocation half, re-verified on the CURRENT tree with the new
// widgets in play. The parked kitty placement pins that a terminal-held
// image costs nothing while nothing changes: `Driver::pre_image_pass`
// never runs on idle turns (no frame), and a rendered frame with a
// clean placement early-outs it allocation-free.
// ---------------------------------------------------------------------------

#[test]
fn idle_turns_with_feed_interval_parked_popup_and_parked_image_allocate_nothing() {
    use abstracttui::app::{App, Driver, RunConfig};
    use abstracttui::prelude::*;
    use abstracttui::reactive::interval;
    use abstracttui::testing::CaptureTerm;
    use abstracttui::ui::text;
    use abstracttui::widgets::{Feed, FeedItem, FeedState};
    use std::time::Duration;

    let _serial = serial();
    let size = Size::new(60, 16);
    let mut term = CaptureTerm::new(size);
    let mut app = App::new(size);
    let start = std::time::Instant::now();
    app.mount(|cx| {
        // A feed with history and an OPEN streaming item (live but quiet).
        let feed = FeedState::new(cx);
        for i in 0..8 {
            feed.push(
                format!("h{i}"),
                FeedItem::markdown(format!("**msg {i}** body")),
            );
        }
        feed.push_stream("live");
        feed.stream_append("live", "streaming answer paused mid-");
        // An armed interval: bounds the SLEEP, never the frames.
        let ticks = cx.signal(0u32);
        interval(cx, Duration::from_secs(3600), move || {
            ticks.update(|t| *t += 1);
        });
        let follow = cx.signal(true);
        Element::new()
            .style(LayoutStyle::column())
            .child(
                Select::new(vec![
                    SelectOption::new("stable"),
                    SelectOption::new("beta"),
                    SelectOption::new("nightly"),
                ])
                .layout(LayoutStyle::default().w(20).h(1).shrink(0.0))
                .view(cx),
            )
            .child(
                Element::new()
                    .style(LayoutStyle::column().grow(1.0))
                    .child(
                        Scroll::new(Feed::new(&feed).view(cx))
                            .follow_tail(follow)
                            .view(cx),
                    )
                    .build(),
            )
            .child(text(" status"))
            .build()
    })
    .expect("mount");
    let cfg = RunConfig {
        caps: Some(abstracttui::term::Capabilities::with(|c| {
            c.truecolor = true;
            c.colors_256 = true;
            // Byte channel for the parked image below: the placement
            // must live in TERMINAL state (kitty), not the cell model.
            c.kitty_graphics = true;
        })),
        enter: None,
        probe: false,
        ..RunConfig::default()
    };
    let mut driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
    // Injected clock, frozen: the interval stays armed-but-not-due for
    // every measured turn.
    let now = std::rc::Rc::new(std::cell::Cell::new(start));
    let clock = now.clone();
    driver.set_clock(move || clock.get());
    // Park a protocol image (top-right, off the popup): transmitted
    // once during setup, then held by the terminal.
    let overlays = app.overlays();
    let _img = overlays.image(
        Rect::new(44, 2, 12, 6),
        abstracttui::gfx::Bitmap::new(16, 12, Rgba::rgb(200, 40, 40)),
    );
    // Settle the mount, focus the trigger (Tab: first focusable), then
    // park the Select popup open (Enter) and settle again.
    for _ in 0..64 {
        if driver.turn(&mut app, &mut term).expect("turn").idle {
            break;
        }
    }
    term.push_input(b"\t\r");
    for _ in 0..64 {
        if driver.turn(&mut app, &mut term).expect("turn").idle {
            break;
        }
    }
    assert!(
        term.screen().to_text().contains("nightly"),
        "precondition: the popup is open and parked:\n{}",
        term.screen().to_text()
    );
    let setup_bytes = term.take_bytes();
    assert!(
        setup_bytes.windows(3).any(|w| w == b"\x1b_G"),
        "precondition: the image went through the kitty byte channel"
    );

    // 16 idle turns: not one byte, not one allocation.
    let (allocs, reallocs, bytes) = alloc_delta(|| {
        for _ in 0..16 {
            let turn = driver.turn(&mut app, &mut term).expect("idle turn");
            assert!(turn.idle, "turn must report idle");
            assert!(!turn.rendered, "idle turn rendered");
        }
    });
    assert_eq!(
        (allocs, reallocs),
        (0, 0),
        "idle turns allocated with the new mounts parked: \
         {allocs} allocs / {reallocs} reallocs / {bytes} B over 16 turns"
    );
    assert!(term.bytes().is_empty(), "idle turns wrote bytes");
}

// ---------------------------------------------------------------------------
// Wave-3 extension of the idle pin (INPUTAV): a PARKED METER (decayed to
// its fixpoint, frame task dropped), an AudioScope with a quiet window,
// an armed push-to-talk binding, and the ARMED key-state service — the
// always-mounted shapes of a voice app — must cost literal zero on idle
// turns. This is the allocation half of media-av/0620's required
// acceptance ("meter with unchanged input over N turns = zero frames
// requested, zero allocs"); the frames half is pinned in
// tests/wave_inputav.rs.
// ---------------------------------------------------------------------------

#[test]
fn idle_turns_with_parked_meter_scope_and_key_state_allocate_nothing() {
    use abstracttui::app::{use_key_state, App, Driver, PushToTalk, RunConfig};
    use abstracttui::prelude::*;
    use abstracttui::testing::CaptureTerm;
    use abstracttui::ui::text;
    use std::time::Duration;

    let _serial = serial();
    let size = Size::new(60, 12);
    let mut term = CaptureTerm::new(size);
    let mut app = App::new(size);
    let level_slot: std::rc::Rc<Cell<Option<abstracttui::reactive::Signal<f32>>>> =
        std::rc::Rc::new(Cell::new(None));
    let level_out = level_slot.clone();
    app.mount(move |cx| {
        let _keys = use_key_state(cx); // armed: the driver tap is live
        let _ptt = PushToTalk::bind(cx, KeyChord::plain(Key::Char(' ')));
        let level = cx.signal(0.0f32);
        level_out.set(Some(level));
        let window = cx.signal(vec![0.2f32, 0.6, 0.4, 0.1]);
        Element::new()
            .style(LayoutStyle::column())
            .child(
                Meter::new(level)
                    .decay(240.0)
                    .peak_hold(Duration::from_millis(50))
                    .view(cx),
            )
            .child(AudioScope::new(window).range(0.0, 1.0).view(cx))
            .child(text(" status"))
            .build()
    })
    .expect("mount");
    let cfg = RunConfig {
        caps: Some(abstracttui::term::Capabilities::with(|c| {
            c.truecolor = true;
            c.colors_256 = true;
            c.kitty_keyboard = true; // Full fidelity: the richest tap path
        })),
        enter: None,
        probe: false,
        ..RunConfig::default()
    };
    let mut driver = Driver::new(&mut app, &mut term, cfg).expect("driver");
    let start = std::time::Instant::now();
    let now = std::rc::Rc::new(Cell::new(start));
    let clock = now.clone();
    driver.set_clock(move || clock.get());

    // Exercise the full life first: a PTT hold cycle over kitty bytes,
    // a level burst, and the decay back to silence — then park.
    for _ in 0..64 {
        if driver.turn(&mut app, &mut term).expect("turn").idle {
            break;
        }
    }
    term.push_input(b"\x1b[32u"); // Space down (capture starts)
    driver.turn(&mut app, &mut term).expect("turn");
    let level = level_slot.get().expect("level signal");
    level.set(0.85);
    driver.turn(&mut app, &mut term).expect("turn");
    term.push_input(b"\x1b[32;1:3u"); // Space up (capture stops)
    driver.turn(&mut app, &mut term).expect("turn");
    level.set(0.0); // silence: the meter must now DECAY to its fixpoint
    let mut parked = false;
    for _ in 0..240 {
        now.set(now.get() + Duration::from_millis(16));
        if driver.turn(&mut app, &mut term).expect("turn").idle {
            parked = true;
            break;
        }
    }
    assert!(parked, "precondition: the meter decayed to its fixpoint");
    let _ = term.take_bytes();

    // 16 idle turns: not one byte, not one allocation — with the key
    // tap armed, a PTT bound, a parked meter and a quiet scope mounted.
    let (allocs, reallocs, bytes) = alloc_delta(|| {
        for _ in 0..16 {
            now.set(now.get() + Duration::from_millis(16));
            let turn = driver.turn(&mut app, &mut term).expect("idle turn");
            assert!(turn.idle, "turn must report idle");
            assert!(!turn.rendered, "idle turn rendered");
        }
    });
    assert_eq!(
        (allocs, reallocs),
        (0, 0),
        "idle turns allocated with the voice surfaces parked: \
         {allocs} allocs / {reallocs} reallocs / {bytes} B over 16 turns"
    );
    assert!(term.bytes().is_empty(), "idle turns wrote bytes");
}

// ---------------------------------------------------------------------------
// Canvas layer (extensions 0420): the stroke + blit steady state is
// allocation-free. A DotCanvas allocates ONCE at construction; every
// primitive (Bresenham, adaptive bezier flattening, param-stepped
// arcs, fills) works in-place or on the stack, and `clear_all()`
// keeps the allocation — so a per-frame redraw of a custom trace
// costs zero heap traffic, per the vision charter.
// ---------------------------------------------------------------------------

#[test]
fn dot_canvas_stroke_and_blit_paths_allocate_nothing() {
    use abstracttui::base::{Point, Rect};
    use abstracttui::canvas::{fill_h, fill_v, DotCanvas};
    use abstracttui::ui::BufferCanvas;

    let _serial = serial();
    let mut dots = DotCanvas::braille(40, 12); // 80x48 dots, allocated here
    let mut out = BufferCanvas::new(Size::new(40, 12));
    let ink = Rgba::rgb(240, 170, 40);

    let (allocs, reallocs, bytes) = alloc_delta(|| {
        for frame in 0..8 {
            dots.clear_all(); // reuse, never realloc
            dots.line((0, frame), (79, 47 - frame));
            dots.polyline(&[(0, 40), (20, 8), (40, 30), (79, 2)]);
            dots.bezier_quad((0.0, 47.0), (40.0, -20.0), (79.0, 47.0), 0.25);
            dots.bezier_cubic((0.0, 2.0), (30.0, 60.0), (50.0, -12.0), (79.0, 40.0), 0.25);
            dots.ellipse_arc((40.0, 24.0), 30.0, 18.0, 0.0, std::f32::consts::TAU);
            // Far off-grid segment: pre-clipped, bounded, still free.
            dots.line((-1_000_000, 24), (1_000_000, 24));
            dots.blit(&mut out, Point::new(0, 0), ink);
            fill_v(
                &mut out,
                Rect::new(0, 0, 4, 12),
                0.6,
                ink,
                Rgba::TRANSPARENT,
            );
            fill_h(
                &mut out,
                Rect::new(0, 11, 40, 1),
                0.4,
                ink,
                Rgba::TRANSPARENT,
            );
        }
        std::hint::black_box(&out);
    });
    assert_eq!(
        (allocs, reallocs),
        (0, 0),
        "the stroke/blit steady state allocated: \
         {allocs} allocs / {reallocs} reallocs / {bytes} B over 8 frames"
    );
}

#[test]
fn jpeg_hostile_corpus_allocation_is_bounded() {
    let _serial = serial();
    use abstracttui::gfx::jpeg;
    use abstracttui::testing::jpeg_build::FlatJpeg;

    // A batch of small-but-pathological inputs (deep trees, dangling
    // refs, truncations). None declares large dimensions, so total
    // allocation across the batch must stay modest — no hidden
    // amplification from a mutated count or table.
    let base = FlatJpeg::grayscale(16, 16).with_flat_code_len(16).build();
    let (allocs, _, alloc_bytes) = alloc_delta(|| {
        for cut in (2..base.len()).step_by(3) {
            let _ = std::hint::black_box(jpeg::decode(&base[..cut]));
        }
    });
    // ~ base.len()/3 decode attempts, each parsing a 16x16 frame at most.
    // The budget was 128 KiB per attempt against a measured 20,394 bytes
    // for all 52 attempts together — 334x slack, enough for a tenfold
    // amplification bug to pass as healthy. 4 KiB per attempt keeps ~10x
    // headroom over the measured total and still fails long before any
    // allocation sized from a mutated count or table could matter.
    let attempts = (base.len() - 2).div_ceil(3);
    assert!(
        alloc_bytes < attempts as u64 * 4 * 1024,
        "hostile-corpus decode allocated {alloc_bytes} bytes over {attempts} attempts ({allocs} allocs)"
    );
}