From 9f4c1539e47b00c76333572329a2d4abcb173ad5 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 20:15:10 +0200 Subject: [PATCH] Fix select motions and mobile line aliases --- KEYMAP.md | 15 ++++--- REQUIREMENTS.md | 4 ++ src/tui.zig | 93 +++++++++++++++++++++++++++++++------------ tools/terminal_e2e.py | 11 ++++- 4 files changed, 89 insertions(+), 34 deletions(-) diff --git a/KEYMAP.md b/KEYMAP.md index f33c36e..1824a10 100644 --- a/KEYMAP.md +++ b/KEYMAP.md @@ -97,17 +97,19 @@ selection entry, leader rails, tool commands, save/quit, and recoverability. Text-entry mode for ordinary typing. `mim` starts in Normal mode; Insert mode is entered explicitly with `i`, `a`, `o`, or another visible command. In Insert mode, -`Space` commits a literal space immediately. Physical keyboards may use +`Space` commits a literal space immediately and `Tab` inserts spaces by default +(expandtab, matching the dotfiles Neovim baseline). Physical keyboards may use conventional direct keys such as Escape as aliases back to Normal, but those aliases are optional accelerators, not required controls. ### Select mode Selection-building mode. Movement extends the active selection by default. -Selection commands are semantic: select word, line, current indent block, -matching delimiter pair, enclosing text object, parameter, diagnostic range, and -visible panel item. Select mode can apply operations such as delete, replace, -copy, format, code action, or explain to the selection. +Semantic object selections live behind the match/object rail (`m w`, `m l`, +`m i`, `m p`, etc.) so bare `h/j/k/l`, word motions, arrows, and mobile line +aliases keep behaving as selection-extending motions. Select mode can apply +operations such as delete, replace, copy, format, code action, or explain to the +selection. ### Panel mode @@ -179,7 +181,8 @@ such as arrow keys, Home/End, PageUp/PageDown, Escape, or `%`. | `O` or `Space o` `a` | open line above and enter Insert | | `h` `j` `k` `l` | left/down/up/right where comfortable; arrows are aliases | | `w` `b` `e` | word forward/back/end | -| `0` `$` | line start/end when available; `Space g` `l` opens line targets | +| `H` `L` | mobile-reachable line start/end aliases | +| `0` `^` `$` | attached/Vim-style line start/first-nonblank/end aliases; `Space g` `l` opens line targets | | `u` | undo | | `Space u` `r` | redo | | `Space w` | write/save current buffer | diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c939eec..9ff1ca0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -18,11 +18,15 @@ Rows are redgate TSV requirements: `ringidsummary [tag]`. 2 005 Keyboard layout profiles SHALL be source-patched tables backed by recorded terminal traces, starting with iOS QWERTZ. [mobile] 1 006 Physical keyboard input SHALL be a first-class terminal path with discoverable shortcuts and no loss of mobile no-required-modifier command access. [mobile] 1 007 The v1 keymap SHALL keep modes, verbs, objects, counts/repetition, panels, and tool operations orthogonal so operations compose instead of multiplying bindings. [mobile] +1 008 Line start/end motions SHALL have mobile-reachable aliases in addition to attached/Vim-style symbol or digit keys. [mobile] +1 009 Insert-mode Tab SHALL expand to spaces by default. [mobile] +1 010 Select mode SHALL extend the active range with motions and keep semantic object selection behind an explicit object rail. [mobile] ## ui 0 001 Narrow terminals SHALL use transient panels instead of permanent desktop splits. [mobile] 1 002 Files, git, diagnostics, LSP, build, terminal, and Pi surfaces SHALL share one panel navigation model. [panels] +0 003 When the cursor moves beyond the visible horizontal viewport, the editor SHALL scroll the line horizontally enough to keep the cursor cell visible. [mobile] ## coding diff --git a/src/tui.zig b/src/tui.zig index 786f794..80d2100 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1055,6 +1055,8 @@ pub const Client = struct { return; } if (std.mem.eql(u8, text, "s")) { + const snap = try self.session.snapshot(); + try self.session.selectRange(snap.cursor_byte, snap.cursor_byte); self.mode = .select; self.message = "select"; self.pending_count = 0; @@ -1070,8 +1072,8 @@ pub const Client = struct { if (std.mem.eql(u8, text, "p")) return self.pasteRegister(); if (std.mem.eql(u8, text, "u")) return self.undoEdit(); 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, "H") or std.mem.eql(u8, text, "0") or std.mem.eql(u8, text, "^")) return self.applyProtocol("command move_line_start"); + if (std.mem.eql(u8, text, "L") or 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); @@ -1110,7 +1112,7 @@ pub const Client = struct { .key => |key| switch (key) { .space => try self.insertText(" "), .enter => try self.insertNewlineWithIndent(), - .tab => try self.insertText(try self.smartTabText()), + .tab => try self.insertText(" "), .escape => { self.mode = .normal; self.message = "normal"; @@ -1136,7 +1138,16 @@ pub const Client = struct { self.message = "normal"; return; } - if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null); + if (std.mem.eql(u8, text, "%")) return self.extendSelectionWithJump(null); + if (std.mem.eql(u8, text, "H") or std.mem.eql(u8, text, "0") or std.mem.eql(u8, text, "^")) return self.extendSelectionWithProtocol("command move_line_start"); + if (std.mem.eql(u8, text, "L") or std.mem.eql(u8, text, "$")) return self.extendSelectionWithProtocol("command move_line_end"); + if (std.mem.eql(u8, text, "j")) return self.repeatSelectionProtocol("command move_down", self.takeRepeat()); + if (std.mem.eql(u8, text, "k")) return self.repeatSelectionProtocol("command move_up", self.takeRepeat()); + if (std.mem.eql(u8, text, "h")) return self.repeatSelectionProtocol("command move_left", self.takeRepeat()); + if (std.mem.eql(u8, text, "l")) return self.repeatSelectionProtocol("command move_right", self.takeRepeat()); + if (std.mem.eql(u8, text, "w")) return self.repeatSelectionProtocol("command move_word_forward", self.takeRepeat()); + if (std.mem.eql(u8, text, "b")) return self.repeatSelectionProtocol("command move_word_back", self.takeRepeat()); + if (std.mem.eql(u8, text, "e")) return self.repeatSelectionProtocol("command move_word_end", self.takeRepeat()); 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) { @@ -1153,12 +1164,12 @@ pub const Client = struct { self.mode = .normal; self.message = "normal"; }, - .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"), + .arrow_left => try self.extendSelectionWithProtocol("command move_left"), + .arrow_right => try self.extendSelectionWithProtocol("command move_right"), + .arrow_up => try self.extendSelectionWithProtocol("command move_up"), + .arrow_down => try self.extendSelectionWithProtocol("command move_down"), + .home => try self.extendSelectionWithProtocol("command move_line_start"), + .end => try self.extendSelectionWithProtocol("command move_line_end"), else => {}, }, .unknown => self.unknownPrefixOrInput("select"), @@ -1868,6 +1879,13 @@ pub const Client = struct { } return; } + if (self.effectiveMode() == .select and text.len == 1) { + if (self.objectRangeForKey(text[0])) |range| { + try self.session.selectRange(range.start, range.end); + self.message = "select"; + return; + } else |_| {} + } self.unknownPrefixOrInput("normal"); } @@ -2157,20 +2175,6 @@ pub const Client = struct { try self.insertText(text.items); } - fn smartTabText(self: *Client) ![]const u8 { - const snap = try self.session.snapshot(); - const line_start = currentLineStart(snap.bytes, snap.cursor_byte); - if (line_start < snap.bytes.len and snap.bytes[line_start] == '\t') return "\t"; - var i: usize = 0; - while (i < snap.bytes.len) { - if (snap.bytes[i] == '\t') return "\t"; - while (i < snap.bytes.len and snap.bytes[i] != '\n') : (i += 1) {} - if (i < snap.bytes.len) i += 1; - while (i < snap.bytes.len and snap.bytes[i] == ' ') : (i += 1) {} - } - return " "; - } - fn currentLineStart(bytes: []const u8, cursor_byte: usize) usize { var start = @min(cursor_byte, bytes.len); while (start > 0 and bytes[start - 1] != '\n') start -= 1; @@ -2202,6 +2206,29 @@ pub const Client = struct { while (i < repeat) : (i += 1) try self.applyProtocol(line); } + fn extendSelectionWithProtocol(self: *Client, line: []const u8) !void { + const before = try self.session.snapshot(); + const anchor = if (before.selection) |selection| selection.anchor else before.cursor_byte; + try self.applyProtocol(line); + const after = try self.session.snapshot(); + try self.session.selectRange(@min(anchor, after.cursor_byte), @max(anchor, after.cursor_byte)); + self.message = "select"; + } + + fn repeatSelectionProtocol(self: *Client, line: []const u8, repeat: usize) !void { + var i: usize = 0; + while (i < repeat) : (i += 1) try self.extendSelectionWithProtocol(line); + } + + fn extendSelectionWithJump(self: *Client, delimiter: ?u8) !void { + const before = try self.session.snapshot(); + const anchor = if (before.selection) |selection| selection.anchor else before.cursor_byte; + try self.jumpToMatch(delimiter); + const after = try self.session.snapshot(); + try self.session.selectRange(@min(anchor, after.cursor_byte), @max(anchor, after.cursor_byte)); + self.message = "select"; + } + fn repeatMutatingProtocol(self: *Client, line: []const u8, repeat: usize) !void { var i: usize = 0; while (i < repeat) : (i += 1) try self.applyMutatingProtocol(line); @@ -2417,9 +2444,13 @@ fn appendEditorLine( try out.append(allocator, '\n'); return; } + const horizontal_scroll = if (is_cursor_line and cursor_col >= content_width) + cursor_col - content_width + 1 + else + 0; if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); - try appendEditorCells(allocator, out, line, line_start_byte, content_width, is_cursor_line, cursor_col, cursor_byte, selection); + try appendEditorCells(allocator, out, line, line_start_byte, content_width, is_cursor_line, cursor_col, cursor_byte, selection, horizontal_scroll); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, '\n'); } @@ -2458,11 +2489,19 @@ fn appendEditorCells( cursor_col: usize, cursor_byte: usize, selection: ?session_mod.Selection, + horizontal_scroll: usize, ) !void { var i: usize = 0; var source_col: usize = 0; var visual_col: usize = 0; var drew_cursor = false; + while (i < bytes.len and source_col < horizontal_scroll) { + const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; + const end = @min(bytes.len, i + len); + const width = @max(@as(usize, 1), session_mod.cellWidth(bytes[i..end])); + source_col += width; + i = end; + } while (i < bytes.len and visual_col < max_cells) { const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; const end = @min(bytes.len, i + len); @@ -4178,12 +4217,14 @@ test "regular: select mode object grammar selects word line indent parameter and try client.handleInput("j"); try client.handleInput("w"); try client.handleInput("s"); + try client.handleInput("m"); try client.handleInput("w"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); try client.handleInput("n"); try client.handleInput("s"); + try client.handleInput("m"); try client.handleInput("l"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings(" alpha(beta, gamma);\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); @@ -5100,5 +5141,5 @@ test "regular: insert Enter copies current line indent and Tab follows tab-inden try tabs.handleTraceLine("key tab"); try tabs.handleInput("x"); snap = try tabs.session.snapshot(); - try std.testing.expectEqualStrings("\titem\tx", snap.bytes); + try std.testing.expectEqualStrings("\titem x", snap.bytes); } diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 040ea1b..2f31686 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -448,6 +448,12 @@ def setup_long(tmp: Path) -> tuple[Path, str | None]: return target, "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n" +def setup_long_line(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "wide.txt" + target.write_text("abcdefghijklmnopqrstuvwxyz0123456789\n", encoding="utf-8") + return target, "abcdefghijklmnopqrstuvwxyz0123456789\n" + + def setup_multifile(tmp: Path) -> tuple[Path, str | None]: root = tmp / "repo" root.mkdir() @@ -495,11 +501,12 @@ SCENARIOS = [ Scenario("attached-cursor-left-insert", 56, 14, "empty", "attached-keyboard", "insert-cursor", "truecolor", "file", setup_empty, (b"i", b"a", b"b", b"\x1b[D", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "aXb", ("1│", "☻", "aX", "mode:normal")), Scenario("normal-A-append", 56, 14, "existing", "attached-keyboard", "append-eol", "truecolor", "file", setup_existing, (b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), "alpha!\nbeta", ("1│", "☻", "alpha")), Scenario("insert-enter-indent", 56, 14, "indented", "attached-keyboard", "newline-indent", "truecolor", "file", setup_indented, (b"A", b"\r", b"x", b"\x1b", b" ", b"w", b" ", b"q"), " item\n x", ("1│", "2│", "☻")), - Scenario("insert-tab-detects-tabs", 56, 14, "tabbed", "attached-keyboard", "tab-indent", "truecolor", "file", setup_tabbed, (b"A", b"\t", b"x", b"\x1b", b" ", b"w", b" ", b"q"), "\titem\tx", ("1│", "☻")), + Scenario("insert-tab-expands-spaces", 56, 14, "tabbed", "attached-keyboard", "tab-indent", "truecolor", "file", setup_tabbed, (b"A", b"\t", b"x", b"\x1b", b" ", b"w", b" ", b"q"), "\titem x", ("1│", "☻")), Scenario("page-keys-move-cursor", 56, 10, "long", "attached-keyboard", "page-keys", "truecolor", "file", setup_long, (b"\x1b[6~", b"\x1b[5~", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "l1X\nl2\nl3\nl4\nl5\nl6\nl7\nl8", ("1│", "☻")), Scenario("counted-move-inserts-at-count", 56, 12, "short", "attached-keyboard", "counts", "truecolor", "file", setup_short, (b"3", b"l", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcXdef", ("1│", "☻")), Scenario("percent-match-jump", 56, 12, "brackets", "attached-keyboard", "match-jump", "truecolor", "file", setup_brackets, (b"%", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "(abX)", ("1│", "☻")), - Scenario("select-mode-colors", 56, 12, "short", "attached-keyboard", "select", "truecolor", "file", setup_short, (b"s", b"w", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "░", "mode:normal")), + Scenario("select-mode-motions", 56, 12, "short", "attached-keyboard", "select", "truecolor", "file", setup_short, (b"L", b"s", b"H", b"L", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "░", "mode:normal")), + Scenario("horizontal-cursor-scroll", 24, 8, "wide-line", "attached-keyboard", "horizontal-scroll", "truecolor", "file", setup_long_line, (b"L", b" ", b"q"), "abcdefghijklmnopqrstuvwxyz0123456789\n", ("0123456789", "☻", "mode:normal")), Scenario("multi-file-coding-loop", 72, 16, "repo-multifile", "attached-keyboard", "multi-file", "truecolor", "file", setup_multifile, (b"A", b"?", b"\x1b", b" ", b"w", b" ", b"o", b"r", b"e", b"p", b"o", b"/", b"a", b"l", b"p", b"h", b"a", b".", b"z", b"i", b"g", b"\r", b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), None, ("alpha.zig", "beta", "☻", "mode:normal"), max_key_events=30, expect_files=(("alpha.zig", "alpha!\n"), ("beta.zig", "beta?"))), Scenario("lsp-assisted-coding", 60, 12, "lsp-fixture", "attached-keyboard", "lsp", "truecolor", "file", setup_lsp_assist, (b" ", b"l", b"h", b" ", b"l", b"s", b" ", b"d", b"q", b" ", b"d", b"n", b" ", b"l", b"f", b" ", b"w", b" ", b"q"), "ZLS", ("[zls] add(lhs, rhs)", "[zls] add(lhs: i32, rhs: i32) active=rhs", "diag:fresh:zls", "format:zls:applied", "ZLS", "mode:normal"), max_key_events=19, prelude=LSP_ASSIST_PRELUDE), Scenario("project-search-panel", 60, 12, "repo-search", "attached-keyboard", "project-search", "truecolor", "file", setup_project_search, (b" ", b"s", b"p", b"f", b"i", b"n", b"d", b"m", b"e", b"\r", b"q", b" ", b"q"), "main\n", ("panel [search]", "target.zig", "findme", "mode:normal"), max_key_events=13),