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
//! Mounting blueprints into live instances — including the `Dyn`
//! reactive-region lifecycle, the load-bearing piece of the whole bet.
//!
//! ## Lifecycle model (the part REDTEAM should attack)
//!
//! Mounting a `Dyn` creates ONE effect owned by the surrounding scope.
//! Each run of that effect: (1) disposes the previous run's child scope —
//! which runs cleanups that remove the previous instances and layout
//! nodes — (2) creates a fresh child scope, (3) evaluates the build
//! closure TRACKED (this subscribes the region to exactly the signals it
//! reads), (4) mounts the produced subtree untracked, (5) damages the
//! region and requests a frame. Unmounting is therefore never a special
//! case: disposing any ancestor scope cascades through effect disposal
//! and the registered cleanups; instance/layout bookkeeping cannot leak
//! as long as cleanups run — which the reactive layer guarantees even on
//! re-run (test-pinned there).

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

use crate::base::{Rect, Size};
use crate::reactive::{request_frame, untrack, Scope};

use super::tree::{Inst, InstPayload, TreeCore, ViewId};
use super::view::{View, ViewNode};

/// A two-slot measurement cache for ONE text leaf, keyed on available
/// width alone.
///
/// # Why this exists
///
/// Measuring a text leaf is the engine's dominant frame cost — 91-97% of
/// a solve at 400 rows — and the solver asks the same leaf the same
/// question several times per frame. Not twice: **`1 + the number of
/// `Auto`-sized ancestors above it**, because each one recurses through
/// `intrinsic_size` to find its own basis. Every wrapper `Element`
/// between a card and its text therefore re-measures the whole subtree
/// beneath it. `text::measure` costs ~2 µs for a 22-column list row and
/// ~6 µs at 48 columns, super-linearly, because `wrap` allocates a
/// `Vec<String>` and a `String` per line.
///
/// # Why the key is width ALONE, with no content in it
///
/// The obvious key is `(content, width)`, and content in it would be
/// pure cost. `TextView::content` is an owned `String` fixed at mount,
/// and reactive text does not mutate it — it goes through `ViewNode::Dyn`,
/// which disposes the node and mounts a fresh one with a fresh closure.
/// **Content is a constant of the node**, so a per-node cache has
/// already keyed on it by existing.
///
/// Width is normalised because `text::measure` folds every `avail.w <= 0`
/// into one unconstrained query; storing them separately would keep N
/// entries for one answer.
///
/// # Why TWO slots
///
/// One slot is 100% hit in a plain `column`, and only 33% in a `row`,
/// `grid`, `wrap` or non-`Stretch` shape, where a leaf legitimately sees
/// two widths per solve — its basis at the full content width, then the
/// width actually distributed to it. One slot thrashes between them and
/// gives back most of the win.
///
/// # Why this is NOT at the `node.measure` boundary
///
/// It would be a bug there. `MeasureFn`'s doc says callbacks must be
/// pure and two in this crate are not: `widgets::feed`'s callback
/// re-typesets shared state and calls `schedule_geometry_sync()`, which
/// is what keeps the public `FeedState::total_rows` signal honest —
/// caching it silently re-introduces the stale-extent bug that seam was
/// added to fix — and `widgets::markdown`'s stats and reads image files
/// on every call. A memo may only wrap a callback whose purity is a
/// property of THIS code, which is why it lives inside the one closure
/// this module owns.
pub(crate) struct WidthMemo {
    /// `(normalised width, answer)`. `i32::MIN` marks an empty slot: a
    /// normalised width is never negative, so no real query collides
    /// with it.
    slots: std::cell::Cell<[(i32, Size); 2]>,
    /// Which slot the next miss overwrites. Round-robin over two entries
    /// — an LRU bit would cost more to maintain than it can save here.
    next: std::cell::Cell<usize>,
}

impl WidthMemo {
    const EMPTY: i32 = i32::MIN;

    pub(crate) fn new() -> Self {
        WidthMemo {
            slots: std::cell::Cell::new([(Self::EMPTY, Size::ZERO); 2]),
            next: std::cell::Cell::new(0),
        }
    }

    /// Every `avail.w <= 0` is the same question to `text::measure`
    /// (they all become `UNBOUNDED_WIDTH`), so they are the same key.
    pub(crate) fn normalise(w: i32) -> i32 {
        if w <= 0 {
            0
        } else {
            w
        }
    }

    /// The answer for `width`, computing it with `compute` only on a
    /// miss.
    pub(crate) fn get_or(&self, width: i32, compute: impl FnOnce(i32) -> Size) -> Size {
        let key = Self::normalise(width);
        let slots = self.slots.get();
        for (k, v) in slots.iter() {
            if *k == key {
                return *v;
            }
        }
        let value = compute(width);
        let mut slots = slots;
        let victim = self.next.get();
        slots[victim] = (key, value);
        self.slots.set(slots);
        self.next.set((victim + 1) % slots.len());
        value
    }
}

/// Recursively mount a blueprint under `parent`. Borrows of the core are
/// short bursts — never held across child mounts or reactive calls.
pub(super) fn mount_view(
    core: &Rc<RefCell<TreeCore>>,
    cx: Scope,
    view: View,
    parent: Option<ViewId>,
) -> ViewId {
    match view.0 {
        ViewNode::Element(mut el) => {
            let (id, layout) = {
                let mut c = core.borrow_mut();
                let mut style = el.style.clone();
                if let Some(floor) = el.padding_floor {
                    apply_padding_floor(&mut style, floor);
                }
                // An element with an intrinsic-size callback mounts as a
                // measured leaf — the same door text nodes use — so draw
                // widgets (images, chart canvases) can answer `Auto`
                // sizing instead of defaulting to zero (RT8-6 class).
                let layout = match el.measure.take() {
                    Some(m) => c.layout.add_leaf(style, m),
                    None => c.layout.add(style),
                };
                let id = ViewId(c.insts.insert(Inst {
                    parent,
                    children: Vec::new(),
                    layout,
                    focusable: el.focusable,
                    focus_trap: el.focus_trap,
                    focus_memory: el.focus_memory,
                    probe_when_culled: el.probe_when_culled,
                    drag_zone: el.drag_zone.take(),
                    access: el.access.clone(),
                    payload: InstPayload::Element {
                        draw: el.draw.map(|d| Rc::new(RefCell::new(d))),
                        handlers: Rc::new(RefCell::new(el.handlers)),
                        shortcuts: Rc::new(RefCell::new(el.shortcuts)),
                    },
                }));
                if el.autofocus {
                    // Recorded now, consumed AFTER the mount completes
                    // (focus delivery runs handlers; the core borrow
                    // must be released first). Last-mounted wins.
                    c.pending_autofocus = Some(id);
                }
                attach(&mut c, parent, id);
                (id, layout)
            };
            // Rect readback (field-agora 0910): registered here rather
            // than ridden on the draw closure, because the child a
            // consumer's ensure-visible needs to locate is the one the
            // paint cull skips. The cleanup is what makes the "an
            // unmounting element never publishes" guarantee true for a
            // signal the CALLER owns and outlives this element.
            if let Some(sig) = el.rect_sig.take() {
                let sig = *sig;
                let alive = Rc::new(std::cell::Cell::new(true));
                {
                    let alive = alive.clone();
                    cx.on_cleanup(move || alive.set(false));
                }
                core.borrow_mut().rect_probes.push(super::tree::RectProbe {
                    view: id,
                    sig,
                    seen: Rc::new(std::cell::Cell::new(None)),
                    pending: Rc::new(std::cell::Cell::new(false)),
                    alive,
                });
            }
            // Reactive layout style: re-applied on signal change WITHOUT
            // remounting (scroll offsets, animated panes). The effect is
            // owned by the mounting scope, so it dies with the subtree;
            // a stale layout id after removal is a no-op (generational).
            //
            // Invalidation is INCREMENTAL: the re-solve anchor is the
            // nearest ancestor whose own size cannot be affected by this
            // node changing (climb past Auto-sized ancestors — an
            // Auto-sized parent inherits its children's size, so the
            // change bubbles through it; a Cells/Percent/grow-sized one
            // absorbs it inside its fixed box). resolve_subtree(anchor)
            // then recomputes every affected rect, including this node's
            // own (assigned by its parent's pass) and displaced siblings.
            if let Some(mut style_fn) = el.style_fn.take() {
                let core_for_style = core.clone();
                let floor = el.padding_floor;
                cx.effect(move || {
                    let mut style = style_fn(); // tracked
                    if let Some(f) = floor {
                        // The chrome floor survives reactive styles too.
                        apply_padding_floor(&mut style, f);
                    }
                    let mut c = core_for_style.borrow_mut();
                    if !c.layout.is_alive(layout) {
                        return;
                    }
                    c.layout.set_style(layout, style);
                    let anchor = resolve_anchor(&c.layout, layout);
                    c.dirty_subtrees.push(anchor);
                    drop(c);
                    request_frame();
                });
            }
            for child in el.children {
                mount_view(core, cx, child, Some(id));
            }
            id
        }
        ViewNode::Text(t) => {
            let mut c = core.borrow_mut();
            let content = t.content;
            let measured = content.clone();
            // Measurement through the engine's ONE width authority:
            // text::measure is wrap-aware (newlines + width-constrained
            // wrapping), so a multi-line leaf reports its true block
            // size instead of one enormous line (the RT3-4 repro's inner
            // content collapsed a sibling through exactly that).
            //
            // Memoized per leaf on width: `measured` is fixed for this
            // node's whole life (reactive text re-mounts through `Dyn`
            // rather than mutating), and the solver asks the same
            // question once per Auto-sized ancestor. See `WidthMemo`.
            let memo = WidthMemo::new();
            let layout = c.layout.add_leaf(
                t.style,
                Box::new(move |avail: Size| {
                    memo.get_or(avail.w, |w| {
                        crate::text::measure(&measured, Size::new(w, avail.h))
                    })
                }),
            );
            let id = ViewId(c.insts.insert(Inst {
                parent,
                children: Vec::new(),
                layout,
                focusable: false,
                focus_trap: false,
                focus_memory: false,
                probe_when_culled: false,
                drag_zone: None,
                access: Default::default(),
                payload: InstPayload::Text { content },
            }));
            attach(&mut c, parent, id);
            id
        }
        ViewNode::Dyn(d) => {
            let dyn_id = {
                let mut c = core.borrow_mut();
                let layout = c.layout.add(d.style.clone());
                let id = ViewId(c.insts.insert(Inst {
                    parent,
                    children: Vec::new(),
                    layout,
                    focusable: false,
                    focus_trap: false,
                    focus_memory: false,
                    probe_when_culled: false,
                    drag_zone: None,
                    access: Default::default(),
                    payload: InstPayload::Dyn,
                }));
                attach(&mut c, parent, id);
                id
            };
            let mut build = d.build;
            let core_for_effect = core.clone();
            // One scope per render generation, disposed before the next.
            let holder: Rc<RefCell<Option<Scope>>> = Rc::new(RefCell::new(None));
            cx.effect(move || {
                if let Some(prev) = holder.borrow_mut().take() {
                    // Runs the previous generation's cleanups: instances
                    // and layout nodes of the old subtree are removed.
                    prev.dispose();
                }
                // TRACKED: the signals read while building subscribe this
                // region — the fine-grained re-render unit. The build
                // receives the GENERATION scope (dyn_view_scoped): state
                // created on it dies at the next rebuild.
                let child_cx = cx.child();
                let view = build(child_cx);
                *holder.borrow_mut() = Some(child_cx);
                let core2 = core_for_effect.clone();
                // Mount UNTRACKED: bookkeeping must not add dependencies.
                let mounted = untrack(|| mount_view(&core2, child_cx, view, Some(dyn_id)));
                let core3 = core_for_effect.clone();
                child_cx.on_cleanup(move || remove_subtree(&core3, mounted));
                // A mounted subtree may carry an autofocus node (a
                // dialog's default field appearing via Dyn). The request
                // stays PARKED in `pending_autofocus` — focus delivery
                // runs user handlers (`focus_signal` writes), and firing
                // those inside this effect re-enters the running
                // computation through the flush: the 0220 "dependency
                // cycle" mount panic. Safe consume points, both outside
                // every computation: `UiTree::mount` right after the
                // initial mount returns, and `UiTree::layout` (frame
                // phase L) for regenerations — the `request_frame`
                // below guarantees that layout happens.
                {
                    let mut c = core_for_effect.borrow_mut();
                    // Old content's region is stale; a structure change
                    // may also move siblings, so re-solve and damage the
                    // region (whole-tree damage only on first mount when
                    // no rect is known yet).
                    let rect = c
                        .insts
                        .get(dyn_id.0)
                        .map(|inst| c.layout.rect(inst.layout))
                        .unwrap_or(Rect::ZERO);
                    if rect.is_empty() {
                        c.damage_all();
                    } else {
                        c.damage_rect(rect);
                    }
                    c.needs_layout = true;
                }
                request_frame();
            });
            dyn_id
        }
    }
}

/// Per-side maximum: the floor holds where the user style is smaller,
/// user padding beyond it wins (RT8-7 merge semantics).
fn apply_padding_floor(style: &mut crate::layout::Style, floor: crate::layout::Edges) {
    style.padding.left = style.padding.left.max(floor.left);
    style.padding.right = style.padding.right.max(floor.right);
    style.padding.top = style.padding.top.max(floor.top);
    style.padding.bottom = style.padding.bottom.max(floor.bottom);
}

/// Re-solve anchor for a style change on `node`: the nearest ancestor
/// whose OWN size the change cannot alter. The climb starts at the
/// parent unconditionally — the parent's pass assigns `node`'s rect, and
/// judging by the node's own style would be wrong in both directions
/// (the OLD style is already gone: an Auto→fixed flip changed what the
/// node fed into an Auto parent's sizing). From there, climb while the
/// ancestor itself is content-sized (Auto on either axis): its size is
/// derived from children, so the change propagates through it into ITS
/// parent's arithmetic. The first fully-sized ancestor absorbs the
/// change inside its fixed box.
fn resolve_anchor(
    layout: &crate::layout::LayoutTree,
    node: crate::layout::LayoutId,
) -> crate::layout::LayoutId {
    use crate::layout::Dimension;
    let content_sized = |id: crate::layout::LayoutId| {
        layout
            .style(id)
            .map(|s| matches!(s.width, Dimension::Auto) || matches!(s.height, Dimension::Auto))
            .unwrap_or(true)
    };
    let Some(mut cur) = layout.parent(node) else {
        return node;
    };
    while content_sized(cur) {
        match layout.parent(cur) {
            Some(p) => cur = p,
            None => break,
        }
    }
    cur
}

fn attach(core: &mut TreeCore, parent: Option<ViewId>, child: ViewId) {
    if let Some(p) = parent {
        if let Some(pinst) = core.insts.get_mut(p.0) {
            pinst.children.push(child);
        }
        if let (Some(pl), Some(cl)) = (
            core.insts.get(p.0).map(|i| i.layout),
            core.insts.get(child.0).map(|i| i.layout),
        ) {
            core.layout.add_child(pl, cl);
        }
    }
}

/// Remove a mounted subtree: instances, layout nodes, parent link, focus.
/// Registered as the Dyn generation cleanup; also safe on ids already
/// gone (generational arena shrugs at stale keys).
pub(super) fn remove_subtree(core: &Rc<RefCell<TreeCore>>, root: ViewId) {
    let mut c = core.borrow_mut();
    let Some(root_inst) = c.insts.get(root.0) else {
        return;
    };
    let root_layout = root_inst.layout;
    let parent = root_inst.parent;
    let root_rect = c.layout.rect(root_layout);
    // Detach from parent's child list.
    if let Some(p) = parent {
        if let Some(pinst) = c.insts.get_mut(p.0) {
            pinst.children.retain(|k| *k != root);
        }
    }
    // Layout subtree removal is recursive inside LayoutTree.
    c.layout.remove(root_layout);
    // Instance subtree removal, iterative.
    let mut stack = vec![root];
    while let Some(id) = stack.pop() {
        if let Some(inst) = c.insts.remove(id.0) {
            stack.extend(inst.children);
            if c.focus == Some(id) {
                // Focused node vanished with its subtree: drop focus
                // rather than pointing at a corpse. (Focus restoration
                // policy is a widgets-layer concern.)
                c.focus = None;
            }
            // A memory container dying takes its memory with it
            // (app-widgets 0155). Without this the map keeps one entry
            // per REBUILD for the life of the tree: a `Dyn` region
            // holding a `focus_memory` container re-mounts a fresh
            // container each generation, and the previous key is never
            // reachable again. Nothing was unsound — the arena is
            // generational, so a stale key simply never matches — which
            // is exactly why it grew unnoticed.
            //
            // The VALUE side is deliberately NOT swept. A remembered
            // descendant can die while its container lives, and
            // `restore_memory_target` already re-validates on read
            // (alive + still focusable + still inside the container),
            // so a stale value costs a fall-through and nothing else.
            // Sweeping it would mean a full-map scan per removed
            // instance to fix a set bounded by container count, and no
            // test could tell the sweep from its absence.
            c.focus_memory.remove(&id);
        }
    }
    c.damage_rect(root_rect);
    c.needs_layout = true;
}