Separate first nonblank line motion

This commit is contained in:
slhx agent
2026-06-21 22:34:51 +02:00
parent d5c31a92c5
commit d7bf5edc1f
6 changed files with 65 additions and 5 deletions
+2 -2
View File
@@ -213,8 +213,8 @@ such as arrow keys, Home/End, PageUp/PageDown, Escape, or `%`.
| `w` `b` `e` | word forward/back/end | | `w` `b` `e` | word forward/back/end |
| `gg` `G` | document start / bottom line | | `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 | | `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 | | `H` `L` | mobile-reachable absolute line start/end aliases |
| `0` `^` `$` | attached/Vim-style line start/first-nonblank/end aliases; `Space g` `l` opens line targets | | `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) | | `.` | repeat the last repeatable local edit (insert/change/delete/put/open-line subset) |
| `u` | undo | | `u` | undo |
| `Space u` `r` | redo | | `Space u` `r` | redo |
+1 -1
View File
@@ -18,7 +18,7 @@ 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] 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 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 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 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 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] 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]
+17
View File
@@ -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_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_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_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_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_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); 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); 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);
}
+40
View File
@@ -31,6 +31,7 @@ pub const Command = union(enum) {
move_word_forward, move_word_forward,
move_word_back, move_word_back,
move_word_end, move_word_end,
move_line_first_nonblank,
insert: []const u8, insert: []const u8,
insert_pair: Pair, insert_pair: Pair,
delete_backward, delete_backward,
@@ -95,6 +96,7 @@ pub const Buffer = struct {
.move_word_forward => self.moveWordForward(), .move_word_forward => self.moveWordForward(),
.move_word_back => self.moveWordBack(), .move_word_back => self.moveWordBack(),
.move_word_end => self.moveWordEnd(), .move_word_end => self.moveWordEnd(),
.move_line_first_nonblank => self.moveLineFirstNonblank(),
.insert => |text| try self.insert(text), .insert => |text| try self.insert(text),
.insert_pair => |pair| try self.insertPair(pair), .insert_pair => |pair| try self.insertPair(pair),
.delete_backward => self.deleteBackward(), .delete_backward => self.deleteBackward(),
@@ -267,6 +269,14 @@ pub const Buffer = struct {
self.refreshCellResetPreferred(); 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 { pub fn moveDocumentStart(self: *Buffer) void {
self.cursor.byte = 0; self.cursor.byte = 0;
self.refreshCellResetPreferred(); 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_byte);
try std.testing.expectEqual(@as(usize, 0), snap.cursor_cell); 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);
}
+4 -2
View File
@@ -1115,7 +1115,8 @@ pub const Client = struct {
if (std.mem.eql(u8, text, ".")) return self.repeatLastEdit(); 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.undoEdit();
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, "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, "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, "%")) 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);
@@ -1186,7 +1187,8 @@ pub const Client = struct {
return; return;
} }
if (std.mem.eql(u8, text, "%")) return self.extendSelectionWithJump(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, "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, "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, "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, "k")) return self.repeatSelectionProtocol("command move_up", self.takeRepeat());
+1
View File
@@ -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("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("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("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("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("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")), 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")),