diff --git a/KEYMAP.md b/KEYMAP.md index 72bf4e7..2df2571 100644 --- a/KEYMAP.md +++ b/KEYMAP.md @@ -213,8 +213,8 @@ such as arrow keys, Home/End, PageUp/PageDown, Escape, or `%`. | `w` `b` `e` | word forward/back/end | | `gg` `G` | document start / bottom line | | `Ctrl-u` `Ctrl-d` | half-page up/down when the SSH client exposes Ctrl; `g u` / `g d` are mobile fallback aliases | -| `H` `L` | mobile-reachable line start/end aliases | -| `0` `^` `$` | attached/Vim-style line start/first-nonblank/end aliases; `Space g` `l` opens line targets | +| `H` `L` | mobile-reachable absolute line start/end aliases | +| `0` `^` `$` | attached/Vim-style line start/first-nonblank/end aliases; `^` jumps past leading spaces/tabs, not to column zero; `Space g` `l` opens line targets | | `.` | repeat the last repeatable local edit (insert/change/delete/put/open-line subset) | | `u` | undo | | `Space u` `r` | redo | diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 5c55fb1..5101c6d 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -18,7 +18,7 @@ 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 008 Line start/end motions SHALL have mobile-reachable aliases in addition to attached/Vim-style symbol or digit keys, and first-nonblank line motion SHALL remain distinct from absolute line start. [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] 1 011 Vertical line motions SHALL preserve a preferred cursor cell across ragged lines, clamped to each target line until horizontal/editing motion resets the preference. [mobile] diff --git a/src/protocol.zig b/src/protocol.zig index 405730a..fd9ba40 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -96,6 +96,7 @@ fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, if (std.mem.eql(u8, command, "move_word_forward")) return dispatchAndRespond(allocator, session, .move_word_forward); if (std.mem.eql(u8, command, "move_word_back")) return dispatchAndRespond(allocator, session, .move_word_back); if (std.mem.eql(u8, command, "move_word_end")) return dispatchAndRespond(allocator, session, .move_word_end); + if (std.mem.eql(u8, command, "move_line_first_nonblank")) return dispatchAndRespond(allocator, session, .move_line_first_nonblank); if (std.mem.eql(u8, command, "move_line_start")) return dispatchAndRespond(allocator, session, .move_line_start); if (std.mem.eql(u8, command, "move_line_end")) return dispatchAndRespond(allocator, session, .move_line_end); if (std.mem.eql(u8, command, "move_document_start")) return dispatchAndRespond(allocator, session, .move_document_start); @@ -489,3 +490,19 @@ test "adversarial: protocol list errors are explicit" { try std.testing.expectEqualStrings("err no panel open\n", response); } } + +test "regular: protocol exposes first-nonblank line motion" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + var response = try handleLine(std.testing.allocator, &session, "open top\n alpha\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_down\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_line_end\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_line_first_nonblank\n"); + defer std.testing.allocator.free(response); + + try std.testing.expectEqualStrings("ok state cursor_byte=6 cursor_cell=2 bytes_len=11 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response); +} diff --git a/src/session.zig b/src/session.zig index f0bab2c..d73864e 100644 --- a/src/session.zig +++ b/src/session.zig @@ -31,6 +31,7 @@ pub const Command = union(enum) { move_word_forward, move_word_back, move_word_end, + move_line_first_nonblank, insert: []const u8, insert_pair: Pair, delete_backward, @@ -95,6 +96,7 @@ pub const Buffer = struct { .move_word_forward => self.moveWordForward(), .move_word_back => self.moveWordBack(), .move_word_end => self.moveWordEnd(), + .move_line_first_nonblank => self.moveLineFirstNonblank(), .insert => |text| try self.insert(text), .insert_pair => |pair| try self.insertPair(pair), .delete_backward => self.deleteBackward(), @@ -267,6 +269,14 @@ pub const Buffer = struct { self.refreshCellResetPreferred(); } + pub fn moveLineFirstNonblank(self: *Buffer) void { + const range = self.currentLineRange(false); + var at = range.start; + while (at < range.end and (self.bytes.items[at] == ' ' or self.bytes.items[at] == '\t')) : (at += 1) {} + self.cursor.byte = at; + self.refreshCellResetPreferred(); + } + pub fn moveDocumentStart(self: *Buffer) void { self.cursor.byte = 0; self.refreshCellResetPreferred(); @@ -795,3 +805,33 @@ test "regular: document motions jump to top and bottom line" { try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte); try std.testing.expectEqual(@as(usize, 0), snap.cursor_cell); } + +test "regular: line first-nonblank is distinct from absolute line start" { + var buffer = try Buffer.openFromBytes(std.testing.allocator, "top\n \talpha\nend"); + defer buffer.deinit(); + + buffer.moveDown(); + buffer.moveLineEnd(); + try std.testing.expectEqual(@as(usize, 12), buffer.cursor.byte); + + buffer.moveLineStart(); + try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte); + + buffer.moveLineEnd(); + buffer.moveLineFirstNonblank(); + try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte); + try std.testing.expectEqual(@as(usize, 2), buffer.cursor.cell); +} + +test "adversarial: line first-nonblank on blank or whitespace-only line goes to line end" { + var buffer = try Buffer.openFromBytes(std.testing.allocator, "a\n \n\t\tb"); + defer buffer.deinit(); + + buffer.moveDown(); + buffer.moveLineFirstNonblank(); + try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte); + + buffer.moveDown(); + buffer.moveLineFirstNonblank(); + try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte); +} diff --git a/src/tui.zig b/src/tui.zig index 243b536..c14f0dc 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1115,7 +1115,8 @@ pub const Client = struct { if (std.mem.eql(u8, text, ".")) return self.repeatLastEdit(); 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, "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, "H") or 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_first_nonblank"); 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); @@ -1186,7 +1187,8 @@ pub const Client = struct { return; } 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, "H") or std.mem.eql(u8, text, "0")) return self.extendSelectionWithProtocol("command move_line_start"); + if (std.mem.eql(u8, text, "^")) return self.extendSelectionWithProtocol("command move_line_first_nonblank"); 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()); diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 372ece6..70a026f 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -552,6 +552,7 @@ SCENARIOS = [ 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-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("first-nonblank-line-motion", 56, 12, "indented", "attached-keyboard", "line-edges", "truecolor", "file", setup_indented, (b"L", b"^", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), " Xitem", ("1│", "☻", "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("vertical-motion-preferred-column", 56, 12, "ragged", "attached-keyboard", "motion", "truecolor", "file", setup_ragged, (b"L", b"j", b"j", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcdef\nxy\n123456X", ("1│", "3│", "☻", "mode:normal")), Scenario("document-half-page-motions", 56, 10, "tall", "attached-keyboard", "motion", "truecolor", "file", setup_tall, (b"G", b"i", b"X", b"\x1b", b"g", b"g", b"\x04", b"i", b"Y", b"\x1b", b"G", b"g", b"u", b"i", b"Z", b"\x1b", b" ", b"w", b" ", b"q"), "l1\nl2\nl3\nl4\nl5\nZl6\nl7\nl8\nl9\nl10\nYl11\nl12\nl13\nl14\nl15\nXl16", ("11│", "16│", "☻", "mode:normal")),