abstracttui 0.3.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
//! PageHost: full complex pages behind a themed tab bar — the app-shell
//! page host (backlog 0545; the maintainer's "global tab system").
//!
//! `Tabs` (src/widgets/tabs.rs) stays the small in-content strip; a
//! PageHost is the HIGHER-LEVEL container: N pages addressed by id,
//! each a builder `FnMut(Scope) -> View` receiving a per-activation
//! GENERATION scope (`dyn_view_scoped`), a windowed tab bar with
//! badges/counts, container-reserved chords and opt-in digit jumps.
//! It stays inside the cycle-7 router ruling (compose.rs): navigation
//! state IS a signal — the host renders and mutates it, it never owns
//! routing (no history, no deep links).
//!
//! ## State ownership (THE recipe — no keep-alive, by design)
//!
//! Only the ACTIVE page is mounted. Switching disposes the outgoing
//! page's generation scope — its signals, effects, timers and focus
//! die with it — and builds the incoming page fresh. There is
//! deliberately NO keep-alive option: a hidden-but-mounted page keeps
//! its scope alive (its `interval`s tick, its sources ingest), which
//! violates the zero-idle law for invisible content. Durable page
//! state therefore lives in app-owned signals created OUTSIDE the
//! page builders (the compose.rs store pattern), and builders re-read
//! them on remount:
//!
//! ```ignore
//! let draft = cx.signal(String::new());     // survives switches
//! PageHost::new()
//!     .page("write", "Write", move |gcx| {
//!         // gcx dies on switch; `draft` does not.
//!         TextInput::new().value(draft).element(gcx, &t).build()
//!     })
//!     .view(cx)
//! # ;
//! ```
//!
//! ## Navigation contract
//!
//! - Click a tab (or the `‹`/`›` overflow indicators). Left/Right
//!   cycle (with wrap) while the BAR is focused.
//! - CHORDS — default Ctrl+PgUp / Ctrl+PgDn, replaced via
//!   [`PageHost::chords`] — are CONTAINER-RESERVED: intercepted at
//!   Capture phase on the host root, because scrollable widgets match
//!   PageUp/PageDown modifier-blind (scroll.rs/list.rs/table.rs) and
//!   would eat a bubble-layer chord. Plain PgUp/PgDn always stay with
//!   the content. Chords compare NORMALIZED (`KeyChord::normalized`),
//!   so both wire spellings of a shifted letter fire. Chords are live
//!   while focus is anywhere INSIDE the host. With NOTHING focused,
//!   keys target the tree root — a host mounted AS the root element
//!   answers chords from frame one; a host under a wrapper needs
//!   focus established first (click/Tab/`focus_first`, the main tree
//!   is not focus-initialized by the engine).
//! - DIGIT JUMPS 1-9 are OPT-IN ([`PageHost::number_jump`]) and ride
//!   the shortcut table (never capture): a focused TextInput keeps
//!   typing digits; apps own their number keys unless they opt in.
//! - FOCUS: a chord/digit switch re-anchors focus on the host root
//!   (programmatic focus needs no focusability — the focus_init
//!   pattern); the old page's focused node dies with its scope and
//!   an unanchored tree would send the NEXT chord to the tree root,
//!   off the host's path (the 0230 dead-keys class). Clicking keeps
//!   focus on the bar; bar arrows keep the bar focused.
//!
//! `on_change(id)` fires on HOST-driven switches after the active
//! write (disposal-safe, the 0297 law). External writes to a
//! controlled `active` signal switch pages without firing it.
//!
//! OWNER: TABS (wave 8).

use std::cell::{Cell, RefCell};
use std::rc::Rc;

use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{Scope, Signal};
use crate::theme::TokenSet;
use crate::ui::{
    dyn_view, dyn_view_scoped, Element, EventCtx, Key, KeyChord, Mods, MouseButton, MouseKind,
    Phase, UiEvent, View, ViewId,
};

#[path = "page_host_bar.rs"]
mod bar;

type PageBuilder = Box<dyn FnMut(Scope) -> View>;
type BadgeFn = Box<dyn Fn() -> Option<String>>;
type ChangeBox = Box<dyn FnMut(&str)>;
type ChangeFn = Rc<RefCell<Option<ChangeBox>>>;

struct PageDef {
    id: String,
    title: String,
    badge: Option<BadgeFn>,
    build: PageBuilder,
}

/// The page-level tab host: N full pages addressed by id behind one
/// themed, windowed tab bar with badges — the app-shell container.
///
/// Each page is a builder `FnMut(Scope) -> View` that runs on a fresh
/// generation scope per activation: only the active page is mounted,
/// and durable page state belongs in app-owned signals OUTSIDE the
/// builders (type into a form, switch away, come back — the draft
/// survives). Bind [`active`](PageHost::active) to own navigation;
/// Ctrl+PgUp/PgDn are container-reserved,
/// [`number_jump`](PageHost::number_jump) opts into digit jumps. For a
/// small in-content strip use [`Tabs`](crate::widgets::Tabs). The
/// canonical build is `.view(cx)`; see the
/// [module docs](crate::widgets::page_host).
pub struct PageHost {
    pages: Vec<PageDef>,
    active: Option<Signal<String>>,
    initial: Option<String>,
    on_change: Option<ChangeBox>,
    prev_chords: Vec<KeyChord>,
    next_chords: Vec<KeyChord>,
    number_jump: bool,
    layout: Option<LayoutStyle>,
}

/// Unknown/stale ids FOLD to the first page (documented): a controlled
/// signal may transiently hold an id the host does not know; rendering
/// something honest beats a panic in a draw path.
fn idx_of(ids: &[String], id: &str) -> usize {
    ids.iter().position(|p| p == id).unwrap_or(0)
}

impl PageHost {
    pub fn new() -> PageHost {
        PageHost {
            pages: Vec::new(),
            active: None,
            initial: None,
            on_change: None,
            prev_chords: vec![KeyChord::new(Mods::CTRL, Key::PageUp)],
            next_chords: vec![KeyChord::new(Mods::CTRL, Key::PageDown)],
            number_jump: false,
            layout: None,
        }
    }

    /// Add a page: a stable id, the tab title, and the page builder.
    /// The builder receives the GENERATION scope — state created on it
    /// dies when the page deactivates (see the module recipe).
    pub fn page(
        mut self,
        id: impl Into<String>,
        title: impl Into<String>,
        build: impl FnMut(Scope) -> View + 'static,
    ) -> PageHost {
        let id = id.into();
        debug_assert!(
            !self.pages.iter().any(|p| p.id == id),
            "PageHost: duplicate page id {id:?}"
        );
        self.pages.push(PageDef {
            id,
            title: title.into(),
            badge: None,
            build: Box::new(build),
        });
        self
    }

    /// Attach a reactive badge/count to a page's tab. The getter runs
    /// TRACKED inside the bar region: a change to the signals it reads
    /// repaints the BAR only (the page never remounts). `None` hides
    /// the badge.
    pub fn badge(mut self, id: &str, badge: impl Fn() -> Option<String> + 'static) -> PageHost {
        match self.pages.iter_mut().find(|p| p.id == id) {
            Some(p) => p.badge = Some(Box::new(badge)),
            None => debug_assert!(false, "PageHost::badge: unknown page id {id:?}"),
        }
        self
    }

    /// Controlled mode: the app OWNS the active-page signal (id-valued).
    /// External writes switch pages; `on_change` fires only for
    /// host-driven switches.
    pub fn active(mut self, active: Signal<String>) -> PageHost {
        self.active = Some(active);
        self
    }

    /// Uncontrolled mode's start page (ignored when `active` is given).
    pub fn initial(mut self, id: impl Into<String>) -> PageHost {
        self.initial = Some(id.into());
        self
    }

    /// Fires AFTER the active write on host-driven switches — the
    /// callback may dispose the host's scope (the 0297 law).
    pub fn on_change(mut self, f: impl FnMut(&str) + 'static) -> PageHost {
        self.on_change = Some(Box::new(f));
        self
    }

    /// Replace the prev/next chord sets (defaults: Ctrl+PgUp /
    /// Ctrl+PgDn — the wire every terminal delivers, `CSI 5;5~` /
    /// `CSI 6;5~`). Chords are container-reserved (module docs).
    pub fn chords(mut self, prev: &[KeyChord], next: &[KeyChord]) -> PageHost {
        self.prev_chords = prev.to_vec();
        self.next_chords = next.to_vec();
        self
    }

    /// Opt into plain-digit page jumps (1-9, first nine pages). OFF by
    /// default: apps own their number keys.
    pub fn number_jump(mut self, on: bool) -> PageHost {
        self.number_jump = on;
        self
    }

    pub fn layout(mut self, layout: LayoutStyle) -> PageHost {
        self.layout = Some(layout);
        self
    }

    /// Canonical one-call build: tokens resolve from the app's THEME
    /// CONTEXT inside the bar's own dyn region, so the bar retints on
    /// theme switch without remounting the active page.
    pub fn view(self, cx: Scope) -> View {
        self.assemble(cx, None).build()
    }

    /// Explicit-token build (tests, custom theming): the bar captures
    /// `t` — the caller owns retint policy (rebuild to retint).
    pub fn element(self, cx: Scope, t: &TokenSet) -> Element {
        self.assemble(cx, Some(*t))
    }

    fn assemble(self, cx: Scope, fixed: Option<TokenSet>) -> Element {
        let n = self.pages.len();
        let mut ids = Vec::with_capacity(n);
        let mut titles = Vec::with_capacity(n);
        let mut badges = Vec::with_capacity(n);
        let mut builders = Vec::with_capacity(n);
        for p in self.pages {
            ids.push(p.id);
            titles.push(p.title);
            badges.push(p.badge);
            builders.push(p.build);
        }
        let ids: Rc<Vec<String>> = Rc::new(ids);
        let titles: Rc<Vec<String>> = Rc::new(titles);
        let badges: Rc<Vec<Option<BadgeFn>>> = Rc::new(badges);
        let builders = Rc::new(RefCell::new(builders));

        // Active-page signal: controlled (app-owned) or uncontrolled.
        if let (Some(want), None) = (&self.initial, &self.active) {
            debug_assert!(
                ids.contains(want),
                "PageHost::initial: unknown page id {want:?}"
            );
        }
        let start = self
            .initial
            .filter(|want| ids.contains(want))
            .or_else(|| ids.first().cloned())
            .unwrap_or_default();
        let active = self.active.unwrap_or_else(|| cx.signal(start));
        let on_change: ChangeFn = Rc::new(RefCell::new(self.on_change));

        // Host-driven switch: write first, callback second (0297 — the
        // callback may dispose everything, including this host).
        let switch: Rc<dyn Fn(usize)> = {
            let ids = ids.clone();
            let on_change = on_change.clone();
            Rc::new(move |target: usize| {
                if ids.is_empty() {
                    return;
                }
                let target = target.min(ids.len() - 1);
                let cur = active.with_untracked(|id| idx_of(&ids, id));
                if cur == target {
                    return;
                }
                active.set(ids[target].clone());
                // Held borrow across `f`: safe — dispatch/shortcut-only
                // slot; external `active` writes deliberately never fire
                // it (the SharedCallback held-borrow contract).
                if let Some(f) = on_change.borrow_mut().as_mut() {
                    f(ids[target].as_str());
                }
            })
        };
        // Prev/next with WRAP (a cycling gesture — tmux precedent).
        let switch_rel: Rc<dyn Fn(i32)> = {
            let ids = ids.clone();
            let switch = switch.clone();
            Rc::new(move |dir: i32| {
                let n = ids.len();
                if n == 0 {
                    return;
                }
                let cur = active.with_untracked(|id| idx_of(&ids, id)) as i32;
                switch((cur + dir).rem_euclid(n as i32) as usize);
            })
        };

        // Shared bar state: the dyn build refreshes it (tracked reads);
        // the draw closure and the mouse handler both consume it through
        // the ONE pure plan (`bar::plan_bar`) — no mirrored arithmetic.
        let bar_state = Rc::new(RefCell::new(bar::BarModel {
            items: Vec::new(),
            active: 0,
        }));
        // Sticky window anchor — render bookkeeping, not reactive state.
        let window_first = Rc::new(Cell::new(0usize));
        // The plan AS DRAWN (plus the width it was planned for): the
        // mouse handler hit-tests against what the user actually SEES.
        // Recomputing from the live model raced same-batch model
        // changes — a badge widening between the last draw and a click
        // shifted the segments under the pointer, so the press landed
        // on the wrong tab (review2 F1, DRAWER cross-review; pinned by
        // wave_shell_review2::click_resolves_against_the_drawn_bar_*).
        let drawn_plan: Rc<RefCell<Option<(bar::BarPlan, i32)>>> = Rc::new(RefCell::new(None));

        let bar_handler = {
            let state = bar_state.clone();
            let first = window_first.clone();
            let drawn = drawn_plan.clone();
            let switch = switch.clone();
            let switch_rel = switch_rel.clone();
            move |ctx: &mut EventCtx, ev: &UiEvent| match ev {
                UiEvent::Key(k) => {
                    // Plain arrows only: modified arrows belong to the
                    // app (spatial nav chords etc.).
                    if k.mods != Mods::NONE {
                        return;
                    }
                    match k.key {
                        Key::Left => switch_rel(-1),
                        Key::Right => switch_rel(1),
                        _ => return,
                    }
                    ctx.stop_propagation();
                }
                UiEvent::Mouse(m) => {
                    if let MouseKind::Down(MouseButton::Left) = m.kind {
                        let rect = ctx.current_rect();
                        let hit = {
                            // Prefer the drawn plan (pixel truth); fall
                            // back to a fresh plan only before the first
                            // draw (nothing visible to aim at yet).
                            let stashed = drawn.borrow().clone();
                            let (plan, avail) = stashed.unwrap_or_else(|| {
                                let model = state.borrow();
                                (bar::plan_bar(&model, first.get(), rect.w), rect.w)
                            });
                            bar::hit_bar(&plan, avail, m.pos.x - rect.x)
                        };
                        match hit {
                            bar::BarHit::Prev => switch_rel(-1),
                            bar::BarHit::Next => switch_rel(1),
                            bar::BarHit::Tab(i) => switch(i),
                            bar::BarHit::Miss => return,
                        }
                        ctx.stop_propagation();
                    }
                }
                _ => {}
            }
        };

        let access_value = {
            let ids = ids.clone();
            let titles = titles.clone();
            let badges = badges.clone();
            move || {
                if titles.is_empty() {
                    return String::new();
                }
                let idx = active.with_untracked(|id| idx_of(&ids, id));
                let mut s = format!("{} ({}/{})", titles[idx], idx + 1, titles.len());
                if let Some(getter) = badges.get(idx).and_then(|g| g.as_ref()) {
                    // The snapshot samples untracked; badge getters read
                    // tracked signals, so shield them explicitly.
                    if let Some(b) = crate::reactive::untrack(getter) {
                        s.push_str(" [");
                        s.push_str(&b);
                        s.push(']');
                    }
                }
                s
            }
        };

        let bar_dyn = {
            let ids = ids.clone();
            let titles = titles.clone();
            let badges = badges.clone();
            let state = bar_state.clone();
            let first = window_first.clone();
            let drawn = drawn_plan.clone();
            dyn_view(
                LayoutStyle::default()
                    .width(Dimension::Percent(1.0))
                    .height(Dimension::Cells(2)),
                move || {
                    // Tracked: active id, every badge getter, and (view
                    // path) the theme context — a count or theme change
                    // repaints the BAR only; the page never remounts.
                    let t = fixed.unwrap_or_else(|| crate::widgets::theme_tokens(cx));
                    let active_idx = idx_of(&ids, &active.get());
                    let items: Vec<bar::BarItem> = titles
                        .iter()
                        .enumerate()
                        .map(|(i, title)| bar::BarItem {
                            title: title.clone(),
                            badge: badges[i].as_ref().and_then(|f| f()),
                        })
                        .collect();
                    *state.borrow_mut() = bar::BarModel {
                        items,
                        active: active_idx,
                    };
                    let ink = bar::ink_from(&t);
                    let state = state.clone();
                    let first = first.clone();
                    let drawn = drawn.clone();
                    Element::new()
                        .style(
                            LayoutStyle::default()
                                .width(Dimension::Percent(1.0))
                                .height(Dimension::Cells(2)),
                        )
                        .draw(move |canvas, rect| {
                            let m = state.borrow();
                            let plan = bar::plan_bar(&m, first.get(), rect.w);
                            first.set(plan.first);
                            // Publish pixel truth for the hit-test
                            // (review2 F1) — plain-cell bookkeeping,
                            // no reactive access (RT1-2 holds).
                            *drawn.borrow_mut() = Some((plan.clone(), rect.w));
                            bar::draw_bar(canvas, rect, &m, &plan, &ink);
                        })
                        .build()
                },
            )
        };

        // shrink 0: the bar is the widget's control surface — a tight
        // box crushes the PAGE, never the tabs (0240 #2).
        let bar_el = Element::new()
            .style(
                LayoutStyle::default()
                    .height(Dimension::Cells(2))
                    .shrink(0.0),
            )
            .role(crate::ui::Role::Tabs)
            .access_value(access_value)
            .focusable()
            .on(Phase::Bubble, bar_handler)
            .child(bar_dyn);

        // The page region: exactly the active builder mounts, on a
        // generation scope disposed at the next switch.
        let page_dyn = {
            let ids = ids.clone();
            dyn_view_scoped(
                LayoutStyle::default()
                    .width(Dimension::Percent(1.0))
                    .grow(1.0),
                move |gen_cx| {
                    let idx = idx_of(&ids, &active.get());
                    match builders.borrow_mut().get_mut(idx) {
                        Some(build) => build(gen_cx),
                        None => crate::ui::text(""),
                    }
                },
            )
        };

        // Focus anchor: a Capture-phase recorder notes the host's own
        // ViewId on every key routed through it. Registered BEFORE the
        // chord interceptor on the same node, so even the very first
        // chord can re-anchor (handlers run in registration order).
        let anchor: Rc<Cell<Option<ViewId>>> = Rc::new(Cell::new(None));
        let mut root = Element::new()
            .style(self.layout.unwrap_or_else(LayoutStyle::column))
            .on(Phase::Capture, {
                let anchor = anchor.clone();
                move |ctx: &mut EventCtx, ev: &UiEvent| {
                    if matches!(ev, UiEvent::Key(_)) {
                        anchor.set(ctx.current());
                    }
                }
            })
            .on(Phase::Capture, {
                let prev = self.prev_chords.clone();
                let next = self.next_chords.clone();
                let switch_rel = switch_rel.clone();
                let anchor = anchor.clone();
                move |ctx: &mut EventCtx, ev: &UiEvent| {
                    let UiEvent::Key(k) = ev else { return };
                    let chord = k.chord().normalized();
                    let dir = if next.iter().any(|c| c.normalized() == chord) {
                        1
                    } else if prev.iter().any(|c| c.normalized() == chord) {
                        -1
                    } else {
                        return;
                    };
                    switch_rel(dir);
                    if let Some(id) = anchor.get() {
                        ctx.request_focus(id);
                    }
                    ctx.stop_propagation();
                }
            });
        // Labeled twins in the shortcut table: keymap-help surfaces the
        // vocabulary, and the action stays correct even if the capture
        // interceptor ever stops consuming first.
        for c in &self.next_chords {
            let switch_rel = switch_rel.clone();
            let anchor = anchor.clone();
            root = root.shortcut_labeled(*c, "next page", move |ctx| {
                switch_rel(1);
                if let Some(id) = anchor.get() {
                    ctx.request_focus(id);
                }
            });
        }
        for c in &self.prev_chords {
            let switch_rel = switch_rel.clone();
            let anchor = anchor.clone();
            root = root.shortcut_labeled(*c, "previous page", move |ctx| {
                switch_rel(-1);
                if let Some(id) = anchor.get() {
                    ctx.request_focus(id);
                }
            });
        }
        if self.number_jump {
            for i in 0..n.min(9) {
                let digit = char::from_digit(i as u32 + 1, 10).expect("digits 1-9");
                let switch = switch.clone();
                let anchor = anchor.clone();
                root = root.shortcut_labeled(
                    KeyChord::plain(Key::Char(digit)),
                    format!("page {}: {}", i + 1, titles[i]),
                    move |ctx| {
                        switch(i);
                        if let Some(id) = anchor.get() {
                            ctx.request_focus(id);
                        }
                    },
                );
            }
        }
        root.child(bar_el.build()).child(page_dyn)
    }
}

impl Default for PageHost {
    fn default() -> Self {
        PageHost::new()
    }
}

#[cfg(test)]
#[path = "page_host_tests.rs"]
mod tests;