Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244//! 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);}