From 079818201203350183b106a2ed782eddec67b831 Mon Sep 17 00:00:00 2001 From: Rylee Alanza Lyman <46907231+ryleelyman@users.noreply.github.com> Date: Tue, 12 Mar 2024 07:38:23 -0400 Subject: [PATCH] feat: adds `gap_buffer.zig` for `TextInput` (#6) Use a gap buffer for the `TextInput` widget instead of an `ArrayList`. --- build.zig | 6 ++ build.zig.zon | 4 ++ src/widgets/TextInput.zig | 124 +++++++++++++++++++++++++++++--------- 3 files changed, 107 insertions(+), 27 deletions(-) diff --git a/build.zig b/build.zig index e4a17c9..c26bb8f 100644 --- a/build.zig +++ b/build.zig @@ -14,6 +14,10 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .target = target, }); + const gap_buffer_dep = b.dependency("gap_buffer", .{ + .optimize = optimize, + .target = target, + }); // Module const vaxis_mod = b.addModule("vaxis", .{ @@ -23,6 +27,7 @@ pub fn build(b: *std.Build) void { }); vaxis_mod.addImport("ziglyph", ziglyph_dep.module("ziglyph")); vaxis_mod.addImport("zigimg", zigimg_dep.module("zigimg")); + vaxis_mod.addImport("gap_buffer", gap_buffer_dep.module("gap_buffer")); // Examples const example_step = b.step("example", "Run examples"); @@ -49,6 +54,7 @@ pub fn build(b: *std.Build) void { }); tests.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph")); tests.root_module.addImport("zigimg", zigimg_dep.module("zigimg")); + tests.root_module.addImport("gap_buffer", gap_buffer_dep.module("gap_buffer")); const tests_run = b.addRunArtifact(tests); b.installArtifact(tests); diff --git a/build.zig.zon b/build.zig.zon index a9f217d..1d18def 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -11,5 +11,9 @@ .url = "https://github.com/rockorager/zigimg/archive/19a49a7.tar.gz", .hash = "1220ebfa8587cfd644995fc08e218dbb3ebd7344fb8e129ff02bc5a6d52a2325370d", }, + .gap_buffer = .{ + .url = "https://github.com/ryleelyman/GapBuffer.zig/archive/6a746497d5a2494026d0f471e42556f1f153f153.tar.gz", + .hash = "12205354d9903e773f9d934dcfe756b0b5ffd895571ad631ab86ebc1aebba074dd82", + }, }, } diff --git a/src/widgets/TextInput.zig b/src/widgets/TextInput.zig index 1460b8c..6e7f250 100644 --- a/src/widgets/TextInput.zig +++ b/src/widgets/TextInput.zig @@ -3,6 +3,7 @@ const Key = @import("../Key.zig"); const Cell = @import("../Cell.zig"); const Window = @import("../Window.zig"); const GraphemeIterator = @import("ziglyph").GraphemeIterator; +const GapBuffer = @import("gap_buffer").GapBuffer; const log = std.log.scoped(.text_input); @@ -16,15 +17,11 @@ const Event = union(enum) { // Index of our cursor cursor_idx: usize = 0, grapheme_count: usize = 0, - -// TODO: an ArrayList is not great for this. orderedRemove is O(n) and we can -// only remove one byte at a time. Make a bespoke ArrayList which allows removal -// of a slice at a time, or truncating even would be nice -buf: std.ArrayList(u8), +buf: GapBuffer(u8), pub fn init(alloc: std.mem.Allocator) TextInput { return TextInput{ - .buf = std.ArrayList(u8).init(alloc), + .buf = GapBuffer(u8).init(alloc), }; } @@ -37,10 +34,10 @@ pub fn update(self: *TextInput, event: Event) !void { .key_press => |key| { if (key.matches(Key.backspace, .{})) { if (self.cursor_idx == 0) return; - self.deleteBeforeCursor(); + try self.deleteBeforeCursor(); } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { if (self.cursor_idx == self.grapheme_count) return; - self.deleteAtCursor(); + try self.deleteAtCursor(); } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { if (self.cursor_idx > 0) self.cursor_idx -= 1; } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { @@ -50,15 +47,11 @@ pub fn update(self: *TextInput, event: Event) !void { } else if (key.matches('e', .{ .ctrl = true })) { self.cursor_idx = self.grapheme_count; } else if (key.matches('k', .{ .ctrl = true })) { - while (self.cursor_idx < self.grapheme_count) { - self.deleteAtCursor(); - } + try self.deleteToEnd(); } else if (key.matches('u', .{ .ctrl = true })) { - while (self.cursor_idx > 0) { - self.deleteBeforeCursor(); - } + try self.deleteToStart(); } else if (key.text) |text| { - try self.buf.insertSlice(self.byteOffsetToCursor(), text); + try self.buf.insertSliceBefore(self.byteOffsetToCursor(), text); self.cursor_idx += 1; self.grapheme_count += 1; } @@ -67,11 +60,13 @@ pub fn update(self: *TextInput, event: Event) !void { } pub fn draw(self: *TextInput, win: Window) void { - var iter = GraphemeIterator.init(self.buf.items); + // assumption!! the gap is never within a grapheme + // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. + var first_iter = GraphemeIterator.init(self.buf.items); var col: usize = 0; var i: usize = 0; var cursor_idx: usize = 0; - while (iter.next()) |grapheme| { + while (first_iter.next()) |grapheme| { const g = grapheme.slice(self.buf.items); const w = win.gwidth(g); win.writeCell(col, 0, .{ @@ -84,11 +79,30 @@ pub fn draw(self: *TextInput, win: Window) void { i += 1; if (i == self.cursor_idx) cursor_idx = col; } + const second_half = self.buf.secondHalf(); + var second_iter = GraphemeIterator.init(second_half); + while (second_iter.next()) |grapheme| { + const g = grapheme.slice(second_half); + const w = win.gwidth(g); + win.writeCell(col, 0, .{ + .char = .{ + .grapheme = g, + .width = w, + }, + }); + col += w; + i += 1; + if (i == self.cursor_idx) cursor_idx = col; + } win.showCursor(cursor_idx, 0); } // returns the number of bytes before the cursor +// (since GapBuffers are strictly speaking not contiguous, this is a number in 0..realLength() +// which would need to be fed to realIndex() to get an actual offset into self.buf.items.ptr) fn byteOffsetToCursor(self: TextInput) usize { + // assumption! the gap is never in the middle of a grapheme + // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. var iter = GraphemeIterator.init(self.buf.items); var offset: usize = 0; var i: usize = 0; @@ -96,43 +110,99 @@ fn byteOffsetToCursor(self: TextInput) usize { if (i == self.cursor_idx) break; offset += grapheme.len; i += 1; + } else { + var second_iter = GraphemeIterator.init(self.buf.secondHalf()); + while (second_iter.next()) |grapheme| { + if (i == self.cursor_idx) break; + offset += grapheme.len; + i += 1; + } } return offset; } -fn deleteBeforeCursor(self: *TextInput) void { +fn deleteToEnd(self: *TextInput) !void { + self.cursor_idx += 1; + const offset = self.byteOffsetToCursor(); + try self.buf.replaceRangeAfter(offset, self.buf.realLength(), &.{}); + self.grapheme_count = self.cursor_idx; + self.cursor_idx -= 1; +} + +fn deleteToStart(self: *TextInput) !void { + const offset = self.byteOffsetToCursor(); + try self.buf.replaceRangeBefore(0, offset, &.{}); + self.grapheme_count -= self.cursor_idx; + self.cursor_idx = 0; +} + +fn deleteBeforeCursor(self: *TextInput) !void { + // assumption! the gap is never in the middle of a grapheme + // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. var iter = GraphemeIterator.init(self.buf.items); var offset: usize = 0; var i: usize = 1; while (iter.next()) |grapheme| { if (i == self.cursor_idx) { - var j: usize = 0; - while (j < grapheme.len) : (j += 1) { - _ = self.buf.orderedRemove(offset); - } + try self.buf.replaceRangeBefore(offset, grapheme.len, &.{}); self.cursor_idx -= 1; self.grapheme_count -= 1; return; } offset += grapheme.len; i += 1; + } else { + var second_iter = GraphemeIterator.init(self.buf.secondHalf()); + while (second_iter.next()) |grapheme| { + if (i == self.cursor_idx) { + try self.buf.replaceRangeBefore(offset, grapheme.len, &.{}); + self.cursor_idx -= 1; + self.grapheme_count -= 1; + return; + } + offset += grapheme.len; + i += 1; + } } } -fn deleteAtCursor(self: *TextInput) void { +fn deleteAtCursor(self: *TextInput) !void { + // assumption! the gap is never in the middle of a grapheme + // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay. var iter = GraphemeIterator.init(self.buf.items); var offset: usize = 0; var i: usize = 1; while (iter.next()) |grapheme| { if (i == self.cursor_idx + 1) { - var j: usize = 0; - while (j < grapheme.len) : (j += 1) { - _ = self.buf.orderedRemove(offset); - } + try self.buf.replaceRangeAfter(offset, grapheme.len, &.{}); self.grapheme_count -= 1; return; } offset += grapheme.len; i += 1; + } else { + var second_iter = GraphemeIterator.init(self.buf.secondHalf()); + while (second_iter.next()) |grapheme| { + if (i == self.cursor_idx + 1) { + try self.buf.replaceRangeAfter(offset, grapheme.len, &.{}); + self.grapheme_count -= 1; + return; + } + offset += grapheme.len; + i += 1; + } + } +} + +test "assertion" { + const astronaut = "👩‍🚀"; + const astronaut_emoji: Key = .{ + .text = astronaut, + .codepoint = try std.unicode.utf8Decode(astronaut[0..4]), + }; + var input = TextInput.init(std.testing.allocator); + defer input.deinit(); + for (0..6) |_| { + try input.update(.{ .key_press = astronaut_emoji }); } }