summaryrefslogtreecommitdiff
path: root/tools/console.zig
blob: 39a829bfd01059d0b10b07f11fd96a2910881c1d (plain) (blame)
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
//! An interactive terminal on the far side of the serial port.
//!
//! `MonitorStep` prints the board's output for N seconds and never sends anything. That is the
//! right tool for a program that only reports. It is the wrong tool for a program the human is
//! supposed to *use*, which needs the host's keystrokes on the wire and the host's terminal out of
//! the way. So this is the other half: raw-mode stdin forwarded to the UART, UART forwarded to
//! stdout, until the escape byte.
//!
//! The division of labour is the interesting part. The board runs the application and emits ANSI;
//! the host terminal emulator (ghostty here) does the actual terminal work - fonts, scrollback,
//! selection - and answers the application's capability queries itself. This process is a wire, and
//! deliberately almost transparent: a `\x1b[?1049h` from the board reaches ghostty, ghostty's reply
//! to a `CSI c` reaches the board, and neither end needs to know there are 3 metres of USB cable
//! and a CH340 in between.
//!
//! Almost transparent, because of one thing a wire cannot pass through: **size**. A terminal
//! application learns its window size from `ioctl(TIOCGWINSZ)`, and firmware has no ioctl. DEC mode
//! 2048 ("in-band resize") solves half of it - a terminal that has been sent `\x1b[?2048h` reports
//! every subsequent resize as `CSI 48 ; rows ; cols ; ypix ; xpix t`, and those reports flow down
//! the wire like any other bytes. The half it does not solve is the *first* size, because a change
//! notification is only sent on a change. This process owns the real tty, so it is the only party
//! that can answer, and it injects that same sequence itself:
//!
//!   * once at attach, for a board that never asks;
//!   * on SIGWINCH, because the host's window changed and mode 2048 may not be enabled;
//!   * and on seeing `\x1b[?2048h` come *back* from the board - which is the exact moment the
//!     application has declared itself ready to understand one. That is the deterministic trigger;
//!     the other two are belt and braces.
//!
//! Injecting a resize report the host terminal would also have sent is harmless: it carries the
//! size as data, so a duplicate is idempotent, and `vaxis`'s parser (`src/Parser.zig:528-551`)
//! treats both identically because they are byte-for-byte the same sequence.

const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const serial = @import("serial.zig");

/// Ctrl-] , telnet's escape and not a key any full-screen application binds. Ctrl-C, Ctrl-Q and
/// Ctrl-Z all had to be rejected: an editor wants every one of them, and a bridge that swallowed
/// them would be lying about being transparent.
pub const escape_byte: u8 = 0x1d;

/// Set by the SIGWINCH handler, read by the loop. `volatile` rather than atomic because a signal
/// handler on the same thread is not a concurrent writer - it is an interruption - and this only
/// needs the compiler to stop caching the load.
var winch_pending: bool = false;

/// The handler takes `posix.SIG`, not an int: std's `Sigaction.handler_fn` is
/// `*align(1) const fn (SIG) callconv(.c) void` (std/os/linux.zig:6026).
fn onWinch(_: posix.SIG) callconv(.c) void {
    @as(*volatile bool, &winch_pending).* = true;
}

const Winsize = extern struct { row: u16, col: u16, xpixel: u16, ypixel: u16 };
const TIOCGWINSZ = 0x5413;

fn windowSize() Winsize {
    var ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
    // A failure here is not fatal: 80x24 is a defensible terminal, and the alternative is refusing
    // to attach because the size could not be read.
    _ = linux.ioctl(0, TIOCGWINSZ, @intFromPtr(&ws));
    if (ws.row == 0) ws.row = 24;
    if (ws.col == 0) ws.col = 80;
    return ws;
}

/// The in-band resize report, in the form `vaxis` parses: `CSI 48 ; rows ; cols ; ypix ; xpix t`.
/// Note the pixel fields are height-then-width, which is the opposite order from the
/// `struct winsize` they come out of - `Parser.zig:536-539` reads height first.
fn sendWinsize(port: *serial.Port, ws: Winsize) void {
    var buf: [64]u8 = undefined;
    const seq = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};{d};{d}t", .{
        ws.row, ws.col, ws.ypixel, ws.xpixel,
    }) catch return;
    port.write(seq) catch {};
}

/// Matches `\x1b[?2048h` in the board's output stream one byte at a time, because the sequence can
/// be split across reads. Returns true on the byte that completes it.
const ModeWatch = struct {
    const want = "\x1b[?2048h";
    at: usize = 0,

    fn feed(m: *ModeWatch, byte: u8) bool {
        if (byte == want[m.at]) {
            m.at += 1;
            if (m.at == want.len) {
                m.at = 0;
                return true;
            }
        } else {
            // Restart, and allow this byte to be a fresh start - otherwise "\x1b\x1b[?2048h" is
            // missed.
            m.at = if (byte == want[0]) 1 else 0;
        }
        return false;
    }
};

/// How long a motion report may be held while a newer one might replace it.
///
/// A report is ~12 bytes, so 40 ms caps drag traffic at 25 reports a second - about 300 B/s, or 2.6%
/// of a 115200 line. Below roughly 30 ms the thinning stops paying for itself on this link; far above
/// it a drag visibly lags the pointer.
const coalesce_ms: i64 = 40;

/// Thins out mouse reports on their way to the board.
///
/// The board asks for DEC 1002, so the terminal reports presses, releases, and motion WHILE A BUTTON
/// IS HELD. A press is one report; a drag across this grid is one report per cell crossed, each
/// `\x1b[<0;12;5M` at roughly a dozen bytes. A hand can cross forty cells in a tenth of a second,
/// which is ~500 bytes, which is 43 ms of a 115200 line - and every one of those bytes is input the
/// board has to parse while it is trying to paint the result of the previous one. Unthinned, a drag
/// makes the editor unusable for as long as the drag lasts and for a while after.
///
/// The thinning is NEWEST-WINS, and only for motion. Where the pointer passed through is not
/// information the editor can use - a selection is defined by where the drag started and where it is
/// now - so an intermediate report that is already stale by the time it reaches the wire is pure
/// cost. Presses, releases and wheel events are never held: those are discrete, each one means
/// something different, and dropping one loses a click.
///
/// A held report is released when the interval expires or when any non-motion byte follows it, so a
/// drag that stops moving still delivers its final position, and a release always arrives after the
/// motion that preceded it.
const MouseFilter = struct {
    /// The most recent motion report not yet sent, if any.
    held: [max_report]u8 = undefined,
    held_len: usize = 0,
    /// A report arriving in pieces across reads. SGR reports are short, but a read boundary can
    /// still land inside one, and a half-parsed report must not be forwarded as loose bytes.
    partial: [max_report]u8 = undefined,
    partial_len: usize = 0,
    dropped: usize = 0,

    /// Longest `\x1b[<b;x;yM` this will accept. Generous for a 5-digit coordinate each way; anything
    /// longer is not a mouse report and is passed through as ordinary bytes.
    const max_report = 24;

    /// Split `in` into bytes to send now and, possibly, one motion report to hold.
    ///
    /// Returns the number of bytes written to `out`, which is never more than `in.len` plus whatever
    /// a previously held report contributes.
    fn feed(m: *MouseFilter, in: []const u8, out: []u8) usize {
        var n: usize = 0;
        var i: usize = 0;
        while (i < in.len) {
            // Continue a report that straddled the previous read.
            if (m.partial_len > 0) {
                m.partial[m.partial_len] = in[i];
                m.partial_len += 1;
                i += 1;
                switch (classify(m.partial[0..m.partial_len])) {
                    .incomplete => if (m.partial_len < max_report) continue else {
                        // Too long to be a report: it was never one, so pass it on untouched.
                        n += m.flushHeld(out[n..]);
                        @memcpy(out[n..][0..m.partial_len], m.partial[0..m.partial_len]);
                        n += m.partial_len;
                        m.partial_len = 0;
                        continue;
                    },
                    .motion => {
                        m.hold(m.partial[0..m.partial_len]);
                        m.partial_len = 0;
                        continue;
                    },
                    .other => {
                        n += m.flushHeld(out[n..]);
                        @memcpy(out[n..][0..m.partial_len], m.partial[0..m.partial_len]);
                        n += m.partial_len;
                        m.partial_len = 0;
                        continue;
                    },
                }
            }
            // A report can only start at an ESC.
            if (in[i] == 0x1b) {
                m.partial[0] = in[i];
                m.partial_len = 1;
                i += 1;
                continue;
            }
            // Ordinary byte: it orders after anything held, so the held report goes first.
            n += m.flushHeld(out[n..]);
            out[n] = in[i];
            n += 1;
            i += 1;
        }
        return n;
    }

    fn hold(m: *MouseFilter, report: []const u8) void {
        if (m.held_len > 0) m.dropped += 1;
        @memcpy(m.held[0..report.len], report);
        m.held_len = report.len;
    }

    /// Emit the held report, if there is one. Called when ordering requires it and by the caller when
    /// the coalescing interval expires.
    fn flushHeld(m: *MouseFilter, out: []u8) usize {
        if (m.held_len == 0) return 0;
        @memcpy(out[0..m.held_len], m.held[0..m.held_len]);
        const n = m.held_len;
        m.held_len = 0;
        return n;
    }

    fn pending(m: *const MouseFilter) bool {
        return m.held_len > 0;
    }

    const Kind = enum { incomplete, motion, other };

    /// Is `bytes` a complete SGR mouse report, and is it motion?
    ///
    /// `\x1b[<` then decimal parameters separated by `;` then `M` (press or motion) or `m` (release).
    /// Motion is the low two bits of the button field being 3 for a plain move, or bit 5 (32) set for
    /// a drag; a wheel report has bit 6 (64) set and is never motion however it is encoded.
    fn classify(bytes: []const u8) Kind {
        if (bytes.len < 3) {
            const prefix = "\x1b[<";
            return if (std.mem.startsWith(u8, prefix, bytes)) .incomplete else .other;
        }
        if (!std.mem.startsWith(u8, bytes, "\x1b[<")) return .other;
        var button: u32 = 0;
        var digits: usize = 0;
        var i: usize = 3;
        while (i < bytes.len) : (i += 1) {
            const b = bytes[i];
            if (b >= '0' and b <= '9') {
                if (digits == 0) button = button * 10 + (b - '0');
                if (button > 1 << 20) return .other;
                continue;
            }
            if (b == ';') {
                digits += 1;
                continue;
            }
            if (b == 'M' or b == 'm') {
                if (digits != 2) return .other;
                const wheel = button & 64 != 0;
                const drag = button & 32 != 0;
                return if (!wheel and drag) .motion else .other;
            }
            return .other;
        }
        return .incomplete;
    }
};

pub const Options = struct {
    /// Pulse reset so the application starts from boot with the console already attached. Without
    /// it, attaching to a board that has been running for a while shows a screen mid-session with
    /// no redraw until something changes.
    reset: bool = true,
    /// Print the escape-key hint. Suppressed for scripted runs, whose output is being asserted on.
    banner: bool = true,
};

/// Forward bytes both ways until the escape byte arrives on stdin.
///
/// Returns normally on escape; the terminal is always restored, including on error, because the
/// alternative is handing the user back a shell with no echo.
pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void {
    var port = try serial.Port.open(port_path, baud);
    defer port.close();

    // No `isatty`: `tcgetattr` answers the same question with the same syscall this needs anyway,
    // and a null here means "stdin is a pipe" - which is a supported way to run this, for scripted
    // sessions whose input is a file.
    const saved: ?posix.termios = posix.tcgetattr(0) catch null;
    if (saved) |prev| {
        var raw = prev;
        // The same raw mode `serial.Port.open` builds for the port, for the same reason: every byte
        // the user types has to reach the board unmodified, including the ones the line discipline
        // would otherwise interpret. ISIG off is what lets Ctrl-C reach the application instead of
        // killing this process.
        raw.lflag.ICANON = false;
        raw.lflag.ECHO = false;
        raw.lflag.ISIG = false;
        raw.lflag.IEXTEN = false;
        raw.iflag.IXON = false;
        raw.iflag.ICRNL = false;
        raw.iflag.INLCR = false;
        raw.iflag.BRKINT = false;
        raw.oflag.OPOST = false;
        try posix.tcsetattr(0, .FLUSH, raw);
    }
    defer if (saved) |prev| posix.tcsetattr(0, .FLUSH, prev) catch {};

    // Installed after raw mode so a resize during setup cannot be missed-but-flagged.
    posix.sigaction(posix.SIG.WINCH, &.{
        .handler = .{ .handler = onWinch },
        .mask = posix.sigemptyset(),
        .flags = 0,
    }, null);

    // stdin/stdout as `std.Io.File`, because std 0.16 has no `posix.read`/`posix.write` any more -
    // byte traffic goes through std.Io. The `io` is borrowed from the port, which already holds the
    // single-threaded instance `serial.Port.open` created.
    const io = port.io;
    const stdin: std.Io.File = .{ .handle = 0, .flags = .{ .nonblocking = false } };
    const stdout: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };

    if (opts.banner) {
        var hint: [96]u8 = undefined;
        const line = std.fmt.bufPrint(&hint, "[zig-p4 console @ {d} baud - Ctrl-] to detach]\r\n", .{
            baud.rate(),
        }) catch "[zig-p4 console - Ctrl-] to detach]\r\n";
        stdout.writeStreamingAll(io, line) catch {};
    }

    if (opts.reset) try port.resetToRun(.{});
    sendWinsize(&port, windowSize());

    var watch: ModeWatch = .{};
    var mouse: MouseFilter = .{};
    var held_at: ?i64 = null;
    var from_board: [4096]u8 = undefined;
    var from_user: [256]u8 = undefined;
    var to_board: [512]u8 = undefined;

    while (true) {
        if (@as(*volatile bool, &winch_pending).*) {
            @as(*volatile bool, &winch_pending).* = false;
            sendWinsize(&port, windowSize());
        }

        var pfd = [_]posix.pollfd{
            .{ .fd = 0, .events = posix.POLL.IN, .revents = 0 },
            .{ .fd = port.file.handle, .events = posix.POLL.IN, .revents = 0 },
        };
        // A bounded wait rather than an infinite one so a SIGWINCH that lands between the check
        // above and the poll below is still serviced promptly; poll reports the signal itself as
        // an interrupt, which is handled as "go round again".
        // A bounded wait, and shorter while a motion report is being held: the hold has to end on
        // time even when the human has stopped moving the mouse and nothing else is arriving.
        const wait: i32 = if (held_at) |at| blk: {
            const left = coalesce_ms - (port.nowMs() - at);
            break :blk if (left <= 0) 0 else @intCast(left);
        } else 200;
        const ready = posix.poll(&pfd, wait) catch continue;
        if (ready == 0 and held_at == null) continue;

        // POLL.IN is not the only thing poll reports, and ignoring the rest is a hot spin, not a
        // no-op: unplug the CH340 mid-session and the port's revents carries HUP|ERR|NVAL forever.
        // poll then returns immediately with a non-zero count, neither branch below matches because
        // both test POLL.IN, and the loop burns a core with stdin still in raw mode and ISIG off.
        // `std.posix.poll` cannot surface it as an error either - it maps INVAL to `unreachable`
        // (std/posix.zig:1007-1017) because a dead descriptor is reported in `revents`, not errno.
        const gone = posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL;
        if (pfd[1].revents & gone != 0) return error.PortDisconnected;
        // stdin dying is ordinary: a pipe ran out, or the terminal closed. Detach quietly.
        if (pfd[0].revents & gone != 0) return;

        if (pfd[1].revents & posix.POLL.IN != 0) {
            const n = port.read(&from_board) catch 0;
            if (n > 0) {
                stdout.writeStreamingAll(io, from_board[0..n]) catch {};
                for (from_board[0..n]) |b| {
                    if (watch.feed(b)) sendWinsize(&port, windowSize());
                }
            }
        }

        if (pfd[0].revents & posix.POLL.IN != 0) {
            const n = stdin.readStreaming(io, &.{&from_user}) catch 0;
            if (n == 0) return; // stdin closed: a pipe ran out, so detach
            // The escape byte is looked for in the RAW stream, before any filtering: Ctrl-] has to
            // detach whatever else is in flight, including a half-parsed mouse report.
            var raw = from_user[0..n];
            const detaching = std.mem.indexOfScalar(u8, raw, escape_byte);
            if (detaching) |cut| raw = raw[0..cut];
            const send = mouse.feed(raw, &to_board);
            if (send > 0) port.write(to_board[0..send]) catch {};
            if (detaching != null) {
                // Anything still held belongs to the board before we go.
                const tail = mouse.flushHeld(&to_board);
                if (tail > 0) port.write(to_board[0..tail]) catch {};
                if (opts.banner) stdout.writeStreamingAll(io, "\r\n[detached]\r\n") catch {};
                return;
            }
        }

        // The coalescing window. A held motion report goes out when the interval has elapsed, which
        // is what turns a drag into a bounded stream of positions rather than one per cell crossed.
        if (mouse.pending()) {
            const at = held_at orelse port.nowMs();
            held_at = at;
            if (port.nowMs() - at >= coalesce_ms) {
                const send = mouse.flushHeld(&to_board);
                if (send > 0) port.write(to_board[0..send]) catch {};
                held_at = null;
            }
        } else held_at = null;
    }
}

test "ModeWatch completes only on the full sequence" {
    var m: ModeWatch = .{};
    for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
    try std.testing.expect(m.feed('h'));
}

test "ModeWatch resynchronises on a false start" {
    var m: ModeWatch = .{};
    // A prefix that dies, then the real thing immediately after: the naive reset-to-zero misses
    // this because the byte that broke the match is itself the next match's ESC.
    for ("\x1b[?20") |b| try std.testing.expect(!m.feed(b));
    for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
    try std.testing.expect(m.feed('h'));
}

test "ModeWatch ignores unrelated traffic" {
    var m: ModeWatch = .{};
    for ("hello \x1b[?1049h world \x1b[0m") |b| try std.testing.expect(!m.feed(b));
}

// ------------------------------------------------------------------------------- the mouse filter

test "MouseFilter passes an ordinary keystroke straight through" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    const n = m.feed("hello", &out);
    try std.testing.expectEqualStrings("hello", out[0..n]);
    try std.testing.expect(!m.pending());
}

test "MouseFilter never holds a press, a release or a wheel" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    // 0 = left press, 0 with 'm' = release, 64/65 = wheel up/down. All discrete: dropping one loses
    // a click or a scroll notch, so none of them may be coalesced.
    for ([_][]const u8{ "\x1b[<0;10;5M", "\x1b[<0;10;5m", "\x1b[<64;10;5M", "\x1b[<65;10;5M" }) |report| {
        const n = m.feed(report, &out);
        try std.testing.expectEqualStrings(report, out[0..n]);
        try std.testing.expect(!m.pending());
    }
}

test "MouseFilter keeps only the newest drag position" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    // 32 = motion with the left button held: a drag. Three cells crossed in one read.
    const n = m.feed("\x1b[<32;10;5M\x1b[<32;11;5M\x1b[<32;12;5M", &out);
    try std.testing.expectEqual(@as(usize, 0), n); // nothing goes out yet
    try std.testing.expect(m.pending());
    try std.testing.expectEqual(@as(usize, 2), m.dropped);
    const flushed = m.flushHeld(&out);
    try std.testing.expectEqualStrings("\x1b[<32;12;5M", out[0..flushed]);
}

test "MouseFilter releases a held drag before anything that follows it" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    // The release must not overtake the motion that preceded it, or the editor ends a selection at
    // the wrong cell.
    const n = m.feed("\x1b[<32;10;5M\x1b[<0;12;5m", &out);
    try std.testing.expectEqualStrings("\x1b[<32;10;5M\x1b[<0;12;5m", out[0..n]);
    try std.testing.expect(!m.pending());
}

test "MouseFilter holds a drag across a read boundary" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    // A report split by the read: neither half may reach the board as loose bytes.
    try std.testing.expectEqual(@as(usize, 0), m.feed("\x1b[<32;10", &out));
    try std.testing.expectEqual(@as(usize, 0), m.feed(";5M", &out));
    try std.testing.expect(m.pending());
    const flushed = m.flushHeld(&out);
    try std.testing.expectEqualStrings("\x1b[<32;10;5M", out[0..flushed]);
}

test "MouseFilter passes a non-mouse escape sequence through unchanged" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    // An arrow key and an in-band resize report both start with ESC and must survive intact.
    const n = m.feed("\x1b[A\x1b[48;12;40;0;0t", &out);
    try std.testing.expectEqualStrings("\x1b[A\x1b[48;12;40;0;0t", out[0..n]);
    try std.testing.expect(!m.pending());
}

test "MouseFilter does not swallow a lone escape" {
    var m: MouseFilter = .{};
    var out: [64]u8 = undefined;
    // Esc is how you leave insert mode; holding it would be the worst possible bug here.
    _ = m.feed("\x1b", &out);
    const n = m.feed("x", &out);
    try std.testing.expectEqualStrings("\x1bx", out[0..n]);
}