Add physical keyboard parity aliases

This commit is contained in:
slhx agent
2026-06-21 14:30:31 +02:00
parent 616eea670b
commit e96d33edb9
5 changed files with 156 additions and 10 deletions
+5 -4
View File
@@ -378,8 +378,11 @@ Backed by current code/tests:
- protocol/session/job/LSP/diagnostic skeletons; - protocol/session/job/LSP/diagnostic skeletons;
- `Space t` lint/build/test/check job rail backed by source-patched command profiles, - `Space t` lint/build/test/check job rail backed by source-patched command profiles,
captured output panels, missing-tool/cancel/timeout rows, and jump/yank actions; captured output panels, missing-tool/cancel/timeout rows, and jump/yank actions;
- physical-keyboard parity aliases for arrows, Home/End, PageUp/PageDown, Escape,
direct digit counts, `%` match jumping, and panel page keys while preserving
mobile Space paths;
- basic trace commands for open, insert, save, panels, diagnostics, repo files, - basic trace commands for open, insert, save, panels, diagnostics, repo files,
and local context. physical key aliases, and local context.
Design-only and needing future implementation slices: Design-only and needing future implementation slices:
@@ -395,9 +398,7 @@ Design-only and needing future implementation slices:
- source-profile-backed format, organize-imports, code-action edit application; - source-profile-backed format, organize-imports, code-action edit application;
- format-on-save policy and one-shot save-without-format; - format-on-save policy and one-shot save-without-format;
- contextual rails for all prefixes listed in this document; - contextual rails for all prefixes listed in this document;
- Insert pending-space rail, including `Space` pause then `n` to Normal; - Insert pending-space rail, including `Space` pause then `n` to Normal.
- counts/repetition grammar with direct digit and mobile repeat-rail paths;
- physical-keyboard trace fixtures and alias table.
Future slices should implement one vertical behavior at a time with replay or Future slices should implement one vertical behavior at a time with replay or
headless tests: for example, `m m` delimiter jump, `s i` indent selection, headless tests: for example, `m m` delimiter jump, `s i` indent selection,
+14
View File
@@ -18,6 +18,10 @@ pub const Key = enum {
arrow_right, arrow_right,
arrow_up, arrow_up,
arrow_down, arrow_down,
home,
end,
page_up,
page_down,
}; };
pub const Event = union(enum) { pub const Event = union(enum) {
@@ -82,6 +86,10 @@ pub fn normalize(raw: []const u8) Event {
if (std.mem.eql(u8, raw, "\x1b[C")) return .{ .key = .arrow_right }; if (std.mem.eql(u8, raw, "\x1b[C")) return .{ .key = .arrow_right };
if (std.mem.eql(u8, raw, "\x1b[A")) return .{ .key = .arrow_up }; if (std.mem.eql(u8, raw, "\x1b[A")) return .{ .key = .arrow_up };
if (std.mem.eql(u8, raw, "\x1b[B")) return .{ .key = .arrow_down }; if (std.mem.eql(u8, raw, "\x1b[B")) return .{ .key = .arrow_down };
if (std.mem.eql(u8, raw, "\x1b[H") or std.mem.eql(u8, raw, "\x1b[1~") or std.mem.eql(u8, raw, "\x1b[7~")) return .{ .key = .home };
if (std.mem.eql(u8, raw, "\x1b[F") or std.mem.eql(u8, raw, "\x1b[4~") or std.mem.eql(u8, raw, "\x1b[8~")) return .{ .key = .end };
if (std.mem.eql(u8, raw, "\x1b[5~")) return .{ .key = .page_up };
if (std.mem.eql(u8, raw, "\x1b[6~")) return .{ .key = .page_down };
if (std.unicode.utf8ValidateSlice(raw) and isPrintableText(raw)) return .{ .text = raw }; if (std.unicode.utf8ValidateSlice(raw) and isPrintableText(raw)) return .{ .text = raw };
return .{ .unknown = raw }; return .{ .unknown = raw };
@@ -199,6 +207,12 @@ test "regular: raw terminal bytes normalize to stable editor input events" {
try expectEventLabel("\x1b[C", "key:arrow_right"); try expectEventLabel("\x1b[C", "key:arrow_right");
try expectEventLabel("\x1b[A", "key:arrow_up"); try expectEventLabel("\x1b[A", "key:arrow_up");
try expectEventLabel("\x1b[B", "key:arrow_down"); try expectEventLabel("\x1b[B", "key:arrow_down");
try expectEventLabel("\x1b[H", "key:home");
try expectEventLabel("\x1b[1~", "key:home");
try expectEventLabel("\x1b[F", "key:end");
try expectEventLabel("\x1b[4~", "key:end");
try expectEventLabel("\x1b[5~", "key:page_up");
try expectEventLabel("\x1b[6~", "key:page_down");
} }
test "regular: captured trace can be replayed" { test "regular: captured trace can be replayed" {
+1 -1
View File
@@ -162,7 +162,7 @@ pub fn classifyEvent(profile: *const Profile, event: input.Event) Classified {
const kind: Kind = switch (event) { const kind: Kind = switch (event) {
.key => |key| switch (key) { .key => |key| switch (key) {
.space, .tab => .whitespace, .space, .tab => .whitespace,
.enter, .backspace, .escape, .arrow_left, .arrow_right, .arrow_up, .arrow_down => .control, .enter, .backspace, .escape, .arrow_left, .arrow_right, .arrow_up, .arrow_down, .home, .end, .page_up, .page_down => .control,
}, },
.text => |text| lookupFact(profile, text) orelse .unknown, .text => |text| lookupFact(profile, text) orelse .unknown,
.unknown => .unknown, .unknown => .unknown,
+5 -5
View File
@@ -101,12 +101,12 @@ pub const Leader = struct {
if (self.message) |message| return message; if (self.message) |message| return message;
return switch (self.mode) { return switch (self.mode) {
.idle => "", .idle => "",
.rail => "leader: w save q quit o open p symbols s search r repeat x close", .rail => "leader: w save q quit o open p symbols s search r repeat (digits/%/Esc ok)",
.symbol_rail => symbol_mod.rail_status, .symbol_rail => symbol_mod.rail_status,
.search_rail => "search: f current file p project s symbols", .search_rail => "search: f current file p project s symbols",
.language_rail => "language: h hover s signature o open hover", .language_rail => "language: h hover s sig f fmt o imports a actions (Space path)",
.diagnostic_rail => "diagnostics: d/open n next p previous f filter-source", .diagnostic_rail => "diagnostics: d open n/p next/prev f source (arrows/Enter ok)",
.tool_rail => "tools: l lint-file L lint-project b build t test c check x cancel j jump y yank", .tool_rail => "tools: l/L lint b build t test c check x cancel j jump y yank",
.open_prompt => "open: type path, Enter opens, Esc cancels", .open_prompt => "open: type path, Enter opens, Esc cancels",
}; };
} }
@@ -443,7 +443,7 @@ test "regular: space opens a visible leader rail and write dispatches" {
defer leader.deinit(); defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try std.testing.expectEqualStrings("leader: w save q quit o open p symbols s search r repeat x close", leader.status()); try std.testing.expectEqualStrings("leader: w save q quit o open p symbols s search r repeat (digits/%/Esc ok)", leader.status());
try expectActionTag(.save, try leader.handleEvent(input.normalize("w"))); try expectActionTag(.save, try leader.handleEvent(input.normalize("w")));
try std.testing.expect(!leader.isActive()); try std.testing.expect(!leader.isActive());
+131
View File
@@ -928,6 +928,12 @@ pub const Client = struct {
if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b"); if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b");
if (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D"); if (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D");
if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C"); if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C");
if (std.mem.eql(u8, key_name, "up")) return self.handleInput("\x1b[A");
if (std.mem.eql(u8, key_name, "down")) return self.handleInput("\x1b[B");
if (std.mem.eql(u8, key_name, "home")) return self.handleInput("\x1b[H");
if (std.mem.eql(u8, key_name, "end")) return self.handleInput("\x1b[F");
if (std.mem.eql(u8, key_name, "page_up")) return self.handleInput("\x1b[5~");
if (std.mem.eql(u8, key_name, "page_down")) return self.handleInput("\x1b[6~");
if (key_name.len == 1) return self.handleInput(key_name); if (key_name.len == 1) return self.handleInput(key_name);
return Error.UnknownTraceEvent; return Error.UnknownTraceEvent;
} }
@@ -1021,6 +1027,7 @@ pub const Client = struct {
if (std.mem.eql(u8, text, "U")) return self.redoEdit(); if (std.mem.eql(u8, text, "U")) return self.redoEdit();
if (std.mem.eql(u8, text, "0")) return self.applyProtocol("command move_line_start"); if (std.mem.eql(u8, text, "0")) return self.applyProtocol("command move_line_start");
if (std.mem.eql(u8, text, "$")) return self.applyProtocol("command move_line_end"); if (std.mem.eql(u8, text, "$")) return self.applyProtocol("command move_line_end");
if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null);
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match); if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go); if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
if (std.mem.eql(u8, text, "j")) return self.repeatProtocol("command move_down", self.takeRepeat()); if (std.mem.eql(u8, text, "j")) return self.repeatProtocol("command move_down", self.takeRepeat());
@@ -1042,6 +1049,10 @@ pub const Client = struct {
.arrow_right => try self.repeatProtocol("command move_right", self.takeRepeat()), .arrow_right => try self.repeatProtocol("command move_right", self.takeRepeat()),
.arrow_up => try self.repeatProtocol("command move_up", self.takeRepeat()), .arrow_up => try self.repeatProtocol("command move_up", self.takeRepeat()),
.arrow_down => try self.repeatProtocol("command move_down", self.takeRepeat()), .arrow_down => try self.repeatProtocol("command move_down", self.takeRepeat()),
.home => try self.applyProtocol("command move_line_start"),
.end => try self.applyProtocol("command move_line_end"),
.page_up => try self.repeatProtocol("command move_up", @max(@as(usize, 1), self.viewport.height - 2)),
.page_down => try self.repeatProtocol("command move_down", @max(@as(usize, 1), self.viewport.height - 2)),
else => self.unknownPrefixOrInput("normal"), else => self.unknownPrefixOrInput("normal"),
}, },
.unknown => self.unknownPrefixOrInput("normal"), .unknown => self.unknownPrefixOrInput("normal"),
@@ -1060,6 +1071,10 @@ pub const Client = struct {
.backspace => try self.applyMutatingProtocol("command delete_backward"), .backspace => try self.applyMutatingProtocol("command delete_backward"),
.arrow_left => try self.applyProtocol("command move_left"), .arrow_left => try self.applyProtocol("command move_left"),
.arrow_right => try self.applyProtocol("command move_right"), .arrow_right => try self.applyProtocol("command move_right"),
.arrow_up => try self.applyProtocol("command move_up"),
.arrow_down => try self.applyProtocol("command move_down"),
.home => try self.applyProtocol("command move_line_start"),
.end => try self.applyProtocol("command move_line_end"),
else => {}, else => {},
}, },
.unknown => {}, .unknown => {},
@@ -1074,6 +1089,7 @@ pub const Client = struct {
self.message = "normal"; self.message = "normal";
return; return;
} }
if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null);
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match); if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go); if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
if (text.len == 1) { if (text.len == 1) {
@@ -1092,6 +1108,10 @@ pub const Client = struct {
}, },
.arrow_left => try self.applyProtocol("command move_left"), .arrow_left => try self.applyProtocol("command move_left"),
.arrow_right => try self.applyProtocol("command move_right"), .arrow_right => try self.applyProtocol("command move_right"),
.arrow_up => try self.applyProtocol("command move_up"),
.arrow_down => try self.applyProtocol("command move_down"),
.home => try self.applyProtocol("command move_line_start"),
.end => try self.applyProtocol("command move_line_end"),
else => {}, else => {},
}, },
.unknown => self.unknownPrefixOrInput("select"), .unknown => self.unknownPrefixOrInput("select"),
@@ -1110,6 +1130,8 @@ pub const Client = struct {
.key => |key| switch (key) { .key => |key| switch (key) {
.arrow_down => try self.applyProtocol("command list_down"), .arrow_down => try self.applyProtocol("command list_down"),
.arrow_up => try self.applyProtocol("command list_up"), .arrow_up => try self.applyProtocol("command list_up"),
.page_down => try self.repeatProtocol("command list_down", @max(@as(usize, 1), self.viewport.height - 2)),
.page_up => try self.repeatProtocol("command list_up", @max(@as(usize, 1), self.viewport.height - 2)),
.enter => try self.openActivePanelItem(), .enter => try self.openActivePanelItem(),
.escape => try self.closeActivePanel(), .escape => try self.closeActivePanel(),
else => {}, else => {},
@@ -4581,3 +4603,112 @@ test "regular: provider edit rows parse source scope and replacement" {
defer std.testing.allocator.free(action_row); defer std.testing.allocator.free(action_row);
try std.testing.expectEqualStrings("zls", try lsp_mod.providerFromActionPanelRow(action_row)); try std.testing.expectEqualStrings("zls", try lsp_mod.providerFromActionPanelRow(action_row));
} }
test "regular: physical arrows home end page and escape match editor movement" {
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
defer client.deinit();
try client.handleTraceLine("open one\ntwo\nthree\nfour\nfive");
var snap = try client.session.snapshot();
const start = snap.cursor_byte;
try client.handleTraceLine("key page_down");
snap = try client.session.snapshot();
try std.testing.expect(snap.cursor_byte > start);
try client.handleTraceLine("key home");
snap = try client.session.snapshot();
const line_start = snap.cursor_byte;
try client.handleTraceLine("key end");
snap = try client.session.snapshot();
try std.testing.expect(snap.cursor_byte >= line_start);
try client.handleTraceLine("key page_up");
snap = try client.session.snapshot();
try std.testing.expect(snap.cursor_byte <= line_start);
try client.handleInput("i");
try client.handleInput("x");
try client.handleTraceLine("key escape");
try client.handleInput("u");
snap = try client.session.snapshot();
try std.testing.expect(std.mem.indexOf(u8, snap.bytes, "onxe") == null);
}
test "regular: physical digit count matches repeated arrow movement" {
var physical = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
defer physical.deinit();
try physical.handleTraceLine("open a\nb\nc\nd");
try physical.handleInput("3");
try physical.handleTraceLine("key down");
const physical_snap = try physical.session.snapshot();
var repeated = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
defer repeated.deinit();
try repeated.handleTraceLine("open a\nb\nc\nd");
try repeated.handleTraceLine("key down");
try repeated.handleTraceLine("key down");
try repeated.handleTraceLine("key down");
const repeated_snap = try repeated.session.snapshot();
try std.testing.expectEqual(repeated_snap.cursor_byte, physical_snap.cursor_byte);
}
test "regular: physical percent and mobile match rail jump to same pair" {
var physical = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
defer physical.deinit();
try physical.handleTraceLine("open (abc)");
try physical.handleInput("%");
var snap = try physical.session.snapshot();
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
var mobile = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
defer mobile.deinit();
try mobile.handleTraceLine("open (abc)");
try mobile.handleInput("m");
try mobile.handleInput("m");
snap = try mobile.session.snapshot();
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
}
test "regular: insert/select modes accept arrows while Space n remains mobile escape" {
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
defer client.deinit();
try client.handleTraceLine("open ab\ncd");
try client.handleInput("i");
try client.handleTraceLine("key end");
try client.handleInput("X");
try client.handleInput(" ");
try client.handleInput("n");
var snap = try client.session.snapshot();
try std.testing.expectEqualStrings("abX\ncd", snap.bytes);
try client.handleInput("s");
try client.handleTraceLine("key up");
try client.handleTraceLine("key home");
try client.handleInput("n");
snap = try client.session.snapshot();
try std.testing.expect(snap.selection != null or snap.cursor_byte == 0);
}
test "regular: panels accept physical page keys and enter without breaking arrows" {
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
defer client.deinit();
try client.handleTraceLine("list_open files a.zig|b.zig|c.zig|d.zig|e.zig");
try client.handleTraceLine("key page_down");
const after_down = try client.session.activeListItem();
try std.testing.expect(!std.mem.eql(u8, after_down, "a.zig"));
try client.handleTraceLine("key up");
const after_up = try client.session.activeListItem();
try std.testing.expect(!std.mem.eql(u8, after_down, after_up));
}
test "adversarial: unknown physical key names stay rejected and leader help stays narrow" {
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 });
defer client.deinit();
try std.testing.expectError(Error.UnknownTraceEvent, client.handleTraceLine("key f1"));
try client.handleInput(" ");
try std.testing.expect(std.mem.indexOf(u8, client.leader.status(), "digits/%/Esc ok") != null);
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try assertLinesFit(frame, 72);
}