a modern tui library written in zig
1const std = @import("std");
2const assert = std.debug.assert;
3const Key = @import("../Key.zig");
4const Cell = @import("../Cell.zig");
5const Window = @import("../Window.zig");
6const GapBuffer = @import("gap_buffer").GapBuffer;
7const Unicode = @import("../Unicode.zig");
8
9const TextInput = @This();
10
11/// The events that this widget handles
12const Event = union(enum) {
13 key_press: Key,
14};
15
16const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };
17
18// Index of our cursor
19cursor_idx: usize = 0,
20grapheme_count: usize = 0,
21buf: GapBuffer(u8),
22
23/// the number of graphemes to skip when drawing. Used for horizontal scrolling
24draw_offset: usize = 0,
25/// the column we placed the cursor the last time we drew
26prev_cursor_col: usize = 0,
27/// the grapheme index of the cursor the last time we drew
28prev_cursor_idx: usize = 0,
29/// approximate distance from an edge before we scroll
30scroll_offset: usize = 4,
31
32unicode: *const Unicode,
33
34pub fn init(alloc: std.mem.Allocator, unicode: *const Unicode) TextInput {
35 return TextInput{
36 .buf = GapBuffer(u8).init(alloc),
37 .unicode = unicode,
38 };
39}
40
41pub fn deinit(self: *TextInput) void {
42 self.buf.deinit();
43}
44
45pub fn update(self: *TextInput, event: Event) !void {
46 switch (event) {
47 .key_press => |key| {
48 if (key.matches(Key.backspace, .{})) {
49 if (self.cursor_idx == 0) return;
50 try self.deleteBeforeCursor();
51 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
52 if (self.cursor_idx == self.grapheme_count) return;
53 try self.deleteAtCursor();
54 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
55 if (self.cursor_idx > 0) self.cursor_idx -= 1;
56 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) {
57 if (self.cursor_idx < self.grapheme_count) self.cursor_idx += 1;
58 } else if (key.matches('a', .{ .ctrl = true })) {
59 self.cursor_idx = 0;
60 } else if (key.matches('e', .{ .ctrl = true })) {
61 self.cursor_idx = self.grapheme_count;
62 } else if (key.matches('k', .{ .ctrl = true })) {
63 try self.deleteToEnd();
64 } else if (key.matches('u', .{ .ctrl = true })) {
65 try self.deleteToStart();
66 } else if (key.text) |text| {
67 try self.buf.insertSliceBefore(self.byteOffsetToCursor(), text);
68 self.cursor_idx += 1;
69 self.grapheme_count += 1;
70 }
71 },
72 }
73}
74
75/// insert text at the cursor position
76pub fn insertSliceAtCursor(self: *TextInput, data: []const u8) !void {
77 var iter = self.unicode.graphemeIterator(data);
78 var byte_offset_to_cursor = self.byteOffsetToCursor();
79 while (iter.next()) |text| {
80 try self.buf.insertSliceBefore(byte_offset_to_cursor, text.bytes(data));
81 byte_offset_to_cursor += text.len;
82 self.cursor_idx += 1;
83 self.grapheme_count += 1;
84 }
85}
86
87pub fn sliceToCursor(self: *TextInput, buf: []u8) []const u8 {
88 const offset = self.byteOffsetToCursor();
89 assert(offset <= buf.len); // provided buf was too small
90
91 if (offset <= self.buf.items.len) {
92 @memcpy(buf[0..offset], self.buf.items[0..offset]);
93 } else {
94 @memcpy(buf[0..self.buf.items.len], self.buf.items);
95 const second_half = self.buf.secondHalf();
96 const copy_len = offset - self.buf.items.len;
97 @memcpy(buf[self.buf.items.len .. self.buf.items.len + copy_len], second_half[0..copy_len]);
98 }
99 return buf[0..offset];
100}
101
102/// calculates the display width from the draw_offset to the cursor
103fn widthToCursor(self: *TextInput, win: Window) usize {
104 var width: usize = 0;
105 var first_iter = self.unicode.graphemeIterator(self.buf.items);
106 var i: usize = 0;
107 while (first_iter.next()) |grapheme| {
108 defer i += 1;
109 if (i < self.draw_offset) {
110 continue;
111 }
112 if (i == self.cursor_idx) return width;
113 const g = grapheme.bytes(self.buf.items);
114 width += win.gwidth(g);
115 }
116 const second_half = self.buf.secondHalf();
117 var second_iter = self.unicode.graphemeIterator(second_half);
118 while (second_iter.next()) |grapheme| {
119 defer i += 1;
120 if (i < self.draw_offset) {
121 continue;
122 }
123 if (i == self.cursor_idx) return width;
124 const g = grapheme.bytes(second_half);
125 width += win.gwidth(g);
126 }
127 return width;
128}
129
130pub fn draw(self: *TextInput, win: Window) void {
131 if (self.cursor_idx < self.draw_offset) self.draw_offset = self.cursor_idx;
132 if (win.width == 0) return;
133 while (true) {
134 const width = self.widthToCursor(win);
135 if (width >= win.width) {
136 self.draw_offset +|= width - win.width + 1;
137 continue;
138 } else break;
139 }
140
141 self.prev_cursor_idx = self.cursor_idx;
142 self.prev_cursor_col = 0;
143
144 // assumption!! the gap is never within a grapheme
145 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay.
146 var first_iter = self.unicode.graphemeIterator(self.buf.items);
147 var col: usize = 0;
148 var i: usize = 0;
149 while (first_iter.next()) |grapheme| {
150 if (i < self.draw_offset) {
151 i += 1;
152 continue;
153 }
154 const g = grapheme.bytes(self.buf.items);
155 const w = win.gwidth(g);
156 if (col + w >= win.width) {
157 win.writeCell(win.width - 1, 0, .{ .char = ellipsis });
158 break;
159 }
160 win.writeCell(col, 0, .{
161 .char = .{
162 .grapheme = g,
163 .width = w,
164 },
165 });
166 col += w;
167 i += 1;
168 if (i == self.cursor_idx) self.prev_cursor_col = col;
169 }
170 const second_half = self.buf.secondHalf();
171 var second_iter = self.unicode.graphemeIterator(second_half);
172 while (second_iter.next()) |grapheme| {
173 if (i < self.draw_offset) {
174 i += 1;
175 continue;
176 }
177 const g = grapheme.bytes(second_half);
178 const w = win.gwidth(g);
179 if (col + w > win.width) {
180 win.writeCell(win.width - 1, 0, .{ .char = ellipsis });
181 break;
182 }
183 win.writeCell(col, 0, .{
184 .char = .{
185 .grapheme = g,
186 .width = w,
187 },
188 });
189 col += w;
190 i += 1;
191 if (i == self.cursor_idx) self.prev_cursor_col = col;
192 }
193 if (self.draw_offset > 0) {
194 win.writeCell(0, 0, .{ .char = ellipsis });
195 }
196 win.showCursor(self.prev_cursor_col, 0);
197}
198
199pub fn clearAndFree(self: *TextInput) void {
200 self.buf.clearAndFree();
201 self.reset();
202}
203
204pub fn clearRetainingCapacity(self: *TextInput) void {
205 self.buf.clearRetainingCapacity();
206 self.reset();
207}
208
209pub fn toOwnedSlice(self: *TextInput) ![]const u8 {
210 defer self.reset();
211 return self.buf.toOwnedSlice();
212}
213
214fn reset(self: *TextInput) void {
215 self.cursor_idx = 0;
216 self.grapheme_count = 0;
217 self.draw_offset = 0;
218 self.prev_cursor_col = 0;
219 self.prev_cursor_idx = 0;
220}
221
222// returns the number of bytes before the cursor
223// (since GapBuffers are strictly speaking not contiguous, this is a number in 0..realLength()
224// which would need to be fed to realIndex() to get an actual offset into self.buf.items.ptr)
225pub fn byteOffsetToCursor(self: TextInput) usize {
226 // assumption! the gap is never in the middle of a grapheme
227 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay.
228 var iter = self.unicode.graphemeIterator(self.buf.items);
229 var offset: usize = 0;
230 var i: usize = 0;
231 while (iter.next()) |grapheme| {
232 if (i == self.cursor_idx) break;
233 offset += grapheme.len;
234 i += 1;
235 } else {
236 var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf());
237 while (second_iter.next()) |grapheme| {
238 if (i == self.cursor_idx) break;
239 offset += grapheme.len;
240 i += 1;
241 }
242 }
243 return offset;
244}
245
246fn deleteToEnd(self: *TextInput) !void {
247 const offset = self.byteOffsetToCursor();
248 try self.buf.replaceRangeAfter(offset, self.buf.realLength() - offset, &.{});
249 self.grapheme_count = self.cursor_idx;
250}
251
252fn deleteToStart(self: *TextInput) !void {
253 const offset = self.byteOffsetToCursor();
254 try self.buf.replaceRangeBefore(0, offset, &.{});
255 self.grapheme_count -= self.cursor_idx;
256 self.cursor_idx = 0;
257}
258
259fn deleteBeforeCursor(self: *TextInput) !void {
260 // assumption! the gap is never in the middle of a grapheme
261 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay.
262 var iter = self.unicode.graphemeIterator(self.buf.items);
263 var offset: usize = 0;
264 var i: usize = 1;
265 while (iter.next()) |grapheme| {
266 if (i == self.cursor_idx) {
267 try self.buf.replaceRangeBefore(offset, grapheme.len, &.{});
268 self.cursor_idx -= 1;
269 self.grapheme_count -= 1;
270 return;
271 }
272 offset += grapheme.len;
273 i += 1;
274 } else {
275 var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf());
276 while (second_iter.next()) |grapheme| {
277 if (i == self.cursor_idx) {
278 try self.buf.replaceRangeBefore(offset, grapheme.len, &.{});
279 self.cursor_idx -= 1;
280 self.grapheme_count -= 1;
281 return;
282 }
283 offset += grapheme.len;
284 i += 1;
285 }
286 }
287}
288
289fn deleteAtCursor(self: *TextInput) !void {
290 // assumption! the gap is never in the middle of a grapheme
291 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay.
292 var iter = self.unicode.graphemeIterator(self.buf.items);
293 var offset: usize = 0;
294 var i: usize = 1;
295 while (iter.next()) |grapheme| {
296 if (i == self.cursor_idx + 1) {
297 try self.buf.replaceRangeAfter(offset, grapheme.len, &.{});
298 self.grapheme_count -= 1;
299 return;
300 }
301 offset += grapheme.len;
302 i += 1;
303 } else {
304 var second_iter = self.unicode.graphemeIterator(self.buf.secondHalf());
305 while (second_iter.next()) |grapheme| {
306 if (i == self.cursor_idx + 1) {
307 try self.buf.replaceRangeAfter(offset, grapheme.len, &.{});
308 self.grapheme_count -= 1;
309 return;
310 }
311 offset += grapheme.len;
312 i += 1;
313 }
314 }
315}
316
317test "assertion" {
318 const alloc = std.testing.allocator_instance.allocator();
319 const unicode = try Unicode.init(alloc);
320 defer unicode.deinit();
321 const astronaut = "👩🚀";
322 const astronaut_emoji: Key = .{
323 .text = astronaut,
324 .codepoint = try std.unicode.utf8Decode(astronaut[0..4]),
325 };
326 var input = TextInput.init(std.testing.allocator, &unicode);
327 defer input.deinit();
328 for (0..6) |_| {
329 try input.update(.{ .key_press = astronaut_emoji });
330 }
331}
332
333test "sliceToCursor" {
334 const alloc = std.testing.allocator_instance.allocator();
335 const unicode = try Unicode.init(alloc);
336 defer unicode.deinit();
337 var input = init(alloc, &unicode);
338 defer input.deinit();
339 try input.insertSliceAtCursor("hello, world");
340 input.cursor_idx = 2;
341 var buf: [32]u8 = undefined;
342 try std.testing.expectEqualStrings("he", input.sliceToCursor(&buf));
343 input.buf.moveGap(3);
344 input.cursor_idx = 5;
345 try std.testing.expectEqualStrings("hello", input.sliceToCursor(&buf));
346}