Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 146 additions & 20 deletions src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,27 @@ pub const InputEvent = union(enum) {
pub const Arrow = struct {
dir: Dir,
prefixed: bool,
/// The original bytes, forwarded verbatim when the arrow is
/// not intercepted for browse/resize, so a modified arrow or
/// the report-events encoding the terminal used reaches the
/// application intact. Empty for arrows constructed without a
/// source sequence; `bytes()` then falls back to the legacy
/// form.
seq: []const u8 = &.{},

pub const Dir = enum { up, down, left, right };

/// Bytes to forward to the application: the original sequence
/// when present, else the legacy encoding of the direction.
pub fn bytes(self: Arrow) []const u8 {
if (self.seq.len > 0) return self.seq;
return switch (self.dir) {
.up => "\x1b[A",
.down => "\x1b[B",
.right => "\x1b[C",
.left => "\x1b[D",
};
}
};
};

Expand Down Expand Up @@ -292,8 +311,9 @@ pub const InputParser = struct {
}

/// Whether `byte` keeps the held bytes a candidate for a sequence
/// this parser handles as a unit: plain arrows (ESC [ A/B/C/D),
/// CSI mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), and
/// this parser handles as a unit: plain and functional arrows
/// (ESC [ A/B/C/D and ESC [ 1 ; mods [: event] A/B/C/D), CSI
/// mouse (ESC [ < ... M/m), focus (ESC [ I, ESC [ O), and
/// parameterized keys (ESC [ <digits...> [;:] ... ~/u): paste
/// markers, kitty CSI-u keys, modifyOtherKeys, and legacy keys
/// that share the grammar (F5 is ESC [ 15 ~). Holding never
Expand All @@ -313,7 +333,10 @@ pub const InputParser = struct {
else => false,
},
'0'...'9' => switch (byte) {
'0'...'9', ';', ':', '~', 'u' => true,
// A/B/C/D close the functional cursor-key form
// (ESC [ 1 ; mods [: event] A); ~/u close paste,
// kitty, and modifyOtherKeys keys.
'0'...'9', ';', ':', '~', 'u', 'A', 'B', 'C', 'D' => true,
else => false,
},
else => false,
Expand All @@ -334,21 +357,29 @@ pub const InputParser = struct {
const prefixed = self.prefix_held;
self.prefix_held = false;

// Plain arrows. heldAccepts admits the final only directly
// after the bracket, so the body is always empty; modified
// arrows (ESC [ 1;5 A) diverge earlier and are replayed.
// Arrows. A bare ESC [ A/B/C/D directly after the bracket is
// an unmodified cursor key; the functional form
// ESC [ 1 ; mods [: event] A/B/C/D carries modifiers and, when
// the terminal reports event types, even an unmodified press.
// Only an unmodified press or repeat drives browse/resize;
// modified arrows (Ctrl+Left word motion) and release events
// are the application's input and replay verbatim.
switch (final) {
'A', 'B', 'C', 'D' => {
self.held_len = 0;
return handler.event(.{ .arrow = .{
.dir = switch (final) {
'A' => .up,
'B' => .down,
'C' => .right,
else => .left,
},
.prefixed = prefixed,
} });
if (body.len == 0 or arrowNavigates(body)) {
self.held_len = 0;
return handler.event(.{ .arrow = .{
.dir = switch (final) {
'A' => .up,
'B' => .down,
'C' => .right,
else => .left,
},
.prefixed = prefixed,
.seq = seq,
} });
}
return self.flushHeld(handler);
},
else => {},
}
Expand Down Expand Up @@ -525,6 +556,32 @@ pub const InputParser = struct {
return std.fmt.parseInt(u16, text, 10) catch null;
}

/// Whether the body of a functional cursor key
/// (ESC [ 1 ; mods [: event] A/B/C/D) is an unmodified press or
/// repeat, the only forms that drive browse/resize. A modified
/// arrow (mods other than none, ignoring lock bits) or a release
/// event belongs to the application and replays verbatim. The
/// leading parameter is always `1` for these keys.
fn arrowNavigates(body: []const u8) bool {
var sections = std.mem.splitScalar(u8, body, ';');
const first = sections.next() orelse return false;
if (!std.mem.eql(u8, first, "1")) return false;
const mods_section = sections.next() orelse return false;
if (sections.next() != null) return false;
var fields = std.mem.splitScalar(u8, mods_section, ':');
const mods_text = fields.next() orelse return false;
const mods = std.fmt.parseInt(u32, mods_text, 10) catch return false;
// Lock bits (caps/num) do not count as a real modifier.
if ((mods -| 1) & 0x3f != 0) return false;
if (fields.next()) |event_text| {
const event = std.fmt.parseInt(u32, event_text, 10) catch return false;
// 1 press, 2 repeat navigate; 3 release does not.
if (event != 1 and event != 2) return false;
}
if (fields.next() != null) return false;
return true;
}

/// Replay held bytes as session input: the sequence is some other
/// key encoding (function keys, modified arrows, ...) that belongs
/// to the application.
Expand Down Expand Up @@ -1344,8 +1401,7 @@ const Ui = struct {
}
const v = self.liveView() orelse return;
self.snapViewBottom();
v.sendInput(if (a.dir == .left) "\x1b[D" else "\x1b[C") catch
self.markViewLost();
v.sendInput(a.bytes()) catch self.markViewLost();
},
.up, .down => {
// An active resize keeps its width before the
Expand All @@ -1360,8 +1416,7 @@ const Ui = struct {
}
const v = self.liveView() orelse return;
self.snapViewBottom();
v.sendInput(if (a.dir == .up) "\x1b[A" else "\x1b[B") catch
self.markViewLost();
v.sendInput(a.bytes()) catch self.markViewLost();
},
},
.mouse => |m| {
Expand Down Expand Up @@ -2966,11 +3021,15 @@ const TestHandler = struct {
/// Esc-event payload bytes, copied out (they alias the parser's
/// hold buffer).
escs: std.ArrayList(u8) = .empty,
/// Bytes of the most recent arrow event, copied out (they alias
/// the parser's hold buffer).
last_arrow_seq: std.ArrayList(u8) = .empty,

fn deinit(self: *TestHandler) void {
self.events.deinit(self.alloc);
self.forwarded.deinit(self.alloc);
self.escs.deinit(self.alloc);
self.last_arrow_seq.deinit(self.alloc);
}

fn event(self: *TestHandler, ev: InputEvent) !void {
Expand All @@ -2980,6 +3039,15 @@ const TestHandler = struct {
try self.forwarded.appendSlice(self.alloc, bytes);
},
.esc => |bytes| try self.escs.appendSlice(self.alloc, bytes),
.arrow => |a| {
// Copy the seq before storing; the slice aliases the
// hold buffer and is only valid during this call.
self.last_arrow_seq.clearRetainingCapacity();
try self.last_arrow_seq.appendSlice(self.alloc, a.seq);
var rec = a;
rec.seq = &.{};
try self.events.append(self.alloc, .{ .arrow = rec });
},
else => try self.events.append(self.alloc, ev),
}
}
Expand Down Expand Up @@ -3158,6 +3226,64 @@ test "parser: arrow split across feeds" {
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

test "parser: report-events functional arrows still navigate" {
var h: TestHandler = .{ .alloc = std.testing.allocator };
defer h.deinit();
var p: InputParser = .{};
// A report-events terminal encodes even an unmodified arrow press
// as ESC [ 1;1:1 A. After the kitty-encoded prefix it must still
// drive browse/resize, the way the legacy ESC [ A did before the
// keyboard mirror existed.
try p.feed("\x1b[97;5u\x1b[1;1:1A", .{ .kitty = true }, &h);
try std.testing.expectEqual(@as(usize, 1), h.events.items.len);
try std.testing.expectEqual(
InputEvent{ .arrow = .{ .dir = .up, .prefixed = true } },
h.events.items[0],
);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
// The forms without an event subfield (ESC [ 1;1 A) and the
// repeat event navigate too, unprefixed.
try p.feed("\x1b[1;1B\x1b[1;1:2C", .{ .kitty = true }, &h);
try std.testing.expectEqual(@as(usize, 3), h.events.items.len);
try std.testing.expectEqual(
InputEvent{ .arrow = .{ .dir = .down, .prefixed = false } },
h.events.items[1],
);
try std.testing.expectEqual(
InputEvent{ .arrow = .{ .dir = .right, .prefixed = false } },
h.events.items[2],
);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

test "parser: functional arrows forward the original bytes" {
var h: TestHandler = .{ .alloc = std.testing.allocator };
defer h.deinit();
var p: InputParser = .{};
// An unprefixed functional arrow is an arrow event carrying its
// original bytes, so the focused application receives the exact
// report-events encoding rather than a downgraded legacy arrow.
try p.feed("\x1b[1;1:1A", .{ .kitty = true }, &h);
try std.testing.expectEqual(@as(usize, 1), h.events.items.len);
try std.testing.expectEqualStrings("\x1b[1;1:1A", h.last_arrow_seq.items);
}

test "parser: modified arrows and releases are application input" {
var h: TestHandler = .{ .alloc = std.testing.allocator };
defer h.deinit();
var p: InputParser = .{};
// Ctrl+Left (word motion) and an arrow release are the
// application's, not browse/resize: forwarded verbatim, never an
// arrow event, even right after the prefix.
try p.feed("\x1b[1;5D", .{ .kitty = true }, &h);
try std.testing.expectEqualStrings("\x1b[1;5D", h.forwarded.items);
try std.testing.expectEqual(@as(usize, 0), h.events.items.len);
h.forwarded.clearRetainingCapacity();
try p.feed("\x1b[1;1:3A", .{ .kitty = true }, &h);
try std.testing.expectEqualStrings("\x1b[1;1:3A", h.forwarded.items);
try std.testing.expectEqual(@as(usize, 0), h.events.items.len);
}

test "parser: bracketed paste protects the prefix byte" {
var h: TestHandler = .{ .alloc = std.testing.allocator };
defer h.deinit();
Expand Down
41 changes: 41 additions & 0 deletions test/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2218,6 +2218,47 @@ test "ui: arrow browsing selects without attaching until enter" {
try std.testing.expect(std.mem.indexOf(u8, bravo_peek.stdout, "STILL-ALPHA-MARK") == null);
}

test "ui: report-events arrows browse after the prefix" {
const alloc = std.testing.allocator;
var h = try Harness.init(alloc);
defer h.deinit();

// A focused app on the kitty keyboard protocol: the UI mirrors
// it, so a report-events terminal would encode the prefix CSI-u
// and arrows in the functional ESC [ 1 ; mods : event form. cat -v
// makes any leaked bytes visible.
try h.startDetached("kca", &.{
"sh", "-c",
"stty -echo -icanon; printf '\\033[>1u'; " ++
"echo KITTY-ON; exec cat -v",
});
const seeded = try h.waitPeekContains("kca", "KITTY-ON");
alloc.free(seeded);

var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100);
defer ui.deinit();
try ui.waitFor("KITTY-ON");
try ui.waitFor("\x1b[=1;1u");

// C-a then Down, exactly as a report-events terminal sends them:
// the prefix kitty-encoded, the arrow with an explicit press
// event subfield. This must arm browse mode (the sidebar hint),
// the regression that the legacy ESC [ B handled before the
// keyboard mirror started re-encoding arrows.
ui.clearOutput();
try ui.send("\x1b[97;5u");
try ui.send("\x1b[1;1:1B");
try ui.waitFor("enter attach");

// Neither the prefix nor the arrow leaked into the session.
const peek = try h.run(&.{ "peek", "kca" });
defer alloc.free(peek.stdout);
defer alloc.free(peek.stderr);
try std.testing.expect(peek.term.Exited == 0);
try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "1;1:1B") == null);
try std.testing.expect(std.mem.indexOf(u8, peek.stdout, "97;5u") == null);
}

test "ui: enter attaches the selection when nothing is focused" {
const alloc = std.testing.allocator;
var h = try Harness.init(alloc);
Expand Down
Loading