rdom-tui 0.3.10

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
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
//! `Terminal<B>` — front+back buffer management + diff-driven draw loop.
//!
//! Owns a `Backend`, a front buffer (last-drawn state, matches what
//! the TTY actually shows), and a back buffer (the frame being
//! prepared). `draw(|buf| …)` hands the back buffer to the caller,
//! diffs vs front, emits only what changed, then swaps.
//!
//! ## Synchronized output (BSU/ESU)
//!
//! Each `draw()` call is wrapped in DEC private mode 2026:
//!
//! - `\x1b[?2026h` — Begin Synchronized Update
//! - `\x1b[?2026l` — End Synchronized Update
//!
//! Modern terminals buffer everything between these markers and flush
//! atomically, preventing mid-frame tearing. Non-supporting terminals
//! ignore the sequences. Disable with the `no-synchronized-output`
//! Cargo feature if needed.
//!
//! ## Autoresize
//!
//! Before each draw, `Terminal` polls `backend.size()`. If it changed,
//! both buffers are resized (content preserved on the intersection)
//! and a force-full-redraw is scheduled — we can't trust the front
//! buffer reflects reality post-resize.
//!
//! ## Panic safety
//!
//! `TerminalGuard` is an RAII guard that calls `leave_tui_mode` on
//! drop. Pair it with `enter_tui_mode` at program start — even if the
//! user's code panics inside `draw(|...|)`, the guard restores the
//! terminal to a usable state.

use std::io;

use super::backend::Backend;
use super::{Buffer, Rect};

/// Front+back buffer terminal with diff-driven updates.
pub struct Terminal<B: Backend> {
    backend: B,
    /// The buffer that matches what's currently on the terminal.
    front: Buffer,
    /// The buffer being prepared this frame.
    back: Buffer,
    /// Set when something happened (resize, explicit clear) that
    /// invalidates the front buffer. Next draw emits every cell.
    force_full_redraw: bool,
}

/// Returned by `draw` so callers can inspect what happened this frame.
#[derive(Debug, Clone, Copy)]
pub struct CompletedFrame {
    pub area: Rect,
    pub cells_emitted: usize,
    pub was_full_redraw: bool,
}

impl<B: Backend> Terminal<B> {
    /// Construct a Terminal from a backend. Both buffers start at the
    /// backend's current size.
    pub fn new(backend: B) -> io::Result<Self> {
        let size = backend.size()?;
        Ok(Self {
            backend,
            front: Buffer::empty(size),
            back: Buffer::empty(size),
            force_full_redraw: true,
        })
    }

    /// Borrow the backend.
    pub fn backend(&self) -> &B {
        &self.backend
    }

    /// Mutably borrow the backend. Bypasses our state invariants —
    /// use sparingly.
    pub fn backend_mut(&mut self) -> &mut B {
        &mut self.backend
    }

    /// Current viewport.
    pub fn size(&self) -> Rect {
        self.back.area
    }

    /// Queue a full repaint for the next `draw()`.
    pub fn queue_full_redraw(&mut self) {
        self.force_full_redraw = true;
    }

    /// Forget the front buffer and reset the backend's style cache.
    /// Use after out-of-band writes to stdout might have corrupted
    /// our idea of the terminal state.
    pub fn clear(&mut self) -> io::Result<()> {
        self.backend.clear()?;
        self.front.clear();
        self.back.clear();
        self.force_full_redraw = true;
        Ok(())
    }

    /// Check if the backend's reported size matches ours; resize if
    /// not. Called automatically by `draw`.
    ///
    /// When the size changes we also emit `\x1b[2J` via
    /// `backend.clear()` so the terminal is blanked before the next
    /// frame paints. Without this, stale cells from the old frame
    /// persist at positions that are either no longer painted (resize
    /// smaller → content is now out of buffer bounds but still on
    /// screen until overwritten) or now empty (resize larger → new
    /// rows show whatever was in the terminal before).
    pub fn autoresize(&mut self) -> io::Result<()> {
        let actual = self.backend.size()?;
        if actual != self.back.area {
            self.back.resize(actual);
            self.front.resize(actual);
            // Front no longer reflects the terminal — force a full
            // repaint next frame AND wipe the terminal first so stale
            // cells outside the repaint set can't leak through.
            self.force_full_redraw = true;
            self.backend.clear()?;
        }
        Ok(())
    }

    /// Render a frame. `f` receives a mutable reference to the back
    /// buffer; paint into it. On return, we diff back vs front, emit
    /// only the changed cells (wrapped in BSU/ESU when enabled), then
    /// swap so the back becomes the new front for next frame.
    pub fn draw<F>(&mut self, f: F) -> io::Result<CompletedFrame>
    where
        F: FnOnce(&mut Buffer) -> io::Result<()>,
    {
        self.autoresize()?;

        // Clear the back buffer so the caller starts from a clean slate
        // each frame. (Paint pass composes destructively — no need for
        // incremental composition.)
        self.back.clear();

        // Caller paints.
        f(&mut self.back)?;

        // Synchronized output (BSU/ESU = `?2026h` / `?2026l`)
        // INTENTIONALLY OMITTED — iTerm2 has a confirmed bad
        // interaction where the per-frame `?2026h` reset of the
        // sync-output state machine intermittently breaks
        // delivery of `?1003h` (any-motion) mouse events: the
        // user-visible symptom is that hover stops working
        // until the terminal window loses + regains focus. Other
        // terminal frameworks that don't emit `?2026` (ratatui
        // 0.29 confirmed) don't trigger this. The protection
        // BSU/ESU offers (mid-frame tearing on fast paints) is
        // not worth losing hover for. Re-enable when iTerm2 fixes
        // the interaction OR rdom-tui adds a runtime opt-in.
        let mut cells_emitted = 0usize;
        let was_full_redraw = self.force_full_redraw;

        if self.force_full_redraw {
            // Emit every non-blank cell + every cell whose style
            // differs from default. Cheap substitute: diff vs an
            // empty buffer of the same size.
            let blank = Buffer::empty(self.back.area);
            for (x, y, cell) in self.back.diff_iter(&blank) {
                cells_emitted += 1;
                // Emit one-at-a-time via the backend's draw. We
                // construct a trivial iter for each cell.
                self.backend.draw(std::iter::once((x, y, cell)))?;
            }
            self.force_full_redraw = false;
        } else {
            // Normal incremental diff.
            let count = self.back.diff_iter(&self.front).count();
            cells_emitted = count;
            self.backend.draw(self.back.diff_iter(&self.front))?;
        }

        // ESU intentionally omitted — see BSU comment above.
        self.backend.flush()?;

        // Swap: back becomes the new front.
        std::mem::swap(&mut self.front, &mut self.back);

        Ok(CompletedFrame {
            area: self.front.area,
            cells_emitted,
            was_full_redraw,
        })
    }

    /// Hide the cursor (passthrough to backend).
    pub fn hide_cursor(&mut self) -> io::Result<()> {
        self.backend.hide_cursor()
    }

    /// Show the cursor.
    pub fn show_cursor(&mut self) -> io::Result<()> {
        self.backend.show_cursor()
    }

    /// Position the cursor. Use after `draw()` if you want the cursor
    /// at a specific location (e.g., for text input).
    pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
        self.backend.set_cursor_position(x, y)
    }

    /// Dissolve into the backend. Useful when you need to hand the
    /// writer back to something else after tearing down the Terminal.
    pub fn into_backend(self) -> B {
        self.backend
    }
}

// ─── RAII mode guard ────────────────────────────────────────────────

/// Guards a terminal session's mode. Constructed after `enter_tui_mode`;
/// on drop, runs `leave_tui_mode`. Works even on panic.
///
/// ```ignore
/// use rdom_tui::render::{Terminal, CrosstermBackend, TerminalGuard};
/// use rdom_tui::render::backend_crossterm::{enter_tui_mode, leave_tui_mode};
///
/// let mut stdout = std::io::stdout();
/// enter_tui_mode(&mut stdout)?;
/// let _guard = TerminalGuard::new();
/// let backend = CrosstermBackend::new(stdout);
/// let mut term = Terminal::new(backend)?;
/// // Even if this panics, TerminalGuard::drop restores the terminal.
/// term.draw(|buf| { … })?;
/// ```
pub struct TerminalGuard {
    active: bool,
}

impl TerminalGuard {
    /// Construct a guard. Does NOT enter TUI mode — caller is expected
    /// to have done that already. Drop will attempt to leave.
    pub fn new() -> Self {
        Self { active: true }
    }

    /// Deactivate without restoring — use when you've manually
    /// restored the terminal (e.g., clean shutdown) and want to skip
    /// the drop-time restore.
    pub fn disarm(&mut self) {
        self.active = false;
    }
}

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

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        if self.active {
            let _ = super::backend_crossterm::leave_tui_mode(&mut io::stdout());
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::render::backend::TestBackend;
    use crate::render::{Color, Style};

    // ── Basic draw cycle ─────────────────────────────────────────────

    #[test]
    fn construct_initial_full_redraw() {
        let tb = TestBackend::new(10, 3);
        let term = Terminal::new(tb).unwrap();
        assert_eq!(term.size(), Rect::new(0, 0, 10, 3));
    }

    #[test]
    fn first_draw_is_full_redraw() {
        let tb = TestBackend::new(10, 3);
        let mut term = Terminal::new(tb).unwrap();
        let frame = term
            .draw(|buf| {
                buf.set_string(0, 0, "hi", Style::new().fg(Color::Rgb(255, 0, 0)));
                Ok(())
            })
            .unwrap();
        assert!(frame.was_full_redraw);
        assert_eq!(frame.cells_emitted, 2); // 'h' and 'i'
    }

    #[test]
    fn second_draw_is_incremental() {
        let tb = TestBackend::new(10, 3);
        let mut term = Terminal::new(tb).unwrap();
        term.draw(|buf| {
            buf.set_string(0, 0, "hi", Style::new());
            Ok(())
        })
        .unwrap();
        let f2 = term
            .draw(|buf| {
                buf.set_string(0, 0, "hi", Style::new());
                Ok(())
            })
            .unwrap();
        assert!(!f2.was_full_redraw);
        assert_eq!(f2.cells_emitted, 0); // unchanged
    }

    #[test]
    fn changed_cells_are_emitted() {
        let tb = TestBackend::new(10, 3);
        let mut term = Terminal::new(tb).unwrap();
        term.draw(|buf| {
            buf.set_string(0, 0, "hello", Style::new());
            Ok(())
        })
        .unwrap();

        let frame = term
            .draw(|buf| {
                buf.set_string(0, 0, "hELLo", Style::new());
                Ok(())
            })
            .unwrap();
        assert_eq!(frame.cells_emitted, 3); // E, L, L
    }

    // ── BSU/ESU ──────────────────────────────────────────────────────

    #[test]
    fn draw_does_not_emit_synchronized_output_sequences() {
        // Inverse of the pre-fix invariant: BSU/ESU (`?2026h` /
        // `?2026l`) were emitted by every `draw()` to prevent mid-
        // frame tearing on conformant terminals. iTerm2 has a
        // confirmed interaction where the per-frame `?2026h` reset
        // intermittently breaks `?1003h` motion-event delivery
        // (hover stops working until terminal loses + regains
        // focus). Pin the new contract: draw emits NO `?2026`
        // bytes so motion tracking stays alive.
        let tb = TestBackend::new(5, 1);
        let mut term = Terminal::new(tb).unwrap();
        term.draw(|buf| {
            buf.set_string(0, 0, "X", Style::new());
            Ok(())
        })
        .unwrap();
        let bytes = term.backend().bytes();
        assert!(
            !bytes.windows(8).any(|w| w == b"\x1b[?2026h"),
            "BSU (?2026h) must NOT appear in draw output — see iTerm2 \
             motion-tracking interaction documented in `terminal.rs::draw`"
        );
        assert!(
            !bytes.windows(8).any(|w| w == b"\x1b[?2026l"),
            "ESU (?2026l) must NOT appear in draw output"
        );
    }

    // ── Resize ───────────────────────────────────────────────────────

    #[test]
    fn autoresize_resizes_both_buffers() {
        let tb = TestBackend::new(5, 3);
        let mut term = Terminal::new(tb).unwrap();
        term.draw(|buf| {
            buf.set_string(0, 0, "ab", Style::new());
            Ok(())
        })
        .unwrap();

        term.backend_mut().resize(10, 3);
        assert_eq!(term.backend().size().unwrap(), Rect::new(0, 0, 10, 3));

        let frame = term
            .draw(|buf| {
                buf.set_string(0, 0, "ab", Style::new());
                Ok(())
            })
            .unwrap();
        // After resize, next frame is a full redraw.
        assert!(frame.was_full_redraw);
        assert_eq!(term.size(), Rect::new(0, 0, 10, 3));
    }

    // ── Clear ────────────────────────────────────────────────────────

    #[test]
    fn clear_forces_full_redraw_next_frame() {
        let tb = TestBackend::new(5, 2);
        let mut term = Terminal::new(tb).unwrap();
        term.draw(|buf| {
            buf.set_string(0, 0, "X", Style::new());
            Ok(())
        })
        .unwrap();
        term.clear().unwrap();

        let frame = term
            .draw(|buf| {
                buf.set_string(0, 0, "X", Style::new());
                Ok(())
            })
            .unwrap();
        assert!(frame.was_full_redraw);
    }

    // ── Cursor control passthrough ───────────────────────────────────

    #[test]
    fn hide_show_cursor_passes_through() {
        let tb = TestBackend::new(5, 2);
        let mut term = Terminal::new(tb).unwrap();
        term.hide_cursor().unwrap();
        assert!(term.backend().bytes().contains(&b'l'));
        term.show_cursor().unwrap();
        assert!(term.backend().bytes().contains(&b'h'));
    }

    // ── Full render pipeline integration ─────────────────────────────

    #[test]
    fn end_to_end_dom_to_ansi() {
        use crate::prelude::*;

        let mut dom = TuiDom::new();
        let root = dom.root();
        let span = dom.create_element("span");
        let t = dom.create_text_node("hi");
        dom.append_child(span, t).unwrap();
        dom.append_child(root, span).unwrap();

        let sheet =
            Stylesheet::bare().rule_unchecked("span", TuiStyle::new().fg(Color::Rgb(255, 0, 0)));
        dom.cascade(&sheet);

        let tb = TestBackend::new(10, 1);
        let mut term = Terminal::new(tb).unwrap();
        let viewport = term.size();

        term.draw(|buf| {
            dom.layout_dom(viewport);
            dom.paint_dom(buf, viewport);
            Ok(())
        })
        .unwrap();

        let bytes = term.backend().bytes();
        let s = std::str::from_utf8(bytes).unwrap();
        assert!(s.contains("hi"), "got: {:?}", s);
        assert!(
            s.contains("\x1b[38;2;255;0;0m"),
            "expected Red fg truecolor SGR in: {:?}",
            s,
        );
    }

    // ── Multi-frame persistence ──────────────────────────────────────

    #[test]
    fn unchanged_frames_emit_minimal_bytes() {
        let tb = TestBackend::new(10, 1);
        let mut term = Terminal::new(tb).unwrap();
        term.draw(|buf| {
            buf.set_string(0, 0, "stable", Style::new().fg(Color::Rgb(255, 0, 0)));
            Ok(())
        })
        .unwrap();
        term.backend_mut().take_bytes(); // clear counter

        for _ in 0..5 {
            term.draw(|buf| {
                buf.set_string(0, 0, "stable", Style::new().fg(Color::Rgb(255, 0, 0)));
                Ok(())
            })
            .unwrap();
        }
        let bytes = term.backend().bytes();
        // Steady state: only BSU/ESU wrappers per frame. No cells emitted.
        // 5 frames × 2 escape sequences = at most 80-ish bytes.
        #[cfg(not(feature = "no-synchronized-output"))]
        assert!(
            bytes.len() <= 100,
            "expected tiny steady-state emit, got {} bytes",
            bytes.len()
        );
    }
}