From f37c42a3b324131c131d066767968e1f5b976453 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 8 Sep 2026 15:03:59 +0000 Subject: [PATCH] vaxis: add cursor position queries with lazy expiry Cursor position reports share their encoding with legacy F3 keys, so applications cannot retrieve coordinates through the normal event loop. Add queryCursorPosition and deliver zero-based cursor_position events while requests are outstanding. Share a synchronized count and monotonic last-request timestamp with the parser, expiring the batch lazily after one second of inactivity. Preserve key parsing when no request is pending and prevent overlap with terminal capability probes. Document custom-loop wiring and protocol ambiguity, keep C event bindings in sync, and test counting, malformed input, expiry, and event delivery. Co-authored-by: Tim Culverhouse --- USAGE.md | 40 ++++++++++ include/vaxis.h | 6 ++ src/Loop.zig | 71 +++++++++++++++++- src/Parser.zig | 194 ++++++++++++++++++++++++++++++++++++++++++++---- src/Vaxis.zig | 42 +++++++++++ src/c_api.zig | 36 +++++++++ src/event.zig | 2 + 7 files changed, 375 insertions(+), 16 deletions(-) diff --git a/USAGE.md b/USAGE.md index 221bd20..fdbbf25 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,5 +1,45 @@ # Usage +## Querying the Cursor Position + +Add `cursor_position: vaxis.Screen.Cursor` to your event union, then request a +report after terminal capability discovery has completed: + +```zig +try vx.queryCursorPosition(tty.writer()); + +// In your event switch: +// .cursor_position => |position| { ... } +``` + +The event contains zero-based `row` and `col`. Each request increments a shared +counter; each valid report decrements it. Multiple requests can be outstanding. +Incomplete or malformed reports do not consume a request, and legacy F3 parsing +resumes when the counter reaches zero. + +Pending requests expire **one second after the last request**, using a monotonic +clock. Expiry is checked lazily when sending a query or parsing a possible report; +there is no timer and no timeout event. New requests extend the idle deadline for +the whole pending batch; responses do not. A query after expiry starts a fresh +batch. Continuous queries can therefore keep the window open. + +A legacy F3 sequence identical to a report cannot be distinguished while a +request is pending. Likewise, a late reply after expiry can be interpreted as F3 +or consume a newer request; the protocol has no request IDs. + +Cursor queries return `error.TerminalQueriesPending` during capability discovery. +Conversely, capability discovery returns `error.CursorPositionQueriesPending` +while cursor queries are outstanding, because both use the same report sequence. + +`vaxis.Loop` connects the parser to the counter automatically. For a custom event +loop, initialize the parser with a pointer to your stable Vaxis instance: + +```zig +var parser: vaxis.Parser = .{ + .cursor_position_requests = &vx.cursor_position_requests, +}; +``` + ## Custom Event Loops Vaxis provides an abstract enough API to allow the usage of a custom event loop. diff --git a/include/vaxis.h b/include/vaxis.h index 713689c..cdc5b60 100644 --- a/include/vaxis.h +++ b/include/vaxis.h @@ -91,6 +91,8 @@ typedef enum VAXIS_ENUM_TYPED { VAXIS_EVENT_CAP_COLOR_SCHEME_UPDATES = 19, VAXIS_EVENT_CAP_MULTI_CURSOR = 20, + VAXIS_EVENT_CURSOR_POSITION = 21, + /* Sentinel to pin the enum to int width; never produced. */ VAXIS_EVENT_TYPE_MAX_VALUE = VAXIS_ENUM_MAX_VALUE, } vaxis_event_type; @@ -525,6 +527,10 @@ uint16_t vaxis_event_winsize_cols(const vaxis_event *event); uint16_t vaxis_event_winsize_x_pixel(const vaxis_event *event); uint16_t vaxis_event_winsize_y_pixel(const vaxis_event *event); +/* Cursor position accessors (VAXIS_EVENT_CURSOR_POSITION), zero-based. */ +uint16_t vaxis_event_cursor_position_row(const vaxis_event *event); +uint16_t vaxis_event_cursor_position_col(const vaxis_event *event); + /* Look up a key codepoint by name ("enter", "f1", "kp_0", ...). Returns 0 * for unknown names. */ uint32_t vaxis_key_from_name(const char *name, size_t name_len); diff --git a/src/Loop.zig b/src/Loop.zig index 958b72e..6ff5f66 100644 --- a/src/Loop.zig +++ b/src/Loop.zig @@ -168,10 +168,10 @@ pub fn Loop(comptime T: type) type { // initialize a grapheme cache var cache: GraphemeCache = .{}; + var parser: Parser = .{ .cursor_position_requests = &self.vaxis.cursor_position_requests }; switch (builtin.os.tag) { .windows => { - var parser: Parser = .{}; while (!self.should_quit) { const event = try self.tty.nextEvent(&parser, paste_allocator); try handleEventGeneric(self, self.vaxis, &cache, Event, event, null); @@ -184,8 +184,6 @@ pub fn Loop(comptime T: type) type { try self.postEvent(.{ .winsize = winsize }); } - var parser: Parser = .{}; - // initialize the read buffer var buf: [1024]u8 = undefined; var read_start: usize = 0; @@ -236,6 +234,12 @@ pub fn Loop(comptime T: type) type { // Use return on the self.postEvent's so it can either return error union or void pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Event: type, event: anytype, paste_allocator: ?std.mem.Allocator) !void { + if (event == .cursor_position) { + if (@hasField(Event, "cursor_position")) { + return self.postEvent(.{ .cursor_position = event.cursor_position }); + } + return; + } switch (builtin.os.tag) { .windows => { switch (event) { @@ -395,6 +399,7 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even return self.postEvent(.{ .color_report = report }); } }, + .cursor_position => unreachable, // handled above on all platforms .color_scheme => |scheme| { if (@hasField(Event, "color_scheme")) { return self.postEvent(.{ .color_scheme = scheme }); @@ -479,6 +484,66 @@ test Loop { try vx.queryTerminal(tty.writer(), .fromSeconds(1)); } +test "cursor queries deliver reports without interfering with capability probes" { + const testing = std.testing; + var env = try testing.environ.createMap(testing.allocator); + defer env.deinit(); + var tty = try Tty.init(testing.io, &.{}); + defer tty.deinit(); + var vx = try vaxis.init(testing.io, testing.allocator, &env, .{}); + defer vx.deinit(testing.allocator, tty.writer()); + const Event = union(enum) { + cursor_position: vaxis.Screen.Cursor, + key_press: vaxis.Key, + }; + var loop: Loop(Event) = .init(testing.io, &tty, &vx); + var parser: Parser = .{ .cursor_position_requests = &vx.cursor_position_requests }; + var cache: GraphemeCache = .{}; + + try vx.queryTerminalSend(tty.writer()); + try testing.expectError(error.TerminalQueriesPending, vx.queryCursorPosition(tty.writer())); + for ([_][]const u8{ "\x1b[1;2R", "\x1b[1;3R", "\x1b[?c" }) |input| { + const result = try parser.parse(input, null); + try handleEventGeneric(&loop, &vx, &cache, Event, result.event.?, null); + } + try testing.expect(vx.caps.explicit_width); + try testing.expect(vx.caps.scaled_text); + try testing.expectEqual(@as(usize, 0), vx.cursor_position_requests.pending()); + try testing.expectEqual(@as(?Event, null), try loop.tryEvent()); + + var writer: std.Io.Writer.Allocating = .init(testing.allocator); + defer writer.deinit(); + try vx.queryCursorPosition(&writer.writer); + try vx.queryCursorPosition(&writer.writer); + try testing.expectEqualStrings("\x1b[6n\x1b[6n", writer.written()); + try testing.expectEqual(@as(usize, 2), vx.cursor_position_requests.pending()); + try testing.expectError(error.CursorPositionQueriesPending, vx.queryTerminalSend(tty.writer())); + + for (0..2) |_| { + const result = try parser.parse("\x1b[12;34R", null); + try handleEventGeneric(&loop, &vx, &cache, Event, result.event.?, null); + const report = (try loop.tryEvent()).?.cursor_position; + try testing.expectEqual(@as(u16, 11), report.row); + try testing.expectEqual(@as(u16, 33), report.col); + } + try testing.expectEqual(@as(usize, 0), vx.cursor_position_requests.pending()); + + // Consumers that don't opt into cursor reports silently discard them. + const KeyEvent = union(enum) { key_press: vaxis.Key }; + var key_loop: Loop(KeyEvent) = .init(testing.io, &tty, &vx); + try vx.queryCursorPosition(&writer.writer); + const result = try parser.parse("\x1b[1;1R", null); + try handleEventGeneric(&key_loop, &vx, &cache, KeyEvent, result.event.?, null); + try testing.expectEqual(@as(?KeyEvent, null), try key_loop.tryEvent()); + try testing.expectEqual(@as(usize, 0), vx.cursor_position_requests.pending()); + + // Expired cursor queries must not block capability discovery indefinitely. + try vx.queryCursorPosition(&writer.writer); + vx.cursor_position_requests.last_request_at = vx.cursor_position_requests.last_request_at.subDuration(.fromSeconds(2)); + try vx.queryTerminalSend(tty.writer()); + try testing.expectEqual(@as(usize, 0), vx.cursor_position_requests.pending()); +} + test { std.testing.refAllDecls(@This()); } diff --git a/src/Parser.zig b/src/Parser.zig index 338b983..cfef9e0 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -18,6 +18,46 @@ pub const Result = struct { n: usize, }; +/// Shared cursor query state. The count and idle deadline are updated together +/// because requests and responses are handled on different threads. +pub const CursorPositionRequests = struct { + io: std.Io, + mutex: std.Io.Mutex = .init, + count: usize = 0, + last_request_at: std.Io.Timestamp = .zero, + + pub fn request(self: *CursorPositionRequests) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + const now = std.Io.Timestamp.now(self.io, .awake); + self.expire(now); + self.count += 1; + self.last_request_at = now; + } + + /// Consume a report, or cancel a request whose write failed. + pub fn consume(self: *CursorPositionRequests) bool { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + self.expire(std.Io.Timestamp.now(self.io, .awake)); + if (self.count == 0) return false; + self.count -= 1; + return true; + } + + pub fn pending(self: *CursorPositionRequests) usize { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + self.expire(std.Io.Timestamp.now(self.io, .awake)); + return self.count; + } + + fn expire(self: *CursorPositionRequests, now: std.Io.Timestamp) void { + if (self.last_request_at.durationTo(now).toNanoseconds() >= std.time.ns_per_s) + self.count = 0; + } +}; + const mouse_bits = struct { const motion: u8 = 0b00100000; const buttons: u8 = 0b11000011; @@ -45,6 +85,10 @@ const State = enum { // text-as-codepoints buf: [128]u8 = undefined, +/// Share Vaxis.cursor_position_requests when using a custom event loop. +/// Without pending requests, CSI R sequences retain their F3 key meaning. +cursor_position_requests: ?*CursorPositionRequests = null, + /// Parse the first event from the input buffer. If a completion event is not /// present, Result.event will be null and Result.n will be 0 /// @@ -59,7 +103,7 @@ pub fn parse(self: *Parser, input: []const u8, paste_allocator: ?std.mem.Allocat 0x4F => return parseSs3(input), 0x50 => return skipUntilST(input), // DCS 0x58 => return skipUntilST(input), // SOS - 0x5B => return parseCsi(input, &self.buf), // CSI + 0x5B => return parseCsi(input, &self.buf, self.cursor_position_requests), // CSI 0x5D => return parseOsc(input, paste_allocator), 0x5E => return skipUntilST(input), // PM 0x5F => return parseApc(input), @@ -340,7 +384,7 @@ inline fn parseOsc(input: []const u8, paste_allocator: ?std.mem.Allocator) !Resu } } -inline fn parseCsi(input: []const u8, text_buf: []u8) Result { +inline fn parseCsi(input: []const u8, text_buf: []u8, cursor_position_requests: ?*CursorPositionRequests) Result { if (input.len < 3) { return .{ .event = null, @@ -357,6 +401,25 @@ inline fn parseCsi(input: []const u8, text_buf: []u8) Result { const null_event: Result = .{ .event = null, .n = sequence.len }; const final = sequence[sequence.len - 1]; + if (final == 'R') report: { + const pending = cursor_position_requests orelse break :report; + if (pending.pending() == 0) break :report; + var fields = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';'); + const row_buf = fields.next().?; + const col_buf = fields.next() orelse break :report; + if (fields.next() != null) break :report; + // Only a complete pair of positive decimal coordinates is a report. + // Otherwise, preserve ordinary key parsing (e.g. CSI R or CSI 1;2:3R). + for (row_buf) |b| if (!std.ascii.isDigit(b)) break :report; + for (col_buf) |b| if (!std.ascii.isDigit(b)) break :report; + const row = std.fmt.parseInt(u16, row_buf, 10) catch break :report; + const col = std.fmt.parseInt(u16, col_buf, 10) catch break :report; + if (row == 0 or col == 0) break :report; + if (pending.consume()) return .{ + .event = .{ .cursor_position = .{ .row = row - 1, .col = col - 1 } }, + .n = sequence.len, + }; + } switch (final) { 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { // Legacy keys @@ -1182,11 +1245,116 @@ test "parse: keycap sequence" { try testing.expectEqual(expected_event, result.event); } +test "parse: cursor position requests are counted" { + var pending: CursorPositionRequests = .{ .io = testing.io }; + pending.request(); + pending.request(); + var parser: Parser = .{ .cursor_position_requests = &pending }; + const input = "\x1b[12;300R"; + + // Every incomplete prefix leaves the request outstanding. + for (2..input.len) |len| { + const result = try parser.parse(input[0..len], null); + try testing.expectEqual(@as(usize, 0), result.n); + try testing.expectEqual(@as(?Event, null), result.event); + try testing.expectEqual(@as(usize, 2), pending.pending()); + } + + const first = try parser.parse(input ++ "x", null); + try testing.expectEqual(input.len, first.n); + try testing.expectEqual(@as(u16, 11), first.event.?.cursor_position.row); + try testing.expectEqual(@as(u16, 299), first.event.?.cursor_position.col); + try testing.expectEqual(@as(usize, 1), pending.pending()); + + const key = try parser.parse("x", null); + try testing.expectEqual(@as(u21, 'x'), key.event.?.key_press.codepoint); + try testing.expectEqual(@as(usize, 1), pending.pending()); + + const plain_f3 = try parser.parse("\x1b[R", null); + try testing.expectEqual(Key.f3, plain_f3.event.?.key_press.codepoint); + const release = try parser.parse("\x1b[1;2:3R", null); + try testing.expectEqual(Key.f3, release.event.?.key_release.codepoint); + try testing.expect(release.event.?.key_release.mods.shift); + try testing.expectEqual(@as(usize, 1), pending.pending()); + + const second = try parser.parse("\x1b[1;1R", null); + try testing.expectEqual(@as(u16, 0), second.event.?.cursor_position.row); + try testing.expectEqual(@as(u16, 0), second.event.?.cursor_position.col); + try testing.expectEqual(@as(usize, 0), pending.pending()); + + // With no outstanding query, the same bytes are once again a legacy key. + const f3 = try parser.parse("\x1b[1;2R", null); + try testing.expectEqual(Key.f3, f3.event.?.key_press.codepoint); + try testing.expect(f3.event.?.key_press.mods.shift); + try testing.expectEqual(@as(usize, 0), pending.pending()); +} + +test "parse: invalid cursor position reports do not consume requests" { + var pending: CursorPositionRequests = .{ .io = testing.io }; + pending.request(); + var parser: Parser = .{ .cursor_position_requests = &pending }; + const invalid = [_][]const u8{ + "\x1b[0;1R", "\x1b[1;0R", "\x1b[;1R", "\x1b[1;R", + "\x1b[1R", "\x1b[1;2;3R", "\x1b[-1;1R", "\x1b[+1;1R", + "\x1b[1;2:3R", "\x1b[65536;1R", "\x1b[1;65536R", "\x1b[?1;1R", + }; + for (invalid) |input| { + const result = try parser.parse(input, null); + try testing.expectEqual(input.len, result.n); + if (result.event) |event| try testing.expect(event != .cursor_position); + try testing.expectEqual(@as(usize, 1), pending.pending()); + } + const result = try parser.parse("\x1b[65535;65535R", null); + try testing.expectEqual(@as(u16, 65534), result.event.?.cursor_position.row); + try testing.expectEqual(@as(u16, 65534), result.event.?.cursor_position.col); + try testing.expectEqual(@as(usize, 0), pending.pending()); +} + +test "cursor requests: idle deadline follows the last request, not reports" { + var pending: CursorPositionRequests = .{ .io = testing.io }; + pending.request(); + pending.last_request_at = pending.last_request_at.subDuration(.fromMilliseconds(500)); + const previous = pending.last_request_at; + pending.request(); + try testing.expectEqual(@as(usize, 2), pending.pending()); + try testing.expect(pending.last_request_at.nanoseconds > previous.nanoseconds); + + const last_request = pending.last_request_at; + try testing.expect(pending.consume()); + try testing.expectEqual(last_request, pending.last_request_at); + pending.expire(last_request.addDuration(.fromNanoseconds(std.time.ns_per_s - 1))); + try testing.expectEqual(@as(usize, 1), pending.count); + pending.expire(last_request.addDuration(.fromSeconds(1))); + try testing.expectEqual(@as(usize, 0), pending.count); +} + +test "parse: expired cursor requests restore F3 and new requests start fresh" { + var pending: CursorPositionRequests = .{ .io = testing.io }; + var parser: Parser = .{ .cursor_position_requests = &pending }; + pending.request(); + pending.request(); + pending.last_request_at = pending.last_request_at.subDuration(.fromSeconds(2)); + + const f3 = try parser.parse("\x1b[1;2R", null); + try testing.expectEqual(Key.f3, f3.event.?.key_press.codepoint); + try testing.expect(f3.event.?.key_press.mods.shift); + try testing.expectEqual(@as(usize, 0), pending.count); + + pending.request(); + pending.request(); + pending.last_request_at = pending.last_request_at.subDuration(.fromSeconds(2)); + pending.request(); + try testing.expectEqual(@as(usize, 1), pending.pending()); + const report = try parser.parse("\x1b[1;2R", null); + try testing.expectEqual(@as(u16, 1), report.event.?.cursor_position.col); + try testing.expectEqual(@as(usize, 0), pending.pending()); +} + test "parse(csi): kitty multi cursor" { var buf: [1]u8 = undefined; { const input = "\x1b[>1;2;3;29;30;40;100;101 q"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .cap_multi_cursor, .n = input.len, @@ -1197,7 +1365,7 @@ test "parse(csi): kitty multi cursor" { } { const input = "\x1b[> q"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = null, .n = input.len, @@ -1212,7 +1380,7 @@ test "parse(csi): decrpm" { var buf: [1]u8 = undefined; { const input = "\x1b[?1016;1$y"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .cap_sgr_pixels, .n = input.len, @@ -1223,7 +1391,7 @@ test "parse(csi): decrpm" { } { const input = "\x1b[?1016;0$y"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = null, .n = input.len, @@ -1237,7 +1405,7 @@ test "parse(csi): decrpm" { test "parse(csi): primary da" { var buf: [1]u8 = undefined; const input = "\x1b[?c"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .cap_da1, .n = input.len, @@ -1251,7 +1419,7 @@ test "parse(csi): dsr" { var buf: [1]u8 = undefined; { const input = "\x1b[?997;1n"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .color_scheme = .dark }, .n = input.len, @@ -1262,7 +1430,7 @@ test "parse(csi): dsr" { } { const input = "\x1b[?997;2n"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .color_scheme = .light }, .n = input.len, @@ -1273,7 +1441,7 @@ test "parse(csi): dsr" { } { const input = "\x1b[0n"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = null, .n = input.len, @@ -1287,7 +1455,7 @@ test "parse(csi): dsr" { test "parse(csi): mouse" { var buf: [1]u8 = undefined; const input = "\x1b[<35;1;1m"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .mouse = .{ .col = 0, @@ -1306,7 +1474,7 @@ test "parse(csi): mouse" { test "parse(csi): mouse (negative)" { var buf: [1]u8 = undefined; const input = "\x1b[<35;-50;-100m"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .mouse = .{ .col = -51, @@ -1325,7 +1493,7 @@ test "parse(csi): mouse (negative)" { test "parse(csi): xterm mouse" { var buf: [1]u8 = undefined; const input = "\x1b[M\x20\x21\x21"; - const result = parseCsi(input, &buf); + const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .mouse = .{ .col = 0, diff --git a/src/Vaxis.zig b/src/Vaxis.zig index 8bc67fc..8dbca0f 100644 --- a/src/Vaxis.zig +++ b/src/Vaxis.zig @@ -75,6 +75,10 @@ query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), /// key encoding. This can be used as a flag to determine how we should evaluate this sequence queries_done: atomic.Value(bool) = atomic.Value(bool).init(true), +/// Outstanding application cursor position queries and their idle deadline. +/// Shared with the input parser. +cursor_position_requests: @import("Parser.zig").CursorPositionRequests, + // images next_img_id: u32 = 1, @@ -113,6 +117,7 @@ pub fn init(io: std.Io, alloc: std.mem.Allocator, env_map: *std.process.Environ. .opts = opts, .screen = .{}, .screen_last = try .init(alloc, 0, 0), + .cursor_position_requests = .{ .io = io }, }; } @@ -285,6 +290,9 @@ pub fn queryTerminal(self: *Vaxis, tty: *std.Io.Writer, timeout: std.Io.Duration /// is only for use with a custom main loop. Call Vaxis.queryTerminal() if /// you are using Loop.run() pub fn queryTerminalSend(vx: *Vaxis, tty: *std.Io.Writer) !void { + // Capability probes use CPR too, so they must not overlap application queries. + if (vx.cursor_position_requests.pending() != 0) + return error.CursorPositionQueriesPending; vx.queries_done.store(false, .unordered); // TODO: re-enable this @@ -1196,6 +1204,23 @@ pub fn addTerminalSecondaryCursor(self: *Vaxis, alloc: std.mem.Allocator, y: u16 self.screen.cursor_secondary = try cursors.toOwnedSlice(alloc); } +/// Request the terminal cursor position. Add `cursor_position: vaxis.Screen.Cursor` +/// to your Event union to receive the zero-based row and column asynchronously. +/// Multiple requests may be outstanding. Complete terminal capability queries first. +/// Pending requests expire lazily one second after the last request. No timer is +/// started. While a request is pending, legacy F3 sequences that look like reports +/// are ambiguous; late replies cannot be distinguished from keys or newer replies. +pub fn queryCursorPosition(self: *Vaxis, tty: *std.Io.Writer) !void { + if (!self.queries_done.load(.unordered)) return error.TerminalQueriesPending; + self.cursor_position_requests.request(); + tty.writeAll(ctlseqs.cursor_position_request) catch |err| { + _ = self.cursor_position_requests.consume(); + return err; + }; + // Once buffered, the request may reach the terminal even if flushing fails. + try tty.flush(); +} + /// Request a color report from the terminal. Note: not all terminals support /// reporting colors. It is always safe to try, but you may not receive a /// response. @@ -1522,6 +1547,23 @@ pub fn setTerminalWorkingDirectory(_: *Vaxis, tty: *std.Io.Writer, path: []const try tty.flush(); } +test "queryCursorPosition: failed write does not leave a pending request" { + var env = try std.testing.environ.createMap(std.testing.allocator); + defer env.deinit(); + var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); + var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer writer.deinit(); + defer vx.deinit(std.testing.allocator, &writer.writer); + + var failing: std.Io.Writer = .failing; + try std.testing.expectError(error.WriteFailed, vx.queryCursorPosition(&failing)); + try std.testing.expectEqual(@as(usize, 0), vx.cursor_position_requests.pending()); + + try vx.queryCursorPosition(&writer.writer); + try std.testing.expectError(error.WriteFailed, vx.queryCursorPosition(&failing)); + try std.testing.expectEqual(@as(usize, 1), vx.cursor_position_requests.pending()); +} + test "render: no output when no changes" { const io = std.testing.io; var env_map = try std.testing.environ.createMap(std.testing.allocator); diff --git a/src/c_api.zig b/src/c_api.zig index e09b66b..6f87ede 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -131,6 +131,7 @@ pub const EventType = enum(c_int) { cap_da1 = 18, cap_color_scheme_updates = 19, cap_multi_cursor = 20, + cursor_position = 21, }; comptime { @@ -1058,6 +1059,7 @@ const CEvent = struct { color_report: Color.Report = .{ .kind = .fg, .value = .{ 0, 0, 0 } }, color_scheme: Color.Scheme = .dark, winsize: Winsize = .{ .rows = 0, .cols = 0, .x_pixel = 0, .y_pixel = 0 }, + cursor_position: Screen.Cursor = .{}, }; /// vaxis_parser. Owns the current event and everything it points at @@ -1270,6 +1272,18 @@ pub fn event_winsize_y_pixel(event: ?*const CEvent) callconv(.c) u16 { return e.winsize.y_pixel; } +pub fn event_cursor_position_row(event: ?*const CEvent) callconv(.c) u16 { + const e = event orelse return 0; + if (e.type != .cursor_position) return 0; + return e.cursor_position.row; +} + +pub fn event_cursor_position_col(event: ?*const CEvent) callconv(.c) u16 { + const e = event orelse return 0; + if (e.type != .cursor_position) return 0; + return e.cursor_position.col; +} + pub fn key_from_name(name: ?[*]const u8, name_len: usize) callconv(.c) u32 { const n = name orelse return 0; return Key.name_map.get(n[0..name_len]) orelse 0; @@ -1296,6 +1310,7 @@ fn convertEvent(p: *CParser, event: vaxis.Event) void { .color_report => |report| p.event.color_report = report, .color_scheme => |scheme| p.event.color_scheme = scheme, .winsize => |winsize| p.event.winsize = winsize, + .cursor_position => |position| p.event.cursor_position = position, else => {}, } } @@ -1589,6 +1604,27 @@ test "c api: in-band resize" { try testing.expectEqual(@as(u16, 480), event_winsize_y_pixel(event)); } +test "c api: cursor position event accessors" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + var pending: Parser.CursorPositionRequests = .{ .io = testing.io }; + pending.request(); + parser.parser.cursor_position_requests = &pending; + + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, "\x1b[12;34R", &event, &n)); + try testing.expectEqual(EventType.cursor_position, event_get_type(event)); + try testing.expectEqual(@as(u16, 11), event_cursor_position_row(event)); + try testing.expectEqual(@as(u16, 33), event_cursor_position_col(event)); + try testing.expectEqual(@as(u16, 0), event_cursor_position_row(null)); + try testing.expectEqual(@as(u16, 0), event_cursor_position_col(null)); + + try testing.expectEqual(.ok, parseBytes(parser, "x", &event, &n)); + try testing.expectEqual(@as(u16, 0), event_cursor_position_row(event)); + try testing.expectEqual(@as(u16, 0), event_cursor_position_col(event)); +} + test "c api: color report" { const parser = parser_new() orelse return error.OutOfMemory; defer parser_free(parser); diff --git a/src/event.zig b/src/event.zig index 38e5cf3..556c617 100644 --- a/src/event.zig +++ b/src/event.zig @@ -17,6 +17,8 @@ pub const Event = union(enum) { color_report: Color.Report, // osc 4, 10, 11, 12 response color_scheme: Color.Scheme, winsize: Winsize, + /// Cursor position report, with zero-based row and column. + cursor_position: @import("Screen.zig").Cursor, // these are delivered as discovered terminal capabilities cap_kitty_keyboard, -- knotmirror 0.0.1