PranitInitial commit439f83c3 months agoHistory
365 lines · 12.9 KB · Zig
RawDownload
const std = @import("std");const text_boundaries = @import("text_boundaries.zig"); const Allocator = std.mem.Allocator; pub const InsertResult = enum {    inserted,    inactive,    limit_exceeded,}; pub const ByteResult = enum {    consumed,    ignored,    limit_exceeded,}; pub const SelectionRange = struct {    start: usize,    end: usize,}; pub const SelectionEdge = enum {    start,    end,}; /// Owns the composer text allocation and its layout-independent edit state./// Call `deinit` with the allocator used by fallible edit methods.pub const State = struct {    input: std.ArrayList(u8) = .empty,    cursor: usize = 0,    selection_anchor: ?usize = null,     pub fn deinit(self: *State, alloc: Allocator) void {        self.input.deinit(alloc);        self.* = .{};    }     pub fn setText(self: *State, alloc: Allocator, text: []const u8) Allocator.Error!void {        self.input.clearRetainingCapacity();        self.cursor = 0;        self.selection_anchor = null;        try self.input.appendSlice(alloc, text);        self.cursor = self.input.items.len;    }     pub fn clearRetainingCapacity(self: *State) void {        self.input.clearRetainingCapacity();        self.cursor = 0;        self.selection_anchor = null;    }     /// Exchanges owned text storage with `other` and resets transient edit state.    pub fn swapInput(self: *State, other: *std.ArrayList(u8)) void {        std.mem.swap(std.ArrayList(u8), &self.input, other);        self.cursor = self.input.items.len;        self.selection_anchor = null;    }     pub fn beginSelection(self: *State, raw_offset: usize) bool {        const offset = @min(raw_offset, self.input.items.len);        const changed = self.selection_anchor != offset or self.cursor != offset;        self.selection_anchor = offset;        self.cursor = offset;        return changed;    }     pub fn extendSelection(self: *State, raw_offset: usize) bool {        if (self.selection_anchor == null) return false;        const offset = @min(raw_offset, self.input.items.len);        if (self.cursor == offset) return false;        self.cursor = offset;        return true;    }     pub fn finishSelection(self: *State) void {        if (self.selection_anchor == self.cursor) self.selection_anchor = null;    }     pub fn clearSelection(self: *State) bool {        if (self.selection_anchor == null) return false;        self.selection_anchor = null;        return true;    }     pub fn selectionRange(self: *const State) ?SelectionRange {        const anchor = self.selection_anchor orelse return null;        if (anchor == self.cursor) return null;        return .{            .start = @min(anchor, self.cursor),            .end = @max(anchor, self.cursor),        };    }     pub fn selectedText(self: *const State) ?[]const u8 {        const selection = self.selectionRange() orelse return null;        return self.input.items[selection.start..selection.end];    }     pub fn discardSelection(self: *State) ?usize {        const dropped_bytes = if (self.selectionRange()) |selection|            selection.end - selection.start        else            null;        self.selection_anchor = null;        return dropped_bytes;    }     pub fn collapseSelection(self: *State, edge: SelectionEdge) bool {        const selection = self.selectionRange() orelse {            self.selection_anchor = null;            return false;        };        self.cursor = switch (edge) {            .start => selection.start,            .end => selection.end,        };        self.selection_anchor = null;        return true;    }     pub fn setCursor(self: *State, raw_offset: usize) bool {        const offset = @min(raw_offset, self.input.items.len);        const changed = self.cursor != offset or self.selection_anchor != null;        self.cursor = offset;        self.selection_anchor = null;        return changed;    }     pub fn moveCursorTo(self: *State, raw_offset: usize, extend_selection: bool) bool {        if (!extend_selection) return self.setCursor(raw_offset);        const offset = @min(raw_offset, self.input.items.len);        if (self.selection_anchor == null) self.selection_anchor = self.cursor;        if (self.cursor == offset) return false;        self.cursor = offset;        return true;    }     pub fn selectAll(self: *State) bool {        if (self.input.items.len == 0) return self.clearSelection();        const changed = self.selection_anchor != 0 or self.cursor != self.input.items.len;        self.selection_anchor = 0;        self.cursor = self.input.items.len;        return changed;    }     pub fn moveCharacterLeft(self: *State) bool {        if (self.collapseSelection(.start)) return true;        if (self.cursor == 0) return false;        return self.setCursor(text_boundaries.previousCharacterStart(self.input.items, self.cursor));    }     pub fn moveCharacterRight(self: *State) bool {        if (self.collapseSelection(.end)) return true;        if (self.cursor >= self.input.items.len) return false;        return self.setCursor(text_boundaries.nextCharacterEnd(self.input.items, self.cursor));    }     pub fn moveHome(self: *State) bool {        return self.setCursor(0);    }     pub fn moveEnd(self: *State) bool {        return self.setCursor(self.input.items.len);    }     pub fn moveLineStart(self: *State) bool {        return self.setCursor(text_boundaries.logicalLineStart(self.input.items, self.cursor));    }     pub fn moveLineEnd(self: *State) bool {        return self.setCursor(text_boundaries.logicalLineEnd(self.input.items, self.cursor));    }     pub fn insertSliceAssumeCapacity(self: *State, bytes: []const u8) void {        if (bytes.len == 0) return;        self.input.insertSliceAssumeCapacity(self.cursor, bytes);        self.cursor += bytes.len;        self.selection_anchor = null;    }     pub fn insertSlice(self: *State, alloc: Allocator, bytes: []const u8) Allocator.Error!void {        if (bytes.len == 0) return;        try self.input.insertSlice(alloc, self.cursor, bytes);        self.cursor += bytes.len;        self.selection_anchor = null;    }     pub fn deleteTextRange(self: *State, start: usize, end: usize) bool {        const clamped_start = @min(start, self.input.items.len);        const clamped_end = @min(@max(end, clamped_start), self.input.items.len);        if (clamped_start == clamped_end) return false;         if (self.selection_anchor) |anchor| {            self.selection_anchor = offsetAfterDelete(anchor, clamped_start, clamped_end);        }        return deleteRange(&self.input, &self.cursor, clamped_start, clamped_end);    }     pub fn replaceByteBeforeCursor(self: *State, expected: u8, replacement: u8) bool {        if (self.cursor == 0) return false;        const index = self.cursor - 1;        if (self.input.items[index] != expected) return false;        self.input.items[index] = replacement;        self.selection_anchor = null;        return true;    }}; fn offsetAfterDelete(offset: usize, start: usize, end: usize) usize {    if (offset >= end) return offset - (end - start);    if (offset > start) return start;    return offset;} pub fn canInsert(current_len: usize, inserted_len: usize, max_len: usize) bool {    if (current_len > max_len) return false;    return inserted_len <= max_len -| current_len;} pub fn canReplace(current_len: usize, replaced_len: usize, inserted_len: usize, max_len: usize) bool {    std.debug.assert(replaced_len <= current_len);    return canInsert(current_len - replaced_len, inserted_len, max_len);} pub fn deleteRange(    buffer: *std.ArrayList(u8),    cursor: *usize,    start: usize,    end: usize,) bool {    const clamped_start = @min(start, buffer.items.len);    const clamped_end = @min(@max(end, clamped_start), buffer.items.len);    if (clamped_start == clamped_end) return false;    const count = clamped_end - clamped_start;    std.mem.copyForwards(u8, buffer.items[clamped_start..], buffer.items[clamped_end..]);    buffer.items.len -= count;    if (cursor.* >= clamped_end) {        cursor.* -= count;    } else if (cursor.* > clamped_start) {        cursor.* = clamped_start;    }    return true;} test "editor state owns selection and Unicode cursor transitions" {    const alloc = std.testing.allocator;    var state: State = .{};    defer state.deinit(alloc);     try state.setText(alloc, "Ae\u{301}B");    try std.testing.expect(state.moveCharacterLeft());    try std.testing.expectEqual(@as(usize, 4), state.cursor);    try std.testing.expect(state.moveCharacterLeft());    try std.testing.expectEqual(@as(usize, 1), state.cursor);     try std.testing.expect(state.beginSelection(1));    try std.testing.expect(state.extendSelection(4));    try std.testing.expectEqualStrings("e\u{301}", state.selectedText().?);    try std.testing.expect(state.collapseSelection(.end));    try std.testing.expectEqual(@as(usize, 4), state.cursor);    try std.testing.expect(state.selectionRange() == null);} test "collapsing an empty selection clears its transient anchor" {    const alloc = std.testing.allocator;    var state: State = .{};    defer state.deinit(alloc);     try state.setText(alloc, "abc");    _ = state.beginSelection(0);    try std.testing.expect(!state.collapseSelection(.start));    try std.testing.expectEqual(@as(?usize, null), state.selection_anchor);     _ = state.beginSelection(state.input.items.len);    try std.testing.expect(!state.collapseSelection(.end));    try std.testing.expectEqual(@as(?usize, null), state.selection_anchor);} test "selection extension keeps a fixed anchor while crossing it" {    const alloc = std.testing.allocator;    var state: State = .{};    defer state.deinit(alloc);    try state.setText(alloc, "abcd");     _ = state.setCursor(2);    try std.testing.expect(state.moveCursorTo(1, true));    try std.testing.expectEqual(@as(?usize, 2), state.selection_anchor);    try std.testing.expect(state.moveCursorTo(3, true));    try std.testing.expectEqual(@as(?usize, 2), state.selection_anchor);    try std.testing.expectEqual(        SelectionRange{ .start = 2, .end = 3 },        state.selectionRange().?,    );     try std.testing.expect(state.moveCursorTo(0, false));    try std.testing.expectEqual(@as(?usize, null), state.selection_anchor);    try std.testing.expect(state.selectAll());    try std.testing.expectEqual(SelectionRange{ .start = 0, .end = 4 }, state.selectionRange().?);} test "editor state moves across logical lines" {    const alloc = std.testing.allocator;    var state: State = .{};    defer state.deinit(alloc);     try state.setText(alloc, "alpha beta\ngamma");    try std.testing.expect(state.moveLineStart());    try std.testing.expectEqual(@as(usize, 11), state.cursor);    try std.testing.expect(state.moveHome());    try std.testing.expectEqual(@as(usize, 0), state.cursor);    try std.testing.expect(state.moveLineEnd());    try std.testing.expectEqual(@as(usize, 10), state.cursor);} test "editor state insertion and deletion keep cursor and selection consistent" {    const alloc = std.testing.allocator;    var state: State = .{};    defer state.deinit(alloc);     try state.setText(alloc, "alpha beta");    try state.input.ensureUnusedCapacity(alloc, 1);    _ = state.setCursor(5);    state.insertSliceAssumeCapacity("!");    try std.testing.expectEqualStrings("alpha! beta", state.input.items);    try std.testing.expectEqual(@as(usize, 6), state.cursor);     _ = state.beginSelection(5);    _ = state.extendSelection(7);    try std.testing.expect(state.deleteTextRange(5, 7));    try std.testing.expectEqualStrings("alphabeta", state.input.items);    try std.testing.expectEqual(@as(usize, 5), state.cursor);    try std.testing.expectEqual(@as(?usize, 5), state.selection_anchor);    state.finishSelection();    try std.testing.expect(state.selectionRange() == null);} test "bounded edit admission handles inserts and replacements" {    try std.testing.expect(canInsert(4095, 1, 4096));    try std.testing.expect(!canInsert(4095, 2, 4096));    try std.testing.expect(!canInsert(5000, 0, 4096));    try std.testing.expect(canReplace(4094, 8, 10, 4096));    try std.testing.expect(!canReplace(4094, 8, 11, 4096));} test "range deletion clamps offsets and preserves cursor position" {    const alloc = std.testing.allocator;    var buffer: std.ArrayList(u8) = .empty;    defer buffer.deinit(alloc);    try buffer.appendSlice(alloc, "alpha beta");     var cursor: usize = buffer.items.len;    try std.testing.expect(deleteRange(&buffer, &cursor, 5, 6));    try std.testing.expectEqualStrings("alphabeta", buffer.items);    try std.testing.expectEqual(@as(usize, 9), cursor);     cursor = 3;    try std.testing.expect(deleteRange(&buffer, &cursor, 1, 5));    try std.testing.expectEqualStrings("abeta", buffer.items);    try std.testing.expectEqual(@as(usize, 1), cursor);     try std.testing.expect(!deleteRange(&buffer, &cursor, 99, 100));    try std.testing.expectEqualStrings("abeta", buffer.items);}