feat: adds gap_buffer.zig for TextInput (#6)

Use a gap buffer for the `TextInput` widget instead of an `ArrayList`.
This commit is contained in:
Rylee Alanza Lyman 2024-03-12 07:38:23 -04:00 committed by GitHub
parent 5e940fd295
commit 0798182012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 107 additions and 27 deletions

View file

@ -14,6 +14,10 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
.target = target, .target = target,
}); });
const gap_buffer_dep = b.dependency("gap_buffer", .{
.optimize = optimize,
.target = target,
});
// Module // Module
const vaxis_mod = b.addModule("vaxis", .{ 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("ziglyph", ziglyph_dep.module("ziglyph"));
vaxis_mod.addImport("zigimg", zigimg_dep.module("zigimg")); vaxis_mod.addImport("zigimg", zigimg_dep.module("zigimg"));
vaxis_mod.addImport("gap_buffer", gap_buffer_dep.module("gap_buffer"));
// Examples // Examples
const example_step = b.step("example", "Run 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("ziglyph", ziglyph_dep.module("ziglyph"));
tests.root_module.addImport("zigimg", zigimg_dep.module("zigimg")); 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); const tests_run = b.addRunArtifact(tests);
b.installArtifact(tests); b.installArtifact(tests);

View file

@ -11,5 +11,9 @@
.url = "https://github.com/rockorager/zigimg/archive/19a49a7.tar.gz", .url = "https://github.com/rockorager/zigimg/archive/19a49a7.tar.gz",
.hash = "1220ebfa8587cfd644995fc08e218dbb3ebd7344fb8e129ff02bc5a6d52a2325370d", .hash = "1220ebfa8587cfd644995fc08e218dbb3ebd7344fb8e129ff02bc5a6d52a2325370d",
}, },
.gap_buffer = .{
.url = "https://github.com/ryleelyman/GapBuffer.zig/archive/6a746497d5a2494026d0f471e42556f1f153f153.tar.gz",
.hash = "12205354d9903e773f9d934dcfe756b0b5ffd895571ad631ab86ebc1aebba074dd82",
},
}, },
} }

View file

@ -3,6 +3,7 @@ const Key = @import("../Key.zig");
const Cell = @import("../Cell.zig"); const Cell = @import("../Cell.zig");
const Window = @import("../Window.zig"); const Window = @import("../Window.zig");
const GraphemeIterator = @import("ziglyph").GraphemeIterator; const GraphemeIterator = @import("ziglyph").GraphemeIterator;
const GapBuffer = @import("gap_buffer").GapBuffer;
const log = std.log.scoped(.text_input); const log = std.log.scoped(.text_input);
@ -16,15 +17,11 @@ const Event = union(enum) {
// Index of our cursor // Index of our cursor
cursor_idx: usize = 0, cursor_idx: usize = 0,
grapheme_count: usize = 0, grapheme_count: usize = 0,
buf: GapBuffer(u8),
// 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),
pub fn init(alloc: std.mem.Allocator) TextInput { pub fn init(alloc: std.mem.Allocator) TextInput {
return 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| { .key_press => |key| {
if (key.matches(Key.backspace, .{})) { if (key.matches(Key.backspace, .{})) {
if (self.cursor_idx == 0) return; if (self.cursor_idx == 0) return;
self.deleteBeforeCursor(); try self.deleteBeforeCursor();
} else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
if (self.cursor_idx == self.grapheme_count) return; 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 })) { } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
if (self.cursor_idx > 0) self.cursor_idx -= 1; if (self.cursor_idx > 0) self.cursor_idx -= 1;
} else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { } 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 })) { } else if (key.matches('e', .{ .ctrl = true })) {
self.cursor_idx = self.grapheme_count; self.cursor_idx = self.grapheme_count;
} else if (key.matches('k', .{ .ctrl = true })) { } else if (key.matches('k', .{ .ctrl = true })) {
while (self.cursor_idx < self.grapheme_count) { try self.deleteToEnd();
self.deleteAtCursor();
}
} else if (key.matches('u', .{ .ctrl = true })) { } else if (key.matches('u', .{ .ctrl = true })) {
while (self.cursor_idx > 0) { try self.deleteToStart();
self.deleteBeforeCursor();
}
} else if (key.text) |text| { } else if (key.text) |text| {
try self.buf.insertSlice(self.byteOffsetToCursor(), text); try self.buf.insertSliceBefore(self.byteOffsetToCursor(), text);
self.cursor_idx += 1; self.cursor_idx += 1;
self.grapheme_count += 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 { 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 col: usize = 0;
var i: usize = 0; var i: usize = 0;
var cursor_idx: usize = 0; var cursor_idx: usize = 0;
while (iter.next()) |grapheme| { while (first_iter.next()) |grapheme| {
const g = grapheme.slice(self.buf.items); const g = grapheme.slice(self.buf.items);
const w = win.gwidth(g); const w = win.gwidth(g);
win.writeCell(col, 0, .{ win.writeCell(col, 0, .{
@ -84,11 +79,30 @@ pub fn draw(self: *TextInput, win: Window) void {
i += 1; i += 1;
if (i == self.cursor_idx) cursor_idx = col; 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); win.showCursor(cursor_idx, 0);
} }
// returns the number of bytes before the cursor // 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 { 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 iter = GraphemeIterator.init(self.buf.items);
var offset: usize = 0; var offset: usize = 0;
var i: usize = 0; var i: usize = 0;
@ -96,20 +110,52 @@ fn byteOffsetToCursor(self: TextInput) usize {
if (i == self.cursor_idx) break; if (i == self.cursor_idx) break;
offset += grapheme.len; offset += grapheme.len;
i += 1; 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; 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 iter = GraphemeIterator.init(self.buf.items);
var offset: usize = 0; var offset: usize = 0;
var i: usize = 1; var i: usize = 1;
while (iter.next()) |grapheme| { while (iter.next()) |grapheme| {
if (i == self.cursor_idx) { if (i == self.cursor_idx) {
var j: usize = 0; try self.buf.replaceRangeBefore(offset, grapheme.len, &.{});
while (j < grapheme.len) : (j += 1) { self.cursor_idx -= 1;
_ = self.buf.orderedRemove(offset); 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.cursor_idx -= 1;
self.grapheme_count -= 1; self.grapheme_count -= 1;
return; return;
@ -118,17 +164,27 @@ fn deleteBeforeCursor(self: *TextInput) void {
i += 1; 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 iter = GraphemeIterator.init(self.buf.items);
var offset: usize = 0; var offset: usize = 0;
var i: usize = 1; var i: usize = 1;
while (iter.next()) |grapheme| { while (iter.next()) |grapheme| {
if (i == self.cursor_idx + 1) { if (i == self.cursor_idx + 1) {
var j: usize = 0; try self.buf.replaceRangeAfter(offset, grapheme.len, &.{});
while (j < grapheme.len) : (j += 1) { self.grapheme_count -= 1;
_ = self.buf.orderedRemove(offset); 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; self.grapheme_count -= 1;
return; return;
} }
@ -136,3 +192,17 @@ fn deleteAtCursor(self: *TextInput) void {
i += 1; 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 });
}
}