abstracttui 0.2.1

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
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
//! The frame-loop driver: one `turn` = one pass of the damage contract's
//! phase sequence (docs/design/01-damage-contract.md §1):
//!
//! ```text
//! U. drain posted jobs -> dispatch input (each event batch-wrapped)
//!    -> effects flush (Dyn remounts happen here, marking damage)
//! L. re-solve dirty layout; geometry damage folds into the ui damage set
//! D. clear + redraw ONLY damaged regions into the root layer (draw-phase
//!    guard active: tracked reads panic in debug)
//! C. Compositor::flatten(layers) -> frame + damage union
//! P. diff(prev, next, damage) -> presenter bytes -> ONE flush
//! S. prev <- next; damage bookkeeping cleared
//! ```
//!
//! The frame's damage set is sealed at L (epoch rule §2): user code runs
//! only in U, cross-thread writes arrive only as posted jobs, and posted
//! jobs run only in U — a write landing mid-frame wakes the loop and is
//! drained by the NEXT frame's U. This is structural, not disciplinary.
//!
//! `Driver` is deliberately separable from the blocking outer loop:
//! `turn` never blocks (tests drive it frame by frame against a scripted
//! terminal and inspect bytes between turns); `wait_for_activity` is the
//! blocking edge only the real `App::run` uses.

use std::rc::Rc;

use crate::base::{Point, Rect, Result, Size};
use crate::gfx::ImageSession;
use crate::input::{Event, EventReader};
use crate::reactive::{
    self, drain_posted, flush_effects, take_frame_request, take_worker_failures,
};
use crate::render::{Cell, Compositor, FrameDiff, Glyph, PresentCaps, Presenter, Surface};
use crate::term::{ActiveProbe, Capabilities, EnterOptions, KittyFlags, Terminal, TerminalWaker};
use crate::theme::TokenId;
use crate::ui::SurfaceCanvas;

use super::events::{convert_event, is_default_quit};
use super::overlays::{Overlays, ROOT_LAYER_ID};
use super::selection::{selection_pane, MouseCapture, Selection, SelectionAct};
use super::theme::current_theme;
use super::App;

/// How a `run` session is configured. `Default` is the interactive
/// posture: env-detected capabilities, capability-derived enter options,
/// active probe on.
pub struct RunConfig {
    /// Capabilities to assume. `None` = passive env detection at start
    /// (tests inject a fixed set so host env never leaks into assertions).
    pub caps: Option<Capabilities>,
    /// Session options. `None` = derived from capabilities (kitty
    /// keyboard flags requested only when the terminal speaks them).
    pub enter: Option<EnterOptions>,
    /// Write the active capability probe at startup and fold replies as
    /// they arrive (RT1-6: first paint NEVER waits for this — env-pass
    /// caps draw frame 1, probe results upgrade later frames).
    pub probe: bool,
}

impl Default for RunConfig {
    fn default() -> Self {
        RunConfig {
            caps: None,
            enter: None,
            probe: true,
        }
    }
}

/// What one non-blocking `turn` did — the outer loop's steering data.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct Turn {
    /// Input events dispatched during phase U.
    pub events: usize,
    /// A frame was rendered (phases L..S ran).
    pub rendered: bool,
    /// The rendered frame actually emitted bytes (idle frames do not).
    pub emitted: bool,
    /// The app asked to quit (explicit `Quitter` or default Ctrl+C).
    pub quit: bool,
    /// Nothing happened and nothing is pending: the loop may block.
    pub idle: bool,
}

/// `base::FrameRequester` that interrupts the terminal's blocking read,
/// so a frame requested from a posted job (timer thread) wakes the loop.
struct WakeOnFrame(Option<TerminalWaker>);

impl crate::base::FrameRequester for WakeOnFrame {
    fn request_frame(&self) {
        if let Some(w) = &self.0 {
            w.wake();
        }
    }
}

pub struct Driver {
    reader: EventReader,
    pub(super) caps: Capabilities,
    present_caps: PresentCaps,
    probe: Option<ActiveProbe>,
    comp: Compositor,
    diff: FrameDiff,
    presenter: Presenter,
    /// All compositor layers (root at id 0 + app overlays) live in the
    /// shared overlay store; the driver borrows them per phase.
    pub(super) overlays: Overlays,
    /// Terminal-held image state (RT4-1): one session per terminal;
    /// slot keys are `ImageEntry` ids.
    pub(super) image_session: ImageSession,
    /// Byte-channel image payloads rendered pre-flatten, emitted through
    /// presenter custody AFTER the cell runs (§6: cells first, protocol
    /// payloads second, ONE flush).
    pub(super) pending_image_bytes: Vec<(Vec<u8>, Point)>,
    frame: Surface,
    prev: Surface,
    size: Size,
    /// Event captured by a blocking wait, dispatched by the next turn so
    /// ALL routing stays inside turn's phase U.
    pending: Vec<Event>,
    /// Scratch for `poll_many` bursts (reused, no per-turn alloc).
    burst: Vec<Event>,
    /// tmux passthrough grace: after the DA1 sentinel, wrapped replies
    /// get [`crate::term::probe::TMUX_GRACE`] to arrive; at the deadline
    /// the probe finalizes with whatever answered (KERNEL's reference
    /// loop, driver edition).
    probe_grace: Option<std::time::Instant>,
    now_fn: Option<Rc<dyn Fn() -> std::time::Instant>>,
    out: Vec<u8>,
    scratch_damage: Vec<Rect>,
    /// Screen-text selection layer (0270 tier 3) — the same app-thread
    /// state `app::selection::selection()` hands components.
    selection: Selection,
    /// Tier-2 mouse-reporting suspend requests, drained per turn.
    mouse_capture: MouseCapture,
    /// OSC 52 payloads awaiting presenter-custody emission (§6): cell
    /// runs first, then protocol payloads, one flush.
    pending_clipboard: Vec<Vec<u8>>,
    /// One-time labeled notice latch: OSC 52 copy on an unadvertising
    /// terminal (fire-and-forget — "may be ignored" is the honest claim).
    osc52_noticed: bool,
    /// Zero-collapse diagnostics drained from the trees during phase L/D
    /// of the PREVIOUS frame, forwarded into the notices lane at the next
    /// phase U (signal writes belong to phase U, never the draw phases).
    collapse_pending: Vec<String>,
    /// Everything forwarded this run (bounded like the tree buffer) —
    /// flushed to stderr by `App::run` AFTER the terminal is restored,
    /// so headless/exit visibility survives without corrupting a live
    /// alternate screen.
    collapse_log: Vec<String>,
}

impl Driver {
    /// Enter the terminal session and prepare the pipeline. Emits the
    /// enter bytes and (optionally) the probe queries; does NOT render —
    /// the first `turn` does, from the mount-time damage.
    pub fn new(app: &mut App, term: &mut dyn Terminal, cfg: RunConfig) -> Result<Driver> {
        let caps = cfg.caps.unwrap_or_else(Capabilities::detect_env);
        let enter = cfg.enter.unwrap_or_else(|| EnterOptions {
            kitty_keyboard: if caps.kitty_keyboard {
                KittyFlags::standard()
            } else {
                KittyFlags(0)
            },
            ..EnterOptions::default()
        });
        term.enter(&enter)?;
        let size = term.size()?;
        // Through App::set_viewport, never tree-direct: App::viewport()
        // must stay truthful (RT2-9).
        app.set_viewport(size);

        // Cross-thread wakeups: posted jobs and frame requests interrupt
        // the blocking read. A terminal without a waker (scripted tests)
        // still works — turns discover work on their own cadence.
        let waker = term.waker();
        if let Some(w) = waker.clone() {
            reactive::set_wake_callback(move || w.wake());
        }
        reactive::set_frame_requester(Rc::new(WakeOnFrame(waker)));

        // First paint uses env-pass caps IMMEDIATELY; the probe upgrades
        // later frames (RT1-6). Never probe a dumb terminal (RT1-6b).
        // `for_caps` + `full_query_bytes` (KERNEL cycle 4): under tmux
        // the batch adds WRAPPED queries so passthrough graphics get
        // verified instead of conservatively zeroed.
        let probe = if cfg.probe && !caps.dumb {
            let probe = ActiveProbe::for_caps(&caps);
            term.write(&probe.full_query_bytes())?;
            term.flush()?;
            Some(probe)
        } else {
            None
        };

        let blank = Cell::EMPTY;
        let overlays = app.overlays();
        overlays.ensure_root(size);
        // A fresh session starts with no visible selection (the previous
        // driver's screen-space region is meaningless on a new frame);
        // the app's select-mode choice survives.
        let selection = super::selection::selection();
        selection.reset_session();
        Ok(Driver {
            reader: EventReader::new(),
            present_caps: present_caps_from(&caps),
            caps,
            probe,
            comp: Compositor::new(),
            diff: FrameDiff::new(),
            presenter: Presenter::new(),
            overlays,
            image_session: ImageSession::new(),
            pending_image_bytes: Vec::new(),
            frame: Surface::new(size, blank),
            prev: Surface::new(size, blank),
            size,
            pending: Vec::new(),
            burst: Vec::new(),
            probe_grace: None,
            now_fn: None,
            out: Vec::new(),
            scratch_damage: Vec::new(),
            selection,
            mouse_capture: super::selection::mouse_capture(),
            pending_clipboard: Vec::new(),
            osc52_noticed: false,
            collapse_pending: Vec::new(),
            collapse_log: Vec::new(),
        })
    }

    /// The zero-collapse diagnostics forwarded during this run (debug
    /// builds). `App::run` prints them to stderr after teardown.
    pub(crate) fn collapse_log(&self) -> &[String] {
        &self.collapse_log
    }

    /// Inject the frame-loop clock (animations, one-shot timers, probe
    /// grace all read it). Tests drive turns on synthetic time instead
    /// of real sleeps; production never calls this (`Instant::now`).
    pub fn set_clock(&mut self, f: impl Fn() -> std::time::Instant + 'static) {
        self.now_fn = Some(Rc::new(f));
    }

    /// The loop clock: injected in tests, `Instant::now` in production.
    fn now(&self) -> std::time::Instant {
        match &self.now_fn {
            Some(f) => f(),
            None => std::time::Instant::now(),
        }
    }

    pub fn caps(&self) -> &Capabilities {
        &self.caps
    }

    /// One non-blocking pass: phase U always; phases L..S only when a
    /// frame is wanted. Never blocks — the caller decides how to wait.
    pub fn turn(&mut self, app: &mut App, term: &mut dyn Terminal) -> Result<Turn> {
        // ---- phase U: posted jobs, timers, animation ticks, then input --
        drain_posted();
        let now = self.now();
        // One-shot timers (toast dismissal, debounce) fire here; the
        // outer loop sleeps until the earliest deadline, so a pending
        // timer costs zero wakeups until due.
        reactive::run_due_timers(now);
        // Signal transitions (reactive::animate) advance here — one tick
        // per frame, billed as frame requests per the cursor/animation
        // policy (§4). An empty task list costs nothing.
        reactive::run_frame_tasks(now);
        flush_effects();
        // tmux probe grace expired with wrapped replies still missing:
        // finalize on the evidence in hand (passthrough-off sessions
        // never answer — spending the grace once is the design).
        if let Some(deadline) = self.probe_grace {
            if now >= deadline {
                self.probe_grace = None;
                if self.probe.take().is_some() {
                    self.apply_caps_upgrade(app);
                }
            }
        }
        // A worker that died surfaces as an app error (RT1-15b). Checked
        // AFTER the drain: the failure report itself arrives as a posted
        // job, so draining first catches a death in the same turn.
        let failures = take_worker_failures();
        if !failures.is_empty() {
            return Err(crate::base::Error::App(failures.join("; ")));
        }

        let mut events = 0usize;
        let mut quit = false;
        let pending: Vec<Event> = self.pending.drain(..).collect();
        for ev in pending {
            events += 1;
            self.handle_event(app, ev, &mut quit);
        }
        // Drain whatever is immediately available in ONE burst
        // (`poll_many` with an elapsed deadline = non-blocking drain;
        // KERNEL cycle 4 — one syscall shape instead of one zero-timeout
        // confirmation per event). Dispatch stays per-event: each event
        // is its own reactive batch (inside UiTree::dispatch), so
        // effects flush between events — event N+1 routes over the tree
        // event N produced.
        let drain_deadline = std::time::Instant::now();
        let mut burst = std::mem::take(&mut self.burst);
        burst.clear();
        self.reader
            .poll_many(term, &mut burst, Some(drain_deadline))?;
        // THE COALESCING RULE (mouse-move storms, cycle 7): within one
        // phase-U batch, only the LAST of each consecutive run of plain
        // Move events dispatches — intermediate hover positions were
        // never visible (no frame rendered between them) so nothing is
        // lost. Drag/Down/Up/Wheel are NEVER coalesced (capture and
        // click handlers see every one), and a non-mouse event between
        // moves breaks the run (ordering with keys is preserved).
        // Widgets needing raw motion trails will need an opt-out; none
        // exists in-tree, so the rule is global until one does.
        coalesce_moves(&mut burst);
        for ev in burst.drain(..) {
            events += 1;
            self.handle_event(app, ev, &mut quit);
        }
        self.burst = burst;
        if app.quit_requested() {
            quit = true;
        }
        if quit {
            return Ok(Turn {
                events,
                quit: true,
                ..Turn::default()
            });
        }

        // ---- engine verb drains (still phase U) ------------------------
        // App-queued clipboard writes (`app::selection::copy_to_clipboard`)
        // become custody-emitted OSC 52 payloads on this frame.
        for text in self.selection.take_pending_copies() {
            self.queue_clipboard_text(app, &text);
        }
        // Tier-2 mouse-reporting flip (latest request wins). Refusal is a
        // labeled degradation, never a dead loop: scripted terminals
        // without session tracking honestly decline the verb.
        if let Some(on) = self.mouse_capture.take_request() {
            if let Err(e) = term.set_mouse_reporting(on).and_then(|()| term.flush()) {
                app.push_startup_notice(format!("mouse capture: suspend verb unavailable ({e})"));
            }
        }
        // Zero-collapse diagnostics drained from last frame's solve reach
        // the app here (phase U owns signal writes). The notices lane is
        // the in-session surface; stderr waits until teardown.
        for note in self.collapse_pending.drain(..) {
            if self.collapse_log.len() < 64 {
                self.collapse_log.push(note.clone());
            }
            app.push_startup_notice(note);
        }

        // ---- frame decision: damage set seals HERE (epoch rule §2) -----
        let frame_requested = take_frame_request();
        let wants_frame = frame_requested
            || app.tree().has_pending_work()
            || self.overlays.has_pending_work()
            || {
                let store = self.overlays.store().borrow();
                Compositor::any_dirty(&store.layers)
            };
        if !wants_frame {
            return Ok(Turn {
                events,
                idle: events == 0,
                ..Turn::default()
            });
        }
        let emitted = self.render_frame(app, term)?;
        Ok(Turn {
            events,
            rendered: true,
            emitted,
            ..Turn::default()
        })
    }

    /// Phases L..S for one frame.
    fn render_frame(&mut self, app: &mut App, term: &mut dyn Terminal) -> Result<bool> {
        let theme = current_theme();
        let text_fg = theme.tokens.get(TokenId::Text);
        let bg = theme.tokens.get(TokenId::Bg);
        app.tree().set_text_fg(text_fg);
        // Compositing ground = theme bg (RENDER cycle 5): additive light
        // and translucent veils blend against the theme instead of
        // black. A theme switch already damage_alls (contract §5), so
        // re-reading per frame keeps the ground in lockstep for free.
        self.comp.set_ground(Some(bg));

        // ---- phase L: layout (folds geometry damage into the ui set) ---
        app.tree().layout();
        self.overlays.layout_all();
        // Collect this frame's zero-collapse diagnostics (debug builds;
        // both drains are empty-vec no-ops in release). Forwarded at the
        // NEXT phase U — draw phases never write signals.
        self.collapse_pending
            .extend(app.tree().take_collapse_notices());
        self.collapse_pending
            .extend(self.overlays.take_collapse_notices());

        // ---- phase D: clear + redraw damaged regions (root layer) ------
        // The root surface is STOLEN from the store while user draw code
        // runs (the overlay borrow rule); overlay content paints next.
        let viewport = Rect::from_size(self.size);
        let mut damage = app.tree().take_damage();
        coalesce_damage(&mut damage, viewport);
        let mut root_surface = self.steal_root_surface();
        {
            let clear = Cell::new(Glyph::SPACE).with_fg(text_fg).with_bg(bg);
            for &rect in &damage {
                // The clear erases stale glyphs where content shrank or
                // moved away; surface writes record their own damage for
                // the compositor.
                root_surface.fill_rect(rect, clear);
            }
            let mut canvas = SurfaceCanvas::new(&mut root_surface);
            app.tree().draw_damaged(&mut canvas, &damage);
        }
        // ---- phase D2: image overlays (gfx ladder). Mosaic falls back
        // to CELLS blitted into the root surface (pre-flatten); byte
        // channels stash payloads for post-present custody emission.
        self.render_images(&mut root_surface);
        self.restore_root_surface(root_surface);
        self.overlays.draw_all();

        // ---- selection pre-flatten damage (0270 tier 3) -----------------
        // When the selection region changed, its OLD highlight cells and
        // its NEW row spans must recompose from truth this frame: damage
        // them on the root layer (origin ZERO: screen == layer space) so
        // the compositor rebuilds the full z-stack there — the repair for
        // what the patch below painted last frame, and fresh ground for
        // what it paints now. Zero cost while nothing changed.
        {
            let mut store = self.overlays.store().borrow_mut();
            if let Some(root) = store.index_of(ROOT_LAYER_ID) {
                self.selection
                    .add_flatten_damage(store.layers[root].surface_mut());
            }
        }

        // ---- phase C: flatten (root + overlays, z-sorted) ---------------
        self.scratch_damage.clear();
        {
            let mut store = self.overlays.store().borrow_mut();
            let flat = self.comp.flatten(&mut self.frame, &mut store.layers);
            self.scratch_damage.extend_from_slice(flat);
        }

        // ---- selection patch: recolor selected cells post-flatten -------
        // Glyphs kept, inks replaced (theme selection tokens). Everything
        // this can CHANGE is already inside the flatten damage: region
        // deltas were damaged above, and content changes beneath an
        // unchanged selection arrive damaged by their own layers — cells
        // the compositor left alone get byte-identical rewrites, which
        // the diff never emits.
        self.selection.paint_into(
            &mut self.frame,
            theme.tokens.get(TokenId::SelectionFg),
            theme.tokens.get(TokenId::SelectionBg),
        );

        // ---- phase P: diff -> present -> image payloads -> ONE flush ----
        // Scroll-aware diff (RENDER cycle 5): when the damage reads as
        // one vertical band shift, the terminal scrolls (DECSTBM+SU/SD,
        // ~8-9x fewer bytes on list/log workloads) and only residuals
        // repaint; detection declining yields plain-compute bytes.
        self.out.clear();
        let runs = self
            .diff
            .compute_scrolled(&self.prev, &self.frame, &self.scratch_damage);
        self.presenter
            .emit_scrolled(runs, &self.frame, &self.present_caps, &mut self.out);
        // Protocol payloads AFTER cell runs, through presenter custody
        // (close SGR/link, absolute CUP, invalidate) — same buffer, so
        // the frame still reaches the terminal in one write + one flush.
        for (bytes, at) in self.pending_image_bytes.drain(..) {
            self.presenter.external_write(&mut self.out, &bytes, at);
        }
        // Clipboard payloads (OSC 52) ride the same custody path: after
        // the cell runs, before the single flush. The park point is a
        // formality — the sequence paints nothing — but custody still
        // closes any open SGR/link state and invalidates the cursor.
        for payload in self.pending_clipboard.drain(..) {
            self.presenter
                .external_write(&mut self.out, &payload, Point::ZERO);
        }
        let emitted = !self.out.is_empty();
        if emitted {
            term.write(&self.out)?;
            term.flush()?; // exactly one flush per emitting frame (RT1-16a)
        }

        // ---- phase S: swap ----------------------------------------------
        self.prev
            .blit(&self.frame, Rect::from_size(self.size), Point::ZERO);
        Ok(emitted)
    }

    /// Block until input, a wake, or a resize; capture at most one event
    /// for the NEXT turn's phase U. Used only by the real `App::run` —
    /// never by tests (a scripted terminal has no blocking read).
    pub fn wait_for_activity(&mut self, term: &mut dyn Terminal) -> Result<()> {
        if let Some(ev) = self.reader.poll_event(term, None)? {
            self.pending.push(ev);
        }
        // None = waker fired (posted work / frame request): the next turn
        // drains it. Deliberately no re-loop here: EVERY consequence of a
        // wake is turn's business.
        Ok(())
    }

    /// Frame-paced wait: block until `deadline` (the next animation frame)
    /// or earlier activity. Same event capture as `wait_for_activity`.
    pub fn wait_until(
        &mut self,
        term: &mut dyn Terminal,
        deadline: std::time::Instant,
    ) -> Result<()> {
        if let Some(ev) = self.reader.poll_event(term, Some(deadline))? {
            self.pending.push(ev);
        }
        Ok(())
    }

    /// Leave the terminal session (idempotent; also runs on drop of the
    /// platform terminal — this explicit call just makes teardown bytes
    /// deterministic for tests). Releases every live image slot first:
    /// leaving the alt screen erases CELLS but kitty uploads live in
    /// terminal memory until deleted — exiting without the deletes is
    /// the RT4-1 leak in its most durable form.
    pub fn finish(&mut self, term: &mut dyn Terminal) -> Result<()> {
        if self.image_session.live_slots() > 0 {
            let mut bytes: Vec<(Vec<u8>, Point)> = Vec::new();
            let mut sink = super::driver_images::BufSink(&mut bytes);
            self.image_session
                .release_all(&mut sink, &self.caps.graphics());
            self.out.clear();
            for (payload, at) in bytes {
                self.presenter.external_write(&mut self.out, &payload, at);
            }
            if !self.out.is_empty() {
                term.write(&self.out)?;
                term.flush()?;
            }
        }
        term.leave()
    }

    fn handle_event(&mut self, app: &mut App, event: Event, quit: &mut bool) {
        match event {
            Event::Resize(size) => self.apply_resize(app, size),
            Event::CapsReply(reply) => {
                if let Some(probe) = &mut self.probe {
                    if probe.on_reply(&reply, &mut self.caps) {
                        self.probe = None;
                        self.probe_grace = None;
                        self.apply_caps_upgrade(app);
                    } else if probe.sentinel_passed()
                        && probe.awaiting_wrapped()
                        && self.probe_grace.is_none()
                    {
                        // Sentinel in, wrapped replies (tmux passthrough)
                        // still possible: grant TMUX_GRACE, then finalize
                        // in phase U. The timer wakes an idle loop.
                        let grace = crate::term::probe::TMUX_GRACE;
                        self.probe_grace = Some(self.now() + grace);
                        reactive::after(grace, || {});
                    }
                }
            }
            other => {
                // Screen-text selection intercept (0270 tier 3): while
                // select mode is on, the layer owns left Down/Drag/Up —
                // and, while a selection is VISIBLE, the copy/clear keys
                // (Enter / c / Ctrl+C / Esc). Everything else (wheel,
                // motion, other buttons, all other keys) routes normally,
                // so scrolling keeps working mid-selection. Deliberately
                // ahead of overlay routing: select mode is an explicit
                // user mode and may copy from modal content too (the pane
                // clamp resolves overlay tree panes).
                let overlays = &self.overlays;
                let size = self.size;
                match self
                    .selection
                    .on_input(&other, &mut |p| selection_pane(app, overlays, size, p))
                {
                    SelectionAct::Pass => {}
                    SelectionAct::Consumed => return,
                    SelectionAct::Copy => {
                        self.queue_selection_copy(app);
                        return;
                    }
                }
                if let Some(ui_event) = convert_event(&other) {
                    // Overlay trees route first, topmost-z down; a MODAL
                    // overlay owns everything while visible. Unclaimed
                    // events fall to the root tree.
                    let mut consumed = match self.overlays.dispatch(&ui_event) {
                        Some(consumed) => consumed,
                        None => app.tree().dispatch(&ui_event),
                    };
                    // Global actions run LAST: only keys nothing in the
                    // UI consumed reach the keymap (a focused input
                    // typing 's' never fires a bare-'s' binding).
                    if !consumed {
                        if let crate::ui::UiEvent::Key(k) = &ui_event {
                            let chord = crate::ui::KeyChord {
                                key: k.key,
                                mods: k.mods,
                            };
                            consumed = app.actions().dispatch_chord(chord);
                        }
                    }
                    // Default Ctrl+C = quit, unless the app consumed it
                    // (its own handler/shortcut/action overrides it).
                    if !consumed && is_default_quit(&ui_event) {
                        *quit = true;
                    }
                    if app.quit_requested() {
                        *quit = true;
                    }
                }
            }
        }
    }

    fn apply_resize(&mut self, app: &mut App, size: Size) {
        if size == self.size || size.is_empty() {
            return;
        }
        self.size = size;
        // Screen-space selection geometry is meaningless after a resize;
        // the prev-poison below repaints every cell, so clearing state is
        // all the repair needed.
        self.selection.on_resize();
        let blank = Cell::EMPTY;
        self.overlays.ensure_root(size);
        // Image placements are geometry-relative; re-emit them (the
        // full-repaint pass below rewrites the cells beneath).
        {
            let mut store = self.overlays.store().borrow_mut();
            for img in store.images.iter_mut() {
                img.dirty = true;
            }
        }
        self.frame.resize(size, blank);
        self.prev.resize(size, blank);
        // The terminal's actual content after a resize is unknown (the
        // emulator reflowed or cleared it its own way). Poison `prev` so
        // the diff re-emits every cell of the next frame instead of
        // trusting a model of a screen that no longer exists.
        self.poison_prev();
        // Through App::set_viewport (RT2-9: tree-direct left
        // App::viewport() reporting the stale size forever).
        app.set_viewport(size);
    }

    /// Capability upgrade (probe completed): emission strategy changed
    /// (color depth, sync brackets, graphics channel), so the next frame
    /// must re-present everything even though the scene is unchanged.
    fn apply_caps_upgrade(&mut self, _app: &mut App) {
        let fresh = present_caps_from(&self.caps);
        if fresh != self.present_caps {
            self.present_caps = fresh;
            self.poison_prev();
            let mut store = self.overlays.store().borrow_mut();
            for layer in store.layers.iter_mut() {
                layer.surface_mut().damage_all();
            }
            // The graphics ladder may pick a better channel now.
            for img in store.images.iter_mut() {
                img.dirty = true;
            }
            drop(store);
            reactive::request_frame();
        }
    }

    /// Tier-2 verb, immediate form — for embedders driving their own
    /// turns (component code uses `app::selection::mouse_capture()`,
    /// which the driver applies on its next turn). Emits the entered
    /// mode's disarm/re-arm pair and flushes.
    pub fn set_mouse_reporting(&mut self, term: &mut dyn Terminal, on: bool) -> Result<()> {
        term.set_mouse_reporting(on)?;
        term.flush()
    }

    /// Extract the active selection's screen text and queue its OSC 52
    /// payload (release or copy-key path).
    fn queue_selection_copy(&mut self, app: &mut App) {
        if let Some(region) = self.selection.active_region() {
            let text = super::selection::extract_text(&self.frame, &region);
            self.queue_clipboard_text(app, &text);
        }
    }

    /// Queue one OSC 52 clipboard write for presenter-custody emission.
    /// Empty/whitespace text is refused (an empty OSC 52 payload CLEARS
    /// the clipboard — a surprise, never a copy). Capability honesty:
    /// OSC 52 is write-only fire-and-forget, so an unadvertising
    /// terminal gets the bytes anyway (harmless — unsupporting terminals
    /// ignore the frame, and the env pass is conservative) plus a
    /// one-time labeled notice that the copy may have been ignored.
    fn queue_clipboard_text(&mut self, app: &mut App, text: &str) {
        if text.trim().is_empty() {
            return;
        }
        if !self.caps.osc52_copy && !self.osc52_noticed {
            self.osc52_noticed = true;
            app.push_startup_notice(
                "clipboard: OSC 52 not advertised by this terminal — copies may be ignored",
            );
        }
        self.pending_clipboard
            .push(crate::term::verbs::clipboard_copy_bytes(text));
        reactive::request_frame();
    }

    /// Make every cell of `prev` unequal to any real content so the next
    /// diff emits the full frame. Glyph stays EMPTY; the impossible color
    /// pair does the work (alpha 7 never occurs: ui colors are opaque or
    /// fully transparent by convention).
    fn poison_prev(&mut self) {
        let poison = Cell::EMPTY
            .with_fg(crate::base::Rgba::new(1, 2, 3, 7))
            .with_bg(crate::base::Rgba::new(3, 2, 1, 7));
        self.prev.fill_rect(Rect::from_size(self.size), poison);
    }
}

/// The presenter's view of KERNEL's capabilities — KERNEL's own
/// conversion (`Capabilities::present_caps`, cycle 3) is the one
/// mapping; the hand-assembly this replaced silently zeroed
/// `undercurl`/`underline_color` after the caps fields landed (their
/// cycle-3 reminder).
fn present_caps_from(caps: &Capabilities) -> PresentCaps {
    caps.present_caps()
}

/// Keep only the LAST of each consecutive run of plain mouse-Move
/// events (order-preserving in-place compaction). See the call site for
/// the full coalescing rule.
fn coalesce_moves(events: &mut Vec<Event>) {
    let is_move =
        |e: &Event| matches!(e, Event::Mouse(m) if m.kind == crate::input::MouseKind::Move);
    let mut keep = 0usize;
    for i in 0..events.len() {
        let dropped = is_move(&events[i]) && events.get(i + 1).map(&is_move).unwrap_or(false);
        if !dropped {
            events.swap(keep, i);
            keep += 1;
        }
    }
    events.truncate(keep);
}

/// Clip to the viewport, drop empties and rects wholly contained in an
/// earlier one. Overlapping-but-not-contained rects stay separate:
/// double-painting a sliver is idempotent and cheaper than a rect union
/// pass that can only grow the area.
fn coalesce_damage(damage: &mut Vec<Rect>, viewport: Rect) {
    for r in damage.iter_mut() {
        *r = r.intersect(viewport);
    }
    damage.retain(|r| !r.is_empty());
    let mut kept: Vec<Rect> = Vec::with_capacity(damage.len());
    for &r in damage.iter() {
        let contained = kept.iter().any(|k| k.intersect(r) == r);
        if !contained {
            kept.retain(|k| r.intersect(*k) != *k); // drop rects r swallows
            kept.push(r);
        }
    }
    *damage = kept;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::render::ColorDepth;

    #[test]
    fn coalesce_moves_keeps_last_of_runs_and_every_other_kind() {
        use crate::base::Point;
        use crate::input::{MouseButton as B, MouseEvent as M, MouseKind as K};
        let mv = |x: i32| {
            Event::Mouse(M::new(
                K::Move,
                B::Left,
                Point::new(x, 0),
                crate::input::Mods::NONE,
            ))
        };
        let down = Event::Mouse(M::new(
            K::Down,
            B::Left,
            Point::new(9, 0),
            crate::input::Mods::NONE,
        ));
        let mut evs = vec![mv(1), mv(2), mv(3), down.clone(), mv(4), mv(5)];
        coalesce_moves(&mut evs);
        // Runs collapse to their last member; Down survives; order holds.
        assert_eq!(evs.len(), 3);
        assert!(matches!(&evs[0], Event::Mouse(m) if m.pos.x == 3));
        assert!(matches!(&evs[1], Event::Mouse(m) if m.kind == K::Down));
        assert!(matches!(&evs[2], Event::Mouse(m) if m.pos.x == 5));
        // Drags never coalesce (capture handlers see every step).
        let drag = |x: i32| {
            Event::Mouse(M::new(
                K::Drag,
                B::Left,
                Point::new(x, 0),
                crate::input::Mods::NONE,
            ))
        };
        let mut drags = vec![drag(1), drag(2), drag(3)];
        coalesce_moves(&mut drags);
        assert_eq!(drags.len(), 3);
    }

    #[test]
    fn coalesce_drops_contained_and_clips() {
        let vp = Rect::new(0, 0, 20, 10);
        let mut d = vec![
            Rect::new(0, 0, 5, 5),
            Rect::new(1, 1, 2, 2),    // inside the first: dropped
            Rect::new(18, 8, 10, 10), // clipped to viewport
            Rect::new(-5, -5, 3, 3),  // fully outside: dropped
        ];
        coalesce_damage(&mut d, vp);
        assert_eq!(d, vec![Rect::new(0, 0, 5, 5), Rect::new(18, 8, 2, 2)]);
    }

    #[test]
    fn present_caps_mapping_delegates_to_kernel_including_underline() {
        let mut caps = Capabilities::default();
        assert_eq!(present_caps_from(&caps).color, ColorDepth::Ansi16);
        caps.colors_256 = true;
        assert_eq!(present_caps_from(&caps).color, ColorDepth::Xterm256);
        caps.truecolor = true;
        caps.undercurl = true;
        caps.underline_color = true;
        let pc = present_caps_from(&caps);
        assert_eq!(pc.color, ColorDepth::TrueColor);
        // The cycle-3 KERNEL reminder: these two must flow through
        // (the old hand-assembly pinned them false forever).
        assert!(
            pc.undercurl,
            "undercurl capability must reach the presenter"
        );
        assert!(
            pc.underline_color,
            "underline color capability must reach the presenter"
        );
    }
}