From e96d33edb95f7dd20fb0cf1ae141b554e52e9405 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 14:30:31 +0200 Subject: [PATCH] Add physical keyboard parity aliases --- KEYMAP.md | 9 ++-- src/input.zig | 14 ++++++ src/layout.zig | 2 +- src/leader.zig | 10 ++-- src/tui.zig | 131 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 10 deletions(-) diff --git a/KEYMAP.md b/KEYMAP.md index 79a1f53..632d36d 100644 --- a/KEYMAP.md +++ b/KEYMAP.md @@ -378,8 +378,11 @@ Backed by current code/tests: - protocol/session/job/LSP/diagnostic skeletons; - `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; +- 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, - and local context. + physical key aliases, and local context. 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; - format-on-save policy and one-shot save-without-format; - contextual rails for all prefixes listed in this document; -- 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. +- Insert pending-space rail, including `Space` pause then `n` to Normal. 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, diff --git a/src/input.zig b/src/input.zig index 4c67fa0..35e7088 100644 --- a/src/input.zig +++ b/src/input.zig @@ -18,6 +18,10 @@ pub const Key = enum { arrow_right, arrow_up, arrow_down, + home, + end, + page_up, + page_down, }; 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[A")) return .{ .key = .arrow_up }; 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 }; 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[A", "key:arrow_up"); 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" { diff --git a/src/layout.zig b/src/layout.zig index 51722bd..6a60c64 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -162,7 +162,7 @@ pub fn classifyEvent(profile: *const Profile, event: input.Event) Classified { const kind: Kind = switch (event) { .key => |key| switch (key) { .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, .unknown => .unknown, diff --git a/src/leader.zig b/src/leader.zig index 764ddb1..1b138bf 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -101,12 +101,12 @@ pub const Leader = struct { if (self.message) |message| return message; return switch (self.mode) { .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, .search_rail => "search: f current file p project s symbols", - .language_rail => "language: h hover s signature o open hover", - .diagnostic_rail => "diagnostics: d/open n next p previous f filter-source", - .tool_rail => "tools: l lint-file L lint-project b build t test c check x cancel j jump y yank", + .language_rail => "language: h hover s sig f fmt o imports a actions (Space path)", + .diagnostic_rail => "diagnostics: d open n/p next/prev f source (arrows/Enter ok)", + .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", }; } @@ -443,7 +443,7 @@ test "regular: space opens a visible leader rail and write dispatches" { defer leader.deinit(); 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 std.testing.expect(!leader.isActive()); diff --git a/src/tui.zig b/src/tui.zig index c61d35e..9337dd1 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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, "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, "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); 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, "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.jumpToMatch(null); 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, "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_up => try self.repeatProtocol("command move_up", 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"), }, .unknown => self.unknownPrefixOrInput("normal"), @@ -1060,6 +1071,10 @@ pub const Client = struct { .backspace => try self.applyMutatingProtocol("command delete_backward"), .arrow_left => try self.applyProtocol("command move_left"), .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 => {}, }, .unknown => {}, @@ -1074,6 +1089,7 @@ pub const Client = struct { self.message = "normal"; 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, "g")) return self.openPrefix(.go); if (text.len == 1) { @@ -1092,6 +1108,10 @@ pub const Client = struct { }, .arrow_left => try self.applyProtocol("command move_left"), .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 => {}, }, .unknown => self.unknownPrefixOrInput("select"), @@ -1110,6 +1130,8 @@ pub const Client = struct { .key => |key| switch (key) { .arrow_down => try self.applyProtocol("command list_down"), .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(), .escape => try self.closeActivePanel(), else => {}, @@ -4581,3 +4603,112 @@ test "regular: provider edit rows parse source scope and replacement" { defer std.testing.allocator.free(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); +}