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
+13
View File
@@ -91,7 +91,20 @@ fn parseToolLog(log_line: []const u8) ?struct { name: []const u8, command: []con
fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, command: []const u8) ![]u8 {
if (std.mem.eql(u8, command, "move_left")) return dispatchAndRespond(allocator, session, .move_left);
if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right);
if (std.mem.eql(u8, command, "move_up")) return dispatchAndRespond(allocator, session, .move_up);
if (std.mem.eql(u8, command, "move_down")) return dispatchAndRespond(allocator, session, .move_down);
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_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, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward);
if (std.mem.eql(u8, command, "delete_forward")) return dispatchAndRespond(allocator, session, .delete_forward);
if (std.mem.eql(u8, command, "delete_line")) return dispatchAndRespond(allocator, session, .delete_line);
if (std.mem.eql(u8, command, "change_line")) return dispatchAndRespond(allocator, session, .change_line);
if (std.mem.eql(u8, command, "open_line_below")) return dispatchAndRespond(allocator, session, .open_line_below);
if (std.mem.eql(u8, command, "open_line_above")) return dispatchAndRespond(allocator, session, .open_line_above);
if (std.mem.startsWith(u8, command, "replace_char ")) return dispatchAndRespond(allocator, session, .{ .replace_char = command[13..] });
if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] });
if (std.mem.startsWith(u8, command, "panel_open ")) return panelRespond(allocator, session, .{ .open = command[11..] });
if (std.mem.startsWith(u8, command, "list_open ")) return listOpenRespond(allocator, session, command[10..]);
+211
View File
@@ -26,9 +26,22 @@ pub const Pair = struct {
pub const Command = union(enum) {
move_left,
move_right,
move_up,
move_down,
move_word_forward,
move_word_back,
move_word_end,
insert: []const u8,
insert_pair: Pair,
delete_backward,
delete_forward,
delete_line,
change_line,
open_line_below,
open_line_above,
replace_char: []const u8,
move_line_start,
move_line_end,
};
pub const Snapshot = struct {
@@ -74,9 +87,22 @@ pub const Buffer = struct {
switch (command) {
.move_left => self.moveLeft(),
.move_right => self.moveRight(),
.move_up => self.moveUp(),
.move_down => self.moveDown(),
.move_word_forward => self.moveWordForward(),
.move_word_back => self.moveWordBack(),
.move_word_end => self.moveWordEnd(),
.insert => |text| try self.insert(text),
.insert_pair => |pair| try self.insertPair(pair),
.delete_backward => self.deleteBackward(),
.delete_forward => self.deleteForward(),
.delete_line => self.deleteLine(),
.change_line => try self.changeLine(),
.open_line_below => try self.openLineBelow(),
.open_line_above => try self.openLineAbove(),
.replace_char => |text| try self.replaceChar(text),
.move_line_start => self.moveLineStart(),
.move_line_end => self.moveLineEnd(),
}
}
@@ -90,6 +116,55 @@ pub const Buffer = struct {
self.refreshCell();
}
pub fn moveUp(self: *Buffer) void {
const current = self.currentLineRange(false);
if (current.start == 0) return;
const previous_end = current.start - 1;
var previous_start = previous_end;
while (previous_start > 0 and self.bytes.items[previous_start - 1] != '\n') previous_start -= 1;
self.cursor.byte = byteForCell(self.bytes.items, previous_start, previous_end, self.cursor.cell);
self.refreshCell();
}
pub fn moveDown(self: *Buffer) void {
const current = self.currentLineRange(false);
if (current.end >= self.bytes.items.len) return;
const next_start = current.end + 1;
if (next_start > self.bytes.items.len) return;
var next_end = next_start;
while (next_end < self.bytes.items.len and self.bytes.items[next_end] != '\n') next_end += 1;
self.cursor.byte = byteForCell(self.bytes.items, next_start, next_end, self.cursor.cell);
self.refreshCell();
}
pub fn moveWordForward(self: *Buffer) void {
var at = self.cursor.byte;
while (at < self.bytes.items.len and !isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at);
while (at < self.bytes.items.len and isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at);
self.cursor.byte = at;
self.refreshCell();
}
pub fn moveWordBack(self: *Buffer) void {
var at = self.cursor.byte;
while (at > 0 and isWordSeparator(self.bytes.items[previousBoundary(self.bytes.items, at)])) at = previousBoundary(self.bytes.items, at);
while (at > 0 and !isWordSeparator(self.bytes.items[previousBoundary(self.bytes.items, at)])) at = previousBoundary(self.bytes.items, at);
self.cursor.byte = at;
self.refreshCell();
}
pub fn moveWordEnd(self: *Buffer) void {
var at = self.cursor.byte;
while (at < self.bytes.items.len and isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at);
while (at < self.bytes.items.len) {
const next = nextBoundary(self.bytes.items, at);
if (next >= self.bytes.items.len or isWordSeparator(self.bytes.items[next])) break;
at = next;
}
self.cursor.byte = at;
self.refreshCell();
}
pub fn insert(self: *Buffer, text: []const u8) !void {
if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion;
try self.bytes.insertSlice(self.allocator, self.cursor.byte, text);
@@ -117,6 +192,76 @@ pub const Buffer = struct {
self.selection = null;
}
pub fn deleteForward(self: *Buffer) void {
if (self.cursor.byte >= self.bytes.items.len) return;
const end = nextBoundary(self.bytes.items, self.cursor.byte);
self.bytes.replaceRangeAssumeCapacity(self.cursor.byte, end - self.cursor.byte, "");
self.refreshCell();
self.selection = null;
}
pub fn replaceChar(self: *Buffer, text: []const u8) !void {
if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion;
if (self.cursor.byte < self.bytes.items.len) self.deleteForward();
try self.insert(text);
self.moveLeft();
}
pub fn deleteLine(self: *Buffer) void {
const range = self.currentLineRange(true);
self.bytes.replaceRangeAssumeCapacity(range.start, range.end - range.start, "");
self.cursor.byte = @min(range.start, self.bytes.items.len);
self.refreshCell();
self.selection = null;
}
pub fn changeLine(self: *Buffer) !void {
const range = self.currentLineRange(false);
self.bytes.replaceRangeAssumeCapacity(range.start, range.end - range.start, "");
self.cursor.byte = @min(range.start, self.bytes.items.len);
self.refreshCell();
self.selection = null;
}
pub fn openLineBelow(self: *Buffer) !void {
const range = self.currentLineRange(false);
const has_line_break = range.end < self.bytes.items.len and self.bytes.items[range.end] == '\n';
const at = if (has_line_break) range.end + 1 else self.bytes.items.len;
try self.bytes.insertSlice(self.allocator, at, "\n");
self.cursor.byte = if (has_line_break) at else at + 1;
self.refreshCell();
self.selection = null;
}
pub fn openLineAbove(self: *Buffer) !void {
const range = self.currentLineRange(false);
try self.bytes.insertSlice(self.allocator, range.start, "\n");
self.cursor.byte = range.start;
self.refreshCell();
self.selection = null;
}
pub fn moveLineStart(self: *Buffer) void {
self.cursor.byte = self.currentLineRange(false).start;
self.refreshCell();
}
pub fn moveLineEnd(self: *Buffer) void {
self.cursor.byte = self.currentLineRange(false).end;
self.refreshCell();
}
const LineRange = struct { start: usize, end: usize };
fn currentLineRange(self: *const Buffer, include_newline: bool) LineRange {
var start = self.cursor.byte;
while (start > 0 and self.bytes.items[start - 1] != '\n') start -= 1;
var end = self.cursor.byte;
while (end < self.bytes.items.len and self.bytes.items[end] != '\n') end += 1;
if (include_newline and end < self.bytes.items.len and self.bytes.items[end] == '\n') end += 1;
return .{ .start = start, .end = end };
}
fn refreshCell(self: *Buffer) void {
self.cursor.cell = cellWidth(self.bytes.items[0..self.cursor.byte]);
}
@@ -227,6 +372,23 @@ pub const Session = struct {
}
};
fn isWordSeparator(byte: u8) bool {
return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null;
}
fn byteForCell(bytes: []const u8, start: usize, end: usize, target_cell: usize) usize {
var at = start;
var best = start;
while (at < end) {
const rel = cellWidth(bytes[start..at]);
if (rel > target_cell) break;
best = at;
at = nextBoundary(bytes, at);
}
if (cellWidth(bytes[start..@min(at, end)]) <= target_cell) return @min(at, end);
return best;
}
pub fn boundaryAtOrBefore(bytes: []const u8, cursor: usize) usize {
var i = @min(cursor, bytes.len);
if (i == bytes.len) return i;
@@ -441,3 +603,52 @@ test "adversarial: session panel operations fail clearly on empty or invalid sta
const snap = try session.snapshot();
try std.testing.expectEqual(@as(usize, 0), snap.panel_depth);
}
test "regular: buffer moves by line word and line edges" {
var buffer = try Buffer.openFromBytes(std.testing.allocator, "one\nab cd\nxy");
defer buffer.deinit();
buffer.moveDown();
try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte);
buffer.moveRight();
buffer.moveRight();
buffer.moveDown();
try std.testing.expectEqual(@as(usize, 12), buffer.cursor.byte);
buffer.moveUp();
try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte);
buffer.moveLineStart();
try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte);
buffer.moveWordForward();
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
buffer.moveWordEnd();
try std.testing.expectEqual(@as(usize, 8), buffer.cursor.byte);
buffer.moveWordBack();
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
buffer.moveLineEnd();
try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte);
}
test "regular: buffer line operations replace delete and open around utf8" {
var buffer = try Buffer.openFromBytes(std.testing.allocator, "éx\nsecond");
defer buffer.deinit();
try buffer.replaceChar("A");
var snap = buffer.snapshot();
try std.testing.expectEqualStrings("Ax\nsecond", snap.bytes);
try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte);
try buffer.openLineBelow();
try buffer.insert("below");
snap = buffer.snapshot();
try std.testing.expectEqualStrings("Ax\nbelow\nsecond", snap.bytes);
buffer.moveLineStart();
try buffer.changeLine();
try buffer.insert("changed");
snap = buffer.snapshot();
try std.testing.expectEqualStrings("Ax\nchanged\nsecond", snap.bytes);
buffer.deleteLine();
snap = buffer.snapshot();
try std.testing.expectEqualStrings("Ax\nsecond", snap.bytes);
}
+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);
}