//! Cross-platform input byte buffer + decoder state. Backends (posix Tty, //! windows Tty) `append` raw VT bytes and call `next` to pop one Msg at a //! time. Bracketed-paste accumulation lives here so both backends share it. const std = @import("std"); const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const input = @import("input.zig"); const Msg = @import("msg.zig").Msg; const InputBuffer = @This(); /// Capacity of the inline pending buffer. Larger than any single escape /// sequence we expect; overflow simply drops the oldest bytes. pub const PENDING_CAP: usize = 512; gpa: Allocator, pending: [PENDING_CAP]u8 = undefined, pending_len: usize = 0, /// Bracketed-paste accumulation. While `in_paste`, incoming bytes are /// appended to `paste_buf` instead of being parsed. On `\x1b[201~`, the /// buffer is dup'd and emitted as a `paste` Msg. in_paste: bool = false, paste_buf: ArrayList(u8) = .empty, /// One-slot queue for emitting `paste` + `paste_end` after `paste_start`. /// `next` returns at most one Msg, so the trailing event waits here until /// the next call. queued_msg: ?Msg = null, pub fn init(gpa: Allocator) InputBuffer { return .{ .gpa = gpa }; } pub fn deinit(self: *InputBuffer) void { self.paste_buf.deinit(self.gpa); } /// Append raw bytes to `pending`. On overflow the oldest bytes are dropped. pub fn append(self: *InputBuffer, bytes: []const u8) void { if (bytes.len == 0) return; if (self.pending_len + bytes.len > self.pending.len) { const drop = self.pending_len + bytes.len - self.pending.len; if (drop >= self.pending_len) { self.pending_len = 0; } else { std.mem.copyForwards( u8, self.pending[0 .. self.pending_len - drop], self.pending[drop..self.pending_len], ); self.pending_len -= drop; } } const n = @min(bytes.len, self.pending.len); @memcpy(self.pending[self.pending_len..][0..n], bytes[bytes.len - n ..]); self.pending_len += n; } /// Try to decode one Msg from buffered bytes. Returns null when nothing is /// ready (either the buffer is empty or contains only an incomplete prefix). pub fn next(self: *InputBuffer) !?Msg { if (self.queued_msg) |m| { self.queued_msg = null; return m; } if (self.in_paste) return try self.consumePaste(); var off: usize = 0; while (off < self.pending_len) { const r = input.parse(self.pending[off..self.pending_len]) catch |err| switch (err) { error.Incomplete => break, error.Invalid => { off += 1; continue; }, }; off += r.consumed; self.consume(off); if (r.msg == .paste_start) { self.in_paste = true; self.paste_buf.clearRetainingCapacity(); } return r.msg; } if (off > 0) self.consume(off); return null; } /// While `in_paste`, scan pending for the end marker. Append bytes up to it /// into `paste_buf`; when found, dup the buffer as the `paste` payload and /// queue `paste_end` for the next call. fn consumePaste(self: *InputBuffer) !?Msg { const end_marker = "\x1b[201~"; if (std.mem.indexOf(u8, self.pending[0..self.pending_len], end_marker)) |idx| { try self.paste_buf.appendSlice(self.gpa, self.pending[0..idx]); self.consume(idx + end_marker.len); const content = try self.gpa.dupe(u8, self.paste_buf.items); self.paste_buf.clearRetainingCapacity(); self.in_paste = false; self.queued_msg = .paste_end; return .{ .paste = content }; } // No end marker yet — drain bytes that can't be part of the marker // prefix into paste_buf, keep the trailing suspicious bytes in pending // so the next read can complete the match. const keep = @min(self.pending_len, end_marker.len - 1); const drain_to = self.pending_len - keep; if (drain_to > 0) { try self.paste_buf.appendSlice(self.gpa, self.pending[0..drain_to]); self.consume(drain_to); } return null; } fn consume(self: *InputBuffer, count: usize) void { if (count == 0) return; std.mem.copyForwards( u8, self.pending[0 .. self.pending_len - count], self.pending[count..self.pending_len], ); self.pending_len -= count; } /// True when the pending bytes begin with an ESC whose sequence hasn't /// completed yet (lone ESC, alt-chord prefix, or truncated CSI). Real /// terminals send each sequence in a single write, so callers arm a short /// read timeout when this is set and treat continued silence as a bare /// ESC press. pub fn awaitingEscape(self: *InputBuffer) bool { if (self.in_paste) return false; if (self.pending_len == 0 or self.pending[0] != 0x1b) return false; _ = input.parse(self.pending[0..self.pending_len]) catch |err| return err == error.Incomplete; return false; } /// Consumes the pending leading ESC and returns it as a plain escape /// keypress. Call once the disambiguation timeout has expired with no /// further bytes arriving. pub fn flushEscape(self: *InputBuffer) ?Msg { if (!self.awaitingEscape()) return null; self.consume(1); return .{ .key_press = .{ .kind = .escape } }; } test "decodes single key" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("a"); const msg = (try buf.next()).?; try std.testing.expectEqual(Msg.Key.Kind.rune, msg.key_press.kind); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); } test "splits across appends" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1b["); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); buf.append("A"); const msg = (try buf.next()).?; try std.testing.expectEqual(Msg.Key.Kind.up, msg.key_press.kind); } test "paste delivers start, content, end" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1b[200~hello world\x1b[201~"); try std.testing.expectEqual(Msg.paste_start, (try buf.next()).?); const paste_msg = (try buf.next()).?; try std.testing.expectEqualStrings("hello world", paste_msg.paste); std.testing.allocator.free(paste_msg.paste); try std.testing.expectEqual(Msg.paste_end, (try buf.next()).?); } test "paste across multiple appends" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1b[200~"); try std.testing.expectEqual(Msg.paste_start, (try buf.next()).?); buf.append("part one "); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); buf.append("part two\x1b[201~"); const paste_msg = (try buf.next()).?; try std.testing.expectEqualStrings("part one part two", paste_msg.paste); std.testing.allocator.free(paste_msg.paste); try std.testing.expectEqual(Msg.paste_end, (try buf.next()).?); } test "paste end marker split across appends" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1b[200~data"); try std.testing.expectEqual(Msg.paste_start, (try buf.next()).?); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); buf.append("\x1b["); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); buf.append("201~"); const paste_msg = (try buf.next()).?; try std.testing.expectEqualStrings("data", paste_msg.paste); std.testing.allocator.free(paste_msg.paste); try std.testing.expectEqual(Msg.paste_end, (try buf.next()).?); } test "incomplete escape prefix awaits more bytes" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1b["); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); try std.testing.expect(buf.awaitingEscape()); buf.append("A"); const msg = (try buf.next()).?; try std.testing.expectEqual(Msg.Key.Kind.up, msg.key_press.kind); try std.testing.expect(!buf.awaitingEscape()); } test "lone escape waits then flushes as escape key" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1b"); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); try std.testing.expect(buf.awaitingEscape()); const esc = buf.flushEscape().?; try std.testing.expectEqual(Msg.Key.Kind.escape, esc.key_press.kind); try std.testing.expectEqual(@as(?Msg, null), try buf.next()); try std.testing.expect(!buf.awaitingEscape()); } test "escape plus rune stays an alt chord" { var buf: InputBuffer = .init(std.testing.allocator); defer buf.deinit(); buf.append("\x1bj"); try std.testing.expect(!buf.awaitingEscape()); const msg = (try buf.next()).?; try std.testing.expect(msg.key_press.mods.alt); try std.testing.expectEqual(@as(u21, 'j'), msg.key_press.rune); }