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
+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);
}