Fix select motions and mobile line aliases

This commit is contained in:
slhx agent
2026-06-21 20:15:10 +02:00
parent b83431aefc
commit 9f4c1539e4
4 changed files with 89 additions and 34 deletions
+9 -6
View File
@@ -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 |
+4
View File
@@ -18,11 +18,15 @@ Rows are redgate TSV requirements: `ring<TAB>id<TAB>summary [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
+67 -26
View File
@@ -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);
}
+9 -2
View File
@@ -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),