Files
mim/src/session.zig
T
2026-06-21 22:34:51 +02:00

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