diff --git a/src/protocol.zig b/src/protocol.zig index 8e1c8ad..5b7ac59 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -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..]); diff --git a/src/session.zig b/src/session.zig index 7e78fe5..919c80c 100644 --- a/src/session.zig +++ b/src/session.zig @@ -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); +} diff --git a/src/tui.zig b/src/tui.zig index 86bd5fb..c4f92a5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +}