838 lines
30 KiB
Zig
838 lines
30 KiB
Zig
const std = @import("std");
|
|
const panel_mod = @import("panel.zig");
|
|
|
|
// Headless editor state for the protocol-first core.
|
|
// req: coding/001, session/003
|
|
test {
|
|
_ = Buffer;
|
|
_ = Session;
|
|
}
|
|
|
|
pub const Cursor = struct {
|
|
byte: usize = 0,
|
|
cell: usize = 0,
|
|
};
|
|
|
|
pub const Selection = struct {
|
|
anchor: usize,
|
|
cursor: usize,
|
|
};
|
|
|
|
pub const Pair = struct {
|
|
open: []const u8,
|
|
close: []const u8,
|
|
};
|
|
|
|
pub const Command = union(enum) {
|
|
move_left,
|
|
move_right,
|
|
move_up,
|
|
move_down,
|
|
move_word_forward,
|
|
move_word_back,
|
|
move_word_end,
|
|
move_line_first_nonblank,
|
|
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,
|
|
move_document_start,
|
|
move_document_end,
|
|
};
|
|
|
|
pub const Snapshot = struct {
|
|
bytes: []const u8,
|
|
cursor_byte: usize,
|
|
cursor_cell: usize,
|
|
selection: ?Selection,
|
|
panel_depth: usize,
|
|
active_panel_index: ?usize,
|
|
active_panel_title: ?[]const u8,
|
|
};
|
|
|
|
pub const Buffer = struct {
|
|
allocator: std.mem.Allocator,
|
|
bytes: std.ArrayList(u8),
|
|
cursor: Cursor = .{},
|
|
selection: ?Selection = null,
|
|
preferred_vertical_cell: ?usize = null,
|
|
|
|
pub fn openFromBytes(allocator: std.mem.Allocator, fixture: []const u8) !Buffer {
|
|
var bytes = std.ArrayList(u8).empty;
|
|
try bytes.appendSlice(allocator, fixture);
|
|
return .{ .allocator = allocator, .bytes = bytes };
|
|
}
|
|
|
|
pub fn deinit(self: *Buffer) void {
|
|
self.bytes.deinit(self.allocator);
|
|
self.* = undefined;
|
|
}
|
|
|
|
pub fn snapshot(self: *const Buffer) Snapshot {
|
|
return .{
|
|
.bytes = self.bytes.items,
|
|
.cursor_byte = self.cursor.byte,
|
|
.cursor_cell = self.cursor.cell,
|
|
.selection = self.selection,
|
|
.panel_depth = 0,
|
|
.active_panel_index = null,
|
|
.active_panel_title = null,
|
|
};
|
|
}
|
|
|
|
pub fn dispatch(self: *Buffer, command: Command) !void {
|
|
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(),
|
|
.move_line_first_nonblank => self.moveLineFirstNonblank(),
|
|
.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(),
|
|
.move_document_start => self.moveDocumentStart(),
|
|
.move_document_end => self.moveDocumentEnd(),
|
|
}
|
|
}
|
|
|
|
pub fn moveLeft(self: *Buffer) void {
|
|
self.cursor.byte = previousBoundary(self.bytes.items, self.cursor.byte);
|
|
self.refreshCellResetPreferred();
|
|
}
|
|
|
|
pub fn moveRight(self: *Buffer) void {
|
|
self.cursor.byte = nextBoundary(self.bytes.items, self.cursor.byte);
|
|
self.refreshCellResetPreferred();
|
|
}
|
|
|
|
pub fn moveUp(self: *Buffer) void {
|
|
const current = self.currentLineRange(false);
|
|
const target_cell = self.preferred_vertical_cell orelse self.cursor.cell;
|
|
if (current.start == 0) {
|
|
self.preferred_vertical_cell = target_cell;
|
|
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, target_cell);
|
|
self.refreshCellPreservePreferred(target_cell);
|
|
}
|
|
|
|
pub fn moveDown(self: *Buffer) void {
|
|
const current = self.currentLineRange(false);
|
|
const target_cell = self.preferred_vertical_cell orelse self.cursor.cell;
|
|
if (current.end >= self.bytes.items.len) {
|
|
self.preferred_vertical_cell = target_cell;
|
|
return;
|
|
}
|
|
const next_start = current.end + 1;
|
|
if (next_start > self.bytes.items.len) {
|
|
self.preferred_vertical_cell = target_cell;
|
|
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, target_cell);
|
|
self.refreshCellPreservePreferred(target_cell);
|
|
}
|
|
|
|
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.refreshCellResetPreferred();
|
|
}
|
|
|
|
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.refreshCellResetPreferred();
|
|
}
|
|
|
|
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.refreshCellResetPreferred();
|
|
}
|
|
|
|
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);
|
|
self.cursor.byte += text.len;
|
|
self.refreshCellResetPreferred();
|
|
self.selection = null;
|
|
}
|
|
|
|
pub fn insertPair(self: *Buffer, pair: Pair) !void {
|
|
if (!std.unicode.utf8ValidateSlice(pair.open) or !std.unicode.utf8ValidateSlice(pair.close)) return error.InvalidUtf8Insertion;
|
|
const combined = try std.mem.concat(self.allocator, u8, &.{ pair.open, pair.close });
|
|
defer self.allocator.free(combined);
|
|
try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined);
|
|
self.cursor.byte += pair.open.len;
|
|
self.refreshCellResetPreferred();
|
|
self.selection = null;
|
|
}
|
|
|
|
pub fn deleteBackward(self: *Buffer) void {
|
|
if (self.cursor.byte == 0) return;
|
|
const start = previousBoundary(self.bytes.items, self.cursor.byte);
|
|
self.bytes.replaceRangeAssumeCapacity(start, self.cursor.byte - start, "");
|
|
self.cursor.byte = start;
|
|
self.refreshCellResetPreferred();
|
|
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.refreshCellResetPreferred();
|
|
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.refreshCellResetPreferred();
|
|
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.refreshCellResetPreferred();
|
|
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.refreshCellResetPreferred();
|
|
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.refreshCellResetPreferred();
|
|
self.selection = null;
|
|
}
|
|
|
|
pub fn moveLineStart(self: *Buffer) void {
|
|
self.cursor.byte = self.currentLineRange(false).start;
|
|
self.refreshCellResetPreferred();
|
|
}
|
|
|
|
pub fn moveLineEnd(self: *Buffer) void {
|
|
self.cursor.byte = self.currentLineRange(false).end;
|
|
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 {
|
|
self.cursor.byte = 0;
|
|
self.refreshCellResetPreferred();
|
|
}
|
|
|
|
pub fn moveDocumentEnd(self: *Buffer) void {
|
|
if (self.bytes.items.len == 0) {
|
|
self.cursor.byte = 0;
|
|
self.refreshCellResetPreferred();
|
|
return;
|
|
}
|
|
var at = self.bytes.items.len;
|
|
if (at > 0 and self.bytes.items[at - 1] == '\n') at -= 1;
|
|
while (at > 0 and self.bytes.items[at - 1] != '\n') at -= 1;
|
|
self.cursor.byte = at;
|
|
self.refreshCellResetPreferred();
|
|
}
|
|
|
|
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 refreshCellResetPreferred(self: *Buffer) void {
|
|
self.refreshCellLineLocal();
|
|
self.preferred_vertical_cell = null;
|
|
}
|
|
|
|
fn refreshCellPreservePreferred(self: *Buffer, target_cell: usize) void {
|
|
self.refreshCellLineLocal();
|
|
self.preferred_vertical_cell = target_cell;
|
|
}
|
|
|
|
fn refreshCellLineLocal(self: *Buffer) void {
|
|
const line = self.currentLineRange(false);
|
|
self.cursor.cell = cellWidth(self.bytes.items[line.start..self.cursor.byte]);
|
|
}
|
|
};
|
|
|
|
pub const Session = struct {
|
|
allocator: std.mem.Allocator,
|
|
buffer: ?Buffer = null,
|
|
panels: panel_mod.Stack,
|
|
|
|
pub fn init(allocator: std.mem.Allocator) Session {
|
|
return .{ .allocator = allocator, .panels = panel_mod.Stack.init(allocator) };
|
|
}
|
|
|
|
pub fn deinit(self: *Session) void {
|
|
if (self.buffer) |*buffer| buffer.deinit();
|
|
self.panels.deinit();
|
|
self.* = undefined;
|
|
}
|
|
|
|
pub fn openFixture(self: *Session, fixture: []const u8) !void {
|
|
try self.openFixtureAt(fixture, 0);
|
|
}
|
|
|
|
pub fn openFixtureAt(self: *Session, fixture: []const u8, cursor_byte: usize) !void {
|
|
if (self.buffer) |*buffer| buffer.deinit();
|
|
var buffer = try Buffer.openFromBytes(self.allocator, fixture);
|
|
buffer.cursor.byte = boundaryAtOrBefore(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len));
|
|
buffer.refreshCellResetPreferred();
|
|
self.buffer = buffer;
|
|
}
|
|
|
|
pub fn dispatch(self: *Session, command: Command) !void {
|
|
if (self.buffer) |*buffer| return buffer.dispatch(command);
|
|
return error.NoBufferOpen;
|
|
}
|
|
|
|
pub fn openPanel(self: *Session, title: []const u8) !void {
|
|
try self.panels.open(title);
|
|
}
|
|
|
|
pub fn openListPanel(self: *Session, title: []const u8, items: []const []const u8) !void {
|
|
try self.panels.openList(title, items);
|
|
}
|
|
|
|
pub fn closePanel(self: *Session) !void {
|
|
try self.panels.closeActive();
|
|
}
|
|
|
|
pub fn nextPanel(self: *Session) !void {
|
|
try self.panels.next();
|
|
}
|
|
|
|
pub fn previousPanel(self: *Session) !void {
|
|
try self.panels.previous();
|
|
}
|
|
|
|
pub fn filterListPanel(self: *Session, filter: []const u8) !void {
|
|
try self.panels.filterList(filter);
|
|
}
|
|
|
|
pub fn listPanelDown(self: *Session) !void {
|
|
try self.panels.listDown();
|
|
}
|
|
|
|
pub fn listPanelUp(self: *Session) !void {
|
|
try self.panels.listUp();
|
|
}
|
|
|
|
pub fn selectListPanel(self: *Session) ![]const u8 {
|
|
return self.panels.selectList();
|
|
}
|
|
|
|
pub fn activeListItem(self: *const Session) ![]const u8 {
|
|
return self.panels.activeListItem();
|
|
}
|
|
|
|
pub fn cancelListPanel(self: *Session) !void {
|
|
try self.panels.cancelList();
|
|
}
|
|
|
|
pub fn panelPathAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 {
|
|
return self.panels.pathAlloc(allocator);
|
|
}
|
|
|
|
pub fn panelSummaryAlloc(self: *const Session, allocator: std.mem.Allocator) ![]u8 {
|
|
return self.panels.summaryAlloc(allocator);
|
|
}
|
|
|
|
pub fn activeListRowsAlloc(self: *const Session, allocator: std.mem.Allocator, max_rows: usize) ![][]u8 {
|
|
return self.panels.activeListRowsAlloc(allocator, max_rows);
|
|
}
|
|
|
|
pub fn snapshot(self: *const Session) !Snapshot {
|
|
const panel_state = self.panels.state();
|
|
if (self.buffer) |*buffer| {
|
|
var snap = buffer.snapshot();
|
|
snap.panel_depth = panel_state.depth;
|
|
snap.active_panel_index = panel_state.active_index;
|
|
snap.active_panel_title = panel_state.active_title;
|
|
return snap;
|
|
}
|
|
return .{
|
|
.bytes = "",
|
|
.cursor_byte = 0,
|
|
.cursor_cell = 0,
|
|
.selection = null,
|
|
.panel_depth = panel_state.depth,
|
|
.active_panel_index = panel_state.active_index,
|
|
.active_panel_title = panel_state.active_title,
|
|
};
|
|
}
|
|
|
|
pub fn moveToByte(self: *Session, byte: usize) !void {
|
|
if (self.buffer) |*buffer| {
|
|
if (byte > buffer.bytes.items.len) return error.SelectionOutOfBounds;
|
|
buffer.cursor.byte = byte;
|
|
buffer.refreshCellResetPreferred();
|
|
return;
|
|
}
|
|
return error.NoActiveBuffer;
|
|
}
|
|
|
|
pub fn selectRange(self: *Session, start: usize, end: usize) !void {
|
|
if (self.buffer) |*buffer| {
|
|
if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds;
|
|
buffer.selection = .{ .anchor = start, .cursor = end };
|
|
buffer.cursor.byte = end;
|
|
buffer.refreshCellResetPreferred();
|
|
return;
|
|
}
|
|
return error.NoActiveBuffer;
|
|
}
|
|
|
|
pub fn clearSelection(self: *Session) void {
|
|
if (self.buffer) |*buffer| buffer.selection = null;
|
|
}
|
|
|
|
pub fn replaceRange(self: *Session, start: usize, end: usize, bytes: []const u8) !void {
|
|
if (!std.unicode.utf8ValidateSlice(bytes)) return error.InvalidUtf8Insertion;
|
|
if (self.buffer) |*buffer| {
|
|
if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds;
|
|
try buffer.bytes.replaceRange(buffer.allocator, start, end - start, bytes);
|
|
buffer.cursor.byte = start + bytes.len;
|
|
buffer.refreshCellResetPreferred();
|
|
buffer.selection = null;
|
|
return;
|
|
}
|
|
return error.NoActiveBuffer;
|
|
}
|
|
};
|
|
|
|
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;
|
|
while (i > 0 and isContinuation(bytes[i])) : (i -= 1) {}
|
|
return i;
|
|
}
|
|
|
|
pub fn previousBoundary(bytes: []const u8, cursor: usize) usize {
|
|
if (cursor == 0) return 0;
|
|
var i = @min(cursor, bytes.len) - 1;
|
|
while (i > 0 and isContinuation(bytes[i])) : (i -= 1) {}
|
|
return i;
|
|
}
|
|
|
|
pub fn nextBoundary(bytes: []const u8, cursor: usize) usize {
|
|
if (cursor >= bytes.len) return bytes.len;
|
|
const len = std.unicode.utf8ByteSequenceLength(bytes[cursor]) catch 1;
|
|
return @min(bytes.len, cursor + len);
|
|
}
|
|
|
|
pub fn cellWidth(bytes: []const u8) usize {
|
|
var width: usize = 0;
|
|
var i: usize = 0;
|
|
while (i < bytes.len) {
|
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch {
|
|
i += 1;
|
|
width += 1;
|
|
continue;
|
|
};
|
|
if (i + len > bytes.len) {
|
|
width += 1;
|
|
break;
|
|
}
|
|
const cp = std.unicode.utf8Decode(bytes[i .. i + len]) catch {
|
|
i += 1;
|
|
width += 1;
|
|
continue;
|
|
};
|
|
width += codepointCellWidth(cp);
|
|
i += len;
|
|
}
|
|
return width;
|
|
}
|
|
|
|
fn isContinuation(byte: u8) bool {
|
|
return (byte & 0b1100_0000) == 0b1000_0000;
|
|
}
|
|
|
|
fn codepointCellWidth(cp: u21) usize {
|
|
if (cp == 0) return 0;
|
|
if (cp < 0x20 or (cp >= 0x7f and cp < 0xa0)) return 0;
|
|
if (isCombining(cp)) return 0;
|
|
if (isWide(cp)) return 2;
|
|
return 1;
|
|
}
|
|
|
|
fn isCombining(cp: u21) bool {
|
|
return (cp >= 0x0300 and cp <= 0x036f) or
|
|
(cp >= 0x1ab0 and cp <= 0x1aff) or
|
|
(cp >= 0x1dc0 and cp <= 0x1dff) or
|
|
(cp >= 0x20d0 and cp <= 0x20ff) or
|
|
(cp >= 0xfe20 and cp <= 0xfe2f);
|
|
}
|
|
|
|
fn isWide(cp: u21) bool {
|
|
return (cp >= 0x1100 and cp <= 0x115f) or
|
|
(cp >= 0x2329 and cp <= 0x232a) or
|
|
(cp >= 0x2e80 and cp <= 0xa4cf) or
|
|
(cp >= 0xac00 and cp <= 0xd7a3) or
|
|
(cp >= 0xf900 and cp <= 0xfaff) or
|
|
(cp >= 0xfe10 and cp <= 0xfe19) or
|
|
(cp >= 0xfe30 and cp <= 0xfe6f) or
|
|
(cp >= 0xff00 and cp <= 0xff60) or
|
|
(cp >= 0xffe0 and cp <= 0xffe6) or
|
|
(cp >= 0x1f300 and cp <= 0x1faff) or
|
|
(cp >= 0x20000 and cp <= 0x3fffd);
|
|
}
|
|
|
|
test "regular: opening a fixture at a cursor byte keeps exact utf8 boundary" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try session.openFixtureAt("one\nabcdTARGET\n", 8);
|
|
const snap = try session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: session opens fixture and dispatches multibyte edits" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try session.openFixture("let café = 1\n");
|
|
try session.dispatch(.move_right);
|
|
try session.dispatch(.move_right);
|
|
try session.dispatch(.move_right);
|
|
try session.dispatch(.{ .insert = "🔥" });
|
|
|
|
const snap = try session.snapshot();
|
|
try std.testing.expectEqualStrings("let🔥 café = 1\n", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 7), snap.cursor_byte);
|
|
try std.testing.expectEqual(@as(usize, 5), snap.cursor_cell);
|
|
}
|
|
|
|
test "regular: delete backward removes whole UTF-8 codepoint" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "aéb");
|
|
defer buffer.deinit();
|
|
|
|
buffer.moveRight();
|
|
buffer.moveRight();
|
|
buffer.deleteBackward();
|
|
|
|
const snap = buffer.snapshot();
|
|
try std.testing.expectEqualStrings("ab", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 1), snap.cursor_byte);
|
|
try std.testing.expectEqual(@as(usize, 1), snap.cursor_cell);
|
|
}
|
|
|
|
test "regular: cell width treats combining marks as zero and wide glyphs as two" {
|
|
try std.testing.expectEqual(@as(usize, 1), cellWidth("e\u{0301}"));
|
|
try std.testing.expectEqual(@as(usize, 2), cellWidth("界"));
|
|
try std.testing.expectEqual(@as(usize, 2), cellWidth("🔥"));
|
|
}
|
|
|
|
test "adversarial: cursor movement never lands inside a multibyte codepoint" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "aé🔥z");
|
|
defer buffer.deinit();
|
|
|
|
buffer.moveRight();
|
|
try std.testing.expectEqual(@as(usize, 1), buffer.cursor.byte);
|
|
buffer.moveRight();
|
|
try std.testing.expectEqual(@as(usize, 3), buffer.cursor.byte);
|
|
buffer.moveRight();
|
|
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
|
|
buffer.moveLeft();
|
|
try std.testing.expectEqual(@as(usize, 3), buffer.cursor.byte);
|
|
}
|
|
|
|
test "adversarial: invalid inserted UTF-8 is rejected and existing bytes are preserved" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "safe");
|
|
defer buffer.deinit();
|
|
|
|
const bad = [_]u8{ 0xc3, 0x28 };
|
|
try std.testing.expectError(error.InvalidUtf8Insertion, buffer.insert(&bad));
|
|
try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes);
|
|
try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte);
|
|
}
|
|
|
|
test "adversarial: dispatch requires an open buffer but state remains inspectable" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try std.testing.expectError(error.NoBufferOpen, session.dispatch(.move_right));
|
|
const snap = try session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 0), snap.bytes.len);
|
|
try std.testing.expectEqual(@as(usize, 0), snap.panel_depth);
|
|
}
|
|
|
|
test "regular: pair insertion places cursor between delimiters" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "call");
|
|
defer buffer.deinit();
|
|
|
|
buffer.moveRight();
|
|
buffer.moveRight();
|
|
buffer.moveRight();
|
|
buffer.moveRight();
|
|
try buffer.insertPair(.{ .open = "(", .close = ")" });
|
|
|
|
const snap = buffer.snapshot();
|
|
try std.testing.expectEqualStrings("call()", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte);
|
|
try std.testing.expectEqual(@as(usize, 5), snap.cursor_cell);
|
|
}
|
|
|
|
test "adversarial: invalid UTF-8 pair insertion is rejected and existing bytes are preserved" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "safe");
|
|
defer buffer.deinit();
|
|
|
|
const bad = [_]u8{ 0xc3, 0x28 };
|
|
try std.testing.expectError(error.InvalidUtf8Insertion, buffer.insertPair(.{ .open = &bad, .close = ")" }));
|
|
try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes);
|
|
try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte);
|
|
}
|
|
|
|
test "regular: session opens closes and switches generic panel stack" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try session.openPanel("files");
|
|
try session.openPanel("diagnostics");
|
|
try session.previousPanel();
|
|
var snap = try session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 2), snap.panel_depth);
|
|
try std.testing.expectEqualStrings("files", snap.active_panel_title.?);
|
|
|
|
try session.closePanel();
|
|
snap = try session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 1), snap.panel_depth);
|
|
try std.testing.expectEqualStrings("diagnostics", snap.active_panel_title.?);
|
|
|
|
const path = try session.panelPathAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(path);
|
|
try std.testing.expectEqualStrings("[diagnostics]", path);
|
|
}
|
|
|
|
test "adversarial: session panel operations fail clearly on empty or invalid state" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try std.testing.expectError(error.NoPanelOpen, session.closePanel());
|
|
try std.testing.expectError(error.NoPanelOpen, session.nextPanel());
|
|
try std.testing.expectError(error.InvalidPanelTitle, session.openPanel("bad title"));
|
|
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, 6), 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);
|
|
}
|
|
|
|
test "regular: vertical motion preserves preferred column across short lines" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "abcdef\nxy\n123456\n");
|
|
defer buffer.deinit();
|
|
|
|
buffer.moveLineEnd();
|
|
try std.testing.expectEqual(@as(usize, 6), buffer.cursor.cell);
|
|
|
|
buffer.moveDown();
|
|
try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte);
|
|
try std.testing.expectEqual(@as(usize, 2), buffer.cursor.cell);
|
|
|
|
buffer.moveDown();
|
|
try std.testing.expectEqual(@as(usize, 16), buffer.cursor.byte);
|
|
try std.testing.expectEqual(@as(usize, 6), buffer.cursor.cell);
|
|
}
|
|
|
|
test "regular: horizontal motion resets vertical preferred column" {
|
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "abcdef\nxy\n123456\n");
|
|
defer buffer.deinit();
|
|
|
|
buffer.moveLineEnd();
|
|
buffer.moveDown();
|
|
buffer.moveLeft();
|
|
try std.testing.expectEqual(@as(usize, 1), buffer.cursor.cell);
|
|
|
|
buffer.moveDown();
|
|
try std.testing.expectEqual(@as(usize, 11), buffer.cursor.byte);
|
|
try std.testing.expectEqual(@as(usize, 1), buffer.cursor.cell);
|
|
}
|
|
|
|
test "regular: line object selection includes the newline byte" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try session.openFixture("abc\ndef\n");
|
|
try session.selectRange(0, 4);
|
|
const snap = try session.snapshot();
|
|
try std.testing.expectEqualStrings("abc\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
}
|
|
|
|
test "regular: document motions jump to top and bottom line" {
|
|
var session = Session.init(std.testing.allocator);
|
|
defer session.deinit();
|
|
|
|
try session.openFixtureAt("one\ntwo\nthree\n", 4);
|
|
try session.dispatch(.move_document_end);
|
|
var snap = try session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte);
|
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_cell);
|
|
|
|
try session.dispatch(.move_document_start);
|
|
snap = try session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte);
|
|
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);
|
|
}
|