abstracttui 0.2.25

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
//! Live-pty tests for the unix backend: real openpty pairs, real
//! termios, a real SIGWINCH through the self-pipe. Split from unix.rs
//! to keep the backend itself under the file-size budget.
//!
//! OWNER: KERNEL.

use super::*;
use crate::term::options::{KittyFlags, MouseMode};
use std::mem;
use std::ptr;

/// Read whatever the pty master has within `ms`.
fn read_master(fd: RawFd, ms: i32) -> Vec<u8> {
    let mut out = Vec::new();
    let mut buf = [0u8; 1024];
    loop {
        let mut p = libc::pollfd {
            fd,
            events: libc::POLLIN,
            revents: 0,
        };
        let rc = unsafe { libc::poll(&mut p, 1, ms) };
        if rc <= 0 {
            break;
        }
        let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
        if n <= 0 {
            break;
        }
        out.extend_from_slice(&buf[..n as usize]);
    }
    out
}

fn open_pty() -> Option<(RawFd, RawFd)> {
    let mut master: libc::c_int = -1;
    let mut slave: libc::c_int = -1;
    let rc = unsafe {
        libc::openpty(
            &mut master,
            &mut slave,
            ptr::null_mut(),
            ptr::null_mut(),
            ptr::null_mut(),
        )
    };
    if rc == 0 {
        Some((master, slave))
    } else {
        None
    }
}

#[test]
fn pty_enter_emits_modes_and_leave_restores() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    let opts = EnterOptions {
        mouse: MouseMode::ButtonDrag,
        kitty_keyboard: KittyFlags::standard(),
        ..EnterOptions::default()
    };
    term.enter(&opts).expect("enter");
    let entered = read_master(master, 200);
    let s = String::from_utf8_lossy(&entered);
    assert!(s.contains("\x1b[?1049h"), "altscreen on: {s:?}");
    assert!(s.contains("\x1b[?1006h"), "sgr mouse on: {s:?}");
    assert!(s.contains("\x1b[>3u"), "kitty push: {s:?}");

    term.write(b"hello").unwrap();
    term.flush().unwrap();
    assert_eq!(read_master(master, 200), b"hello");

    term.leave().expect("leave");
    let left = String::from_utf8_lossy(&read_master(master, 200)).into_owned();
    assert!(left.contains("\x1b[<u"), "kitty pop: {left:?}");
    assert!(
        left.ends_with("\x1b[?1049l"),
        "altscreen off last: {left:?}"
    );
    drop(term);
    unsafe { libc::close(master) };
}

#[test]
fn pty_read_bytes_and_deadline() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    // No enter(): read path must work on a cooked pty too.
    unsafe {
        libc::write(master, b"ab\n".as_ptr() as *const libc::c_void, 3);
    }
    let got = match term.read(Some(Instant::now() + Duration::from_millis(500))) {
        Ok(TermRead::Input(b)) => b.to_vec(),
        other => panic!("expected input, got {other:?}"),
    };
    assert!(!got.is_empty());
    // Deadline expiry with no traffic.
    let t0 = Instant::now();
    match term.read(Some(t0 + Duration::from_millis(50))).unwrap() {
        TermRead::Idle => {}
        other => panic!("expected idle, got {other:?}"),
    }
    assert!(t0.elapsed() >= Duration::from_millis(45));
    drop(term);
    unsafe { libc::close(master) };
}

#[test]
fn pty_waker_interrupts_blocking_read() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    let waker = term.waker().expect("wake pipe should exist");
    // Coalescing: several wakes before the read still mean ONE Wake.
    waker.wake();
    waker.wake();
    match term.read(Some(Instant::now() + Duration::from_secs(2))) {
        Ok(TermRead::Wake) => {}
        other => panic!("expected wake, got {other:?}"),
    }
    // Wake from another thread while the read is genuinely blocked.
    let w2 = waker.clone();
    let t0 = Instant::now();
    let th = std::thread::spawn(move || {
        std::thread::sleep(Duration::from_millis(50));
        w2.wake();
    });
    match term.read(Some(Instant::now() + Duration::from_secs(5))) {
        Ok(TermRead::Wake) => {}
        other => panic!("expected wake, got {other:?}"),
    }
    assert!(t0.elapsed() < Duration::from_secs(2), "woke promptly");
    th.join().unwrap();
    // Input outranks a pending wake WHEN BOTH ARE READY; the wake is
    // delivered right after. Ordering setup matters twice: the pty is
    // still in canonical mode (no enter() here), so the line needs its
    // newline to pass the line discipline at all, and the byte must be
    // pollable on the slave before the read, or the wake legitimately
    // wins (it was the only thing ready).
    unsafe {
        libc::write(master, b"x\n".as_ptr() as *const libc::c_void, 2);
    }
    let mut p = libc::pollfd {
        fd: term.read_fd,
        events: libc::POLLIN,
        revents: 0,
    };
    let rc = unsafe { libc::poll(&mut p, 1, 2000) };
    assert!(rc > 0, "pty byte never became readable");
    waker.wake();
    let deadline = Some(Instant::now() + Duration::from_secs(2));
    match term.read(deadline) {
        Ok(TermRead::Input(b)) => assert!(b.starts_with(b"x"), "got {b:?}"),
        other => panic!("expected input first, got {other:?}"),
    }
    match term.read(deadline) {
        Ok(TermRead::Wake) => {}
        other => panic!("expected the wake to survive, got {other:?}"),
    }
    // A waker outliving its terminal is a harmless no-op.
    drop(term);
    waker.wake();
    unsafe { libc::close(master) };
}

#[test]
fn pty_cell_pixel_size_from_winsize() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    let mut ws: libc::winsize = unsafe { mem::zeroed() };
    ws.ws_col = 80;
    ws.ws_row = 24;
    ws.ws_xpixel = 0;
    ws.ws_ypixel = 0;
    unsafe { libc::ioctl(master, libc::TIOCSWINSZ as IoctlReq, &ws) };
    // Zero pixel fields mean "unknown", never Some(0x0).
    assert_eq!(term.cell_pixel_size(), None);

    ws.ws_xpixel = 80 * 9;
    ws.ws_ypixel = 24 * 18;
    unsafe { libc::ioctl(master, libc::TIOCSWINSZ as IoctlReq, &ws) };
    assert_eq!(
        term.cell_pixel_size(),
        Some(crate::base::PixelSize::new(9, 18))
    );
    drop(term);
    unsafe { libc::close(master) };
}

#[test]
fn pty_is_tty_and_pipe_is_not() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let term = UnixTerminal::from_fds(slave, slave, true);
    assert!(term.is_tty(), "a pty slave is a tty");
    drop(term);
    unsafe { libc::close(master) };

    let mut fds = [0 as libc::c_int; 2];
    assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
    let term = UnixTerminal::from_fds(fds[0], fds[1], true);
    assert!(!term.is_tty(), "a pipe is not a tty");
    drop(term);
}

#[test]
fn pty_session_verbs_emit_and_leave_resets() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    term.enter(&EnterOptions::default()).expect("enter");
    let _ = read_master(master, 100); // discard enter modes

    term.set_cursor_style(crate::term::CursorStyle::SteadyBar)
        .unwrap();
    term.set_title("abstract — demo").unwrap();
    term.set_title("second").unwrap(); // stack pushed once, not twice
    term.clipboard_copy("hi").unwrap();
    term.bell().unwrap();
    term.notify("done", crate::term::NotifyChannel::Osc9)
        .unwrap();
    term.notify("kdone", crate::term::NotifyChannel::Osc99)
        .unwrap();
    term.notify("belled", crate::term::NotifyChannel::BellOnly)
        .unwrap();
    term.flush().unwrap();
    let sent = read_master(master, 200);
    let s = String::from_utf8_lossy(&sent);
    assert!(s.contains("\x1b[6 q"), "cursor style: {s:?}");
    assert_eq!(s.matches("\x1b[22;0t").count(), 1, "one title push: {s:?}");
    assert!(s.contains("\x1b]0;abstract — demo\x1b\\"), "{s:?}");
    assert!(s.contains("\x1b]0;second\x1b\\"), "{s:?}");
    assert!(s.contains("\x1b]52;c;aGk=\x1b\\"), "clipboard: {s:?}");
    assert!(s.contains("\x1b]9;done\x1b\\"), "notify osc9: {s:?}");
    assert!(s.contains("\x1b]99;;kdone\x1b\\"), "notify osc99: {s:?}");
    assert_eq!(s.matches('\x07').count(), 2, "bell + bell-fallback: {s:?}");

    term.leave().expect("leave");
    let left = String::from_utf8_lossy(&read_master(master, 200)).into_owned();
    let altscreen_off = left.find("\x1b[?1049l").expect("altscreen exit");
    let style_reset = left.find("\x1b[0 q").expect("cursor style reset");
    let title_pop = left.find("\x1b[23;0t").expect("title pop");
    assert!(
        style_reset > altscreen_off && title_pop > altscreen_off,
        "verb resets apply to the main screen: {left:?}"
    );
    drop(term);
    unsafe { libc::close(master) };
}

#[test]
fn pty_suspend_restores_then_reenters() {
    // deliver_stop() is a no-op under cfg(test) — a real kill(0, SIGTSTP)
    // would stop the whole test process group. This exercises everything
    // else: full restore, then a fresh enter with the same options.
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    let opts = EnterOptions {
        kitty_keyboard: KittyFlags::standard(),
        ..EnterOptions::default()
    };
    term.enter(&opts).expect("enter");
    term.set_cursor_style(crate::term::CursorStyle::SteadyBar)
        .unwrap();
    term.flush().unwrap();
    let _ = read_master(master, 100);

    term.suspend().expect("suspend round-trip");
    let bytes = read_master(master, 200);
    let s = String::from_utf8_lossy(&bytes);
    // Restore then re-enter, in that order, including the verb reset.
    let off = s.find("\x1b[?1049l").expect("left altscreen");
    let style_reset = s.find("\x1b[0 q").expect("style reset on suspend");
    let back_on = s.rfind("\x1b[?1049h").expect("re-entered altscreen");
    let kitty_back = s.rfind("\x1b[>3u").expect("kitty flags re-pushed");
    assert!(
        off < back_on && style_reset < back_on && kitty_back > off,
        "{s:?}"
    );
    // Kitty + bracketed-paste interplay across the suspend boundary:
    // leave pops kitty flags BEFORE dropping paste mode (the pop's reply
    // bytes, if any, drain while our modes still absorb them), and the
    // re-enter restores paste BEFORE pushing kitty — the exact mirror.
    let kitty_pop = s.find("\x1b[<u").expect("kitty pop on suspend");
    let paste_off = s.find("\x1b[?2004l").expect("paste off on suspend");
    let paste_back = s.rfind("\x1b[?2004h").expect("paste re-enabled");
    assert!(kitty_pop < paste_off, "pop before paste-off: {s:?}");
    assert!(paste_off < paste_back, "restore after teardown: {s:?}");
    assert!(paste_back < kitty_back, "paste on before kitty push: {s:?}");
    // Session is live again: reads and writes still work.
    term.write(b"post").unwrap();
    term.flush().unwrap();
    assert_eq!(read_master(master, 200), b"post");
    term.leave().unwrap();
    drop(term);
    unsafe { libc::close(master) };
}

#[test]
fn pty_runtime_kitty_push_pops_on_suspend_repushes_on_resume_and_pops_on_leave() {
    // Backlog 0293: the probe-driven runtime push
    // (`Terminal::set_kitty_keyboard`) must ride the SESSION accounting —
    // enter carried no flags, the upgrade pushes, and from then on
    // leave/suspend behave exactly as if enter had pushed: suspend pops
    // before teardown, resume re-pushes after re-enter, leave pops.
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    term.enter(&EnterOptions::default()).expect("enter"); // no kitty flags
    let entered = read_master(master, 100);
    assert!(
        !String::from_utf8_lossy(&entered).contains("\x1b[>"),
        "enter must not push: env could not claim the protocol"
    );

    term.set_kitty_keyboard(KittyFlags::standard()).unwrap();
    term.flush().unwrap();
    let s = String::from_utf8_lossy(&read_master(master, 100)).into_owned();
    assert!(s.contains("\x1b[>3u"), "runtime push: {s:?}");
    assert!(!s.contains("\x1b[<u"), "first push pops nothing: {s:?}");

    // Same flags again: zero bytes (idempotent).
    term.set_kitty_keyboard(KittyFlags::standard()).unwrap();
    term.flush().unwrap();
    assert!(
        read_master(master, 60).is_empty(),
        "re-setting identical flags must emit nothing"
    );

    term.suspend().expect("suspend round-trip");
    let s = String::from_utf8_lossy(&read_master(master, 200)).into_owned();
    let pop = s.find("\x1b[<u").expect("suspend pops the runtime push");
    let off = s.find("\x1b[?1049l").expect("left altscreen");
    let back = s.rfind("\x1b[?1049h").expect("re-entered altscreen");
    let repush = s.rfind("\x1b[>3u").expect("resume re-pushes");
    assert!(
        pop < off && back < repush,
        "pop inside teardown, re-push inside re-enter: {s:?}"
    );

    term.leave().expect("leave");
    let s = String::from_utf8_lossy(&read_master(master, 100)).into_owned();
    assert_eq!(
        s.matches("\x1b[<u").count(),
        1,
        "final leave pops exactly the one live push: {s:?}"
    );
    drop(term);
    unsafe { libc::close(master) };
}

#[test]
fn pty_resize_via_sigwinch_pipe() {
    let Some((master, slave)) = open_pty() else {
        eprintln!("skipping: openpty unavailable in this environment");
        return;
    };
    let mut term = UnixTerminal::from_fds(slave, slave, true);
    let mut ws: libc::winsize = unsafe { mem::zeroed() };
    ws.ws_col = 80;
    ws.ws_row = 24;
    unsafe { libc::ioctl(master, libc::TIOCSWINSZ as IoctlReq, &ws) };
    term.enter(&EnterOptions::default()).expect("enter");
    let _ = read_master(master, 100); // discard mode bytes

    ws.ws_col = 132;
    ws.ws_row = 43;
    unsafe {
        libc::ioctl(master, libc::TIOCSWINSZ as IoctlReq, &ws);
        // The kernel raises SIGWINCH for the pty's foreground process
        // group, which a test harness usually is not — raise explicitly
        // to exercise handler -> pipe -> poll wake -> ioctl compare.
        libc::kill(libc::getpid(), libc::SIGWINCH);
    }
    match term.read(Some(Instant::now() + Duration::from_secs(2))) {
        Ok(TermRead::Resize(sz)) => assert_eq!(sz, Size::new(132, 43)),
        other => panic!("expected resize, got {other:?}"),
    }
    term.leave().unwrap();
    drop(term);
    unsafe { libc::close(master) };
}