Add core editing commands

This commit is contained in:
slhx agent
2026-06-21 12:31:27 +02:00
parent 1468675792
commit a08ee02820
3 changed files with 543 additions and 18 deletions
+319 -18
View File
@@ -34,6 +34,15 @@ const PrefixRail = enum {
match,
go,
repeat,
delete,
change,
yank,
replace,
};
const EditSnapshot = struct {
bytes: []u8,
cursor_byte: usize,
};
pub const Error = error{
@@ -116,6 +125,9 @@ pub const Client = struct {
prefix: PrefixRail = .none,
pending_count: usize = 0,
last_rail: PrefixRail = .none,
undo_stack: std.ArrayList(EditSnapshot) = .empty,
redo_stack: std.ArrayList(EditSnapshot) = .empty,
yank_bytes: ?[]u8 = null,
pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client {
return initWithIo(allocator, viewport, null);
@@ -135,6 +147,9 @@ pub const Client = struct {
pub fn deinit(self: *Client) void {
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
if (self.yank_bytes) |bytes| self.allocator.free(bytes);
self.freeSnapshotStack(&self.undo_stack);
self.freeSnapshotStack(&self.redo_stack);
self.repo.deinit();
self.leader.deinit();
self.session.deinit();
@@ -206,7 +221,7 @@ pub const Client = struct {
if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line);
if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left");
if (std.mem.eql(u8, line, "right")) return self.applyProtocol("command move_right");
if (std.mem.eql(u8, line, "backspace")) return self.applyProtocol("command delete_backward");
if (std.mem.eql(u8, line, "backspace")) return self.applyMutatingProtocol("command delete_backward");
if (std.mem.eql(u8, line, "save")) return self.save();
if (std.mem.startsWith(u8, line, "resize ")) return self.resize(line[7..]);
if (std.mem.eql(u8, line, "render")) return;
@@ -400,6 +415,10 @@ pub const Client = struct {
.match => "match: m jump s inside a around ( { [ quotes",
.go => "go: d definition r references e diagnostic a parameter",
.repeat => "repeat: digits count . repeat-last",
.delete => "delete: d line h previous-char l char",
.change => "change: c line h previous-char l char",
.yank => "yank: y line",
.replace => "replace: next key replaces char",
};
}
@@ -735,26 +754,51 @@ pub const Client = struct {
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "a")) {
try self.applyProtocol("command move_right");
self.mode = .insert;
self.message = "insert";
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "o")) {
try self.applyMutatingProtocol("command open_line_below");
self.mode = .insert;
self.message = "insert";
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "O")) {
try self.applyMutatingProtocol("command open_line_above");
self.mode = .insert;
self.message = "insert";
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "s")) {
self.mode = .select;
self.message = "select";
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "d")) return self.openPrefix(.delete);
if (std.mem.eql(u8, text, "c")) return self.openPrefix(.change);
if (std.mem.eql(u8, text, "y")) return self.openPrefix(.yank);
if (std.mem.eql(u8, text, "r")) return self.openPrefix(.replace);
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, "m")) return self.openPrefix(.match);
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
if (std.mem.eql(u8, text, "j")) {
_ = self.takeRepeat();
self.message = "vertical movement is not built in this profile yet";
return;
}
if (std.mem.eql(u8, text, "k")) {
_ = self.takeRepeat();
self.message = "vertical movement is not built in this profile yet";
return;
}
if (std.mem.eql(u8, text, "j")) return self.repeatProtocol("command move_down", self.takeRepeat());
if (std.mem.eql(u8, text, "k")) return self.repeatProtocol("command move_up", self.takeRepeat());
if (std.mem.eql(u8, text, "h")) return self.repeatProtocol("command move_left", self.takeRepeat());
if (std.mem.eql(u8, text, "l")) return self.repeatProtocol("command move_right", self.takeRepeat());
if (std.mem.eql(u8, text, "w")) return self.repeatProtocol("command move_word_forward", self.takeRepeat());
if (std.mem.eql(u8, text, "b")) return self.repeatProtocol("command move_word_back", self.takeRepeat());
if (std.mem.eql(u8, text, "e")) return self.repeatProtocol("command move_word_end", self.takeRepeat());
self.unknownPrefixOrInput("normal");
},
.key => |key| switch (key) {
@@ -762,13 +806,11 @@ pub const Client = struct {
self.pending_count = 0;
self.message = "normal";
},
.backspace => try self.applyProtocol("command delete_backward"),
.backspace => try self.applyMutatingProtocol("command delete_backward"),
.arrow_left => try self.repeatProtocol("command move_left", self.takeRepeat()),
.arrow_right => try self.repeatProtocol("command move_right", self.takeRepeat()),
.arrow_up, .arrow_down => {
_ = self.takeRepeat();
self.message = "vertical movement is not built in this profile yet";
},
.arrow_up => try self.repeatProtocol("command move_up", self.takeRepeat()),
.arrow_down => try self.repeatProtocol("command move_down", self.takeRepeat()),
else => self.unknownPrefixOrInput("normal"),
},
.unknown => self.unknownPrefixOrInput("normal"),
@@ -784,7 +826,7 @@ pub const Client = struct {
self.mode = .normal;
self.message = "normal";
},
.backspace => try self.applyProtocol("command delete_backward"),
.backspace => try self.applyMutatingProtocol("command delete_backward"),
.arrow_left => try self.applyProtocol("command move_left"),
.arrow_right => try self.applyProtocol("command move_right"),
else => {},
@@ -848,6 +890,10 @@ pub const Client = struct {
.match => self.applyKnownRailOrMessage(event, "match rail ready"),
.go => self.applyKnownRailOrMessage(event, "go rail ready"),
.repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"),
.delete => try self.applyDeleteRail(event),
.change => try self.applyChangeRail(event),
.yank => try self.applyYankRail(event),
.replace => try self.applyReplaceRail(event),
}
}
@@ -908,10 +954,146 @@ pub const Client = struct {
self.message = null;
}
fn applyDeleteRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
if (std.mem.eql(u8, text, "d")) {
try self.yankCurrentLine(true);
try self.applyMutatingProtocol("command delete_line");
return;
}
if (std.mem.eql(u8, text, "h")) return self.applyMutatingProtocol("command delete_backward");
if (std.mem.eql(u8, text, "l")) return self.applyMutatingProtocol("command delete_forward");
self.unknownPrefixOrInput("normal");
}
fn applyChangeRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
if (std.mem.eql(u8, text, "c")) {
try self.yankCurrentLine(false);
try self.applyMutatingProtocol("command change_line");
self.mode = .insert;
self.message = "insert";
return;
}
if (std.mem.eql(u8, text, "h")) {
try self.applyMutatingProtocol("command delete_backward");
self.mode = .insert;
self.message = "insert";
return;
}
if (std.mem.eql(u8, text, "l")) {
try self.applyMutatingProtocol("command delete_forward");
self.mode = .insert;
self.message = "insert";
return;
}
self.unknownPrefixOrInput("normal");
}
fn applyYankRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
if (std.mem.eql(u8, text, "y")) {
try self.yankCurrentLine(true);
self.message = "yanked line";
return;
}
self.unknownPrefixOrInput("normal");
}
fn applyReplaceRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
const command = try std.fmt.allocPrint(self.allocator, "command replace_char {s}", .{text});
defer self.allocator.free(command);
try self.applyMutatingProtocol(command);
}
fn eventText(event: input.Event) ?[]const u8 {
return switch (event) {
.text => |text| text,
.key => |key| switch (key) {
.space => " ",
else => null,
},
.unknown => null,
};
}
fn pasteRegister(self: *Client) !void {
const bytes = self.yank_bytes orelse {
self.message = "nothing yanked";
return;
};
try self.recordUndo();
self.clearRedo();
self.session.dispatch(.{ .insert = bytes }) catch |err| {
self.dropLastUndoSnapshot();
return err;
};
}
fn yankCurrentLine(self: *Client, include_newline: bool) !void {
const snap = try self.session.snapshot();
var start = snap.cursor_byte;
while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1;
var end = snap.cursor_byte;
while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1;
if (include_newline and end < snap.bytes.len and snap.bytes[end] == '\n') end += 1;
const copy = try self.allocator.dupe(u8, snap.bytes[start..end]);
if (self.yank_bytes) |old| self.allocator.free(old);
self.yank_bytes = copy;
}
fn undoEdit(self: *Client) !void {
const previous = self.undo_stack.pop() orelse {
self.message = "nothing to undo";
return;
};
const current = try self.takeCurrentSnapshot();
try self.redo_stack.append(self.allocator, current);
try self.restoreSnapshot(previous);
self.allocator.free(previous.bytes);
self.message = "undo";
}
fn redoEdit(self: *Client) !void {
const next = self.redo_stack.pop() orelse {
self.message = "nothing to redo";
return;
};
const current = try self.takeCurrentSnapshot();
try self.undo_stack.append(self.allocator, current);
try self.restoreSnapshot(next);
self.allocator.free(next.bytes);
self.message = "redo";
}
fn takeCurrentSnapshot(self: *Client) !EditSnapshot {
const snap = try self.session.snapshot();
return .{ .bytes = try self.allocator.dupe(u8, snap.bytes), .cursor_byte = snap.cursor_byte };
}
fn insertText(self: *Client, text: []const u8) !void {
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text});
defer self.allocator.free(line);
try self.applyProtocolCommand(line);
try self.applyMutatingProtocolCommand(line);
}
fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void {
try self.recordUndo();
self.clearRedo();
self.applyProtocolCommand(line) catch |err| {
self.dropLastUndoSnapshot();
return err;
};
}
fn applyMutatingProtocol(self: *Client, line: []const u8) !void {
try self.recordUndo();
self.clearRedo();
self.applyProtocol(line) catch |err| {
self.dropLastUndoSnapshot();
return err;
};
}
fn repeatProtocol(self: *Client, line: []const u8, repeat: usize) !void {
@@ -919,6 +1101,39 @@ pub const Client = struct {
while (i < repeat) : (i += 1) try self.applyProtocol(line);
}
fn repeatMutatingProtocol(self: *Client, line: []const u8, repeat: usize) !void {
var i: usize = 0;
while (i < repeat) : (i += 1) try self.applyMutatingProtocol(line);
}
fn recordUndo(self: *Client) !void {
const snap = try self.session.snapshot();
const bytes = try self.allocator.dupe(u8, snap.bytes);
errdefer self.allocator.free(bytes);
try self.undo_stack.append(self.allocator, .{
.bytes = bytes,
.cursor_byte = snap.cursor_byte,
});
}
fn dropLastUndoSnapshot(self: *Client) void {
if (self.undo_stack.pop()) |snap| self.allocator.free(snap.bytes);
}
fn restoreSnapshot(self: *Client, snap: EditSnapshot) !void {
try self.session.openFixtureAt(snap.bytes, snap.cursor_byte);
}
fn freeSnapshotStack(self: *Client, stack: *std.ArrayList(EditSnapshot)) void {
for (stack.items) |snap| self.allocator.free(snap.bytes);
stack.deinit(self.allocator);
}
fn clearRedo(self: *Client) void {
self.freeSnapshotStack(&self.redo_stack);
self.redo_stack = .empty;
}
fn unknownPrefixOrInput(self: *Client, mode: []const u8) void {
self.pending_count = 0;
self.message = if (std.mem.eql(u8, mode, "normal"))
@@ -2448,3 +2663,89 @@ test "regular: insert pending space commits literal space or returns normal" {
try client.handleInput("n");
try std.testing.expectEqualStrings("normal", client.modeName());
}
test "regular: normal core editing delete yank paste undo redo" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open abc\ndef");
try client.handleInput("d");
try client.handleInput("d");
const after_delete = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_delete);
try std.testing.expectEqualStrings("def", after_delete);
try client.handleInput("p");
const after_paste = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_paste);
try std.testing.expectEqualStrings("abc\ndef", after_paste);
try client.handleInput("u");
const after_undo = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_undo);
try std.testing.expectEqualStrings("def", after_undo);
try client.handleInput("U");
const after_redo = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_redo);
try std.testing.expectEqualStrings("abc\ndef", after_redo);
}
test "regular: normal change replace open lines and movement enter insert" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open abc\ndef");
try client.handleInput("r");
try client.handleInput("Z");
const after_replace = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_replace);
try std.testing.expectEqualStrings("Zbc\ndef", after_replace);
try client.handleInput("c");
try client.handleInput("c");
try std.testing.expectEqualStrings("insert", client.modeName());
try client.handleInput("X");
try client.handleInput(" ");
try client.handleInput("n");
const after_change = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_change);
try std.testing.expectEqualStrings("X\ndef", after_change);
try client.handleInput("o");
try client.handleInput("Y");
try client.handleInput(" ");
try client.handleInput("n");
const after_open = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_open);
try std.testing.expectEqualStrings("X\nY\ndef", after_open);
try client.handleInput("0");
try client.handleInput("l");
try client.handleInput("k");
try client.handleInput("w");
try client.handleInput("e");
try client.handleInput("b");
}
test "adversarial: replace rejects invalid utf8 and undo preserves content" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open safe");
const bad = [_]u8{ 0xc3, 0x28 };
try client.handleInput("r");
try client.handleInput(&bad);
const after_bad_replace = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_bad_replace);
try std.testing.expectEqualStrings("safe", after_bad_replace);
try client.handleInput("i");
try client.handleInput("!");
try client.handleInput(" ");
try client.handleInput("n");
try client.handleInput("u");
const after_undo = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(after_undo);
try std.testing.expectEqualStrings("safe", after_undo);
}