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 Unicode = @import("../Unicode.zig");
7
8const TextInput = @This();
9
10/// The events that this widget handles
11const Event = union(enum) {
12 key_press: Key,
13};
14
15const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };
16
17// Index of our cursor
18buf: Buffer,
19
20/// the number of graphemes to skip when drawing. Used for horizontal scrolling
21draw_offset: usize = 0,
22/// the column we placed the cursor the last time we drew
23prev_cursor_col: usize = 0,
24/// the grapheme index of the cursor the last time we drew
25prev_cursor_idx: usize = 0,
26/// approximate distance from an edge before we scroll
27scroll_offset: usize = 4,
28
29unicode: *const Unicode,
30
31pub fn init(alloc: std.mem.Allocator, unicode: *const Unicode) TextInput {
32 return TextInput{
33 .buf = Buffer.init(alloc),
34 .unicode = unicode,
35 };
36}
37
38pub fn deinit(self: *TextInput) void {
39 self.buf.deinit();
40}
41
42pub fn update(self: *TextInput, event: Event) !void {
43 switch (event) {
44 .key_press => |key| {
45 if (key.matches(Key.backspace, .{})) {
46 self.deleteBeforeCursor();
47 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
48 self.deleteAfterCursor();
49 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
50 self.cursorLeft();
51 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) {
52 self.cursorRight();
53 } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) {
54 self.buf.moveGapLeft(self.buf.firstHalf().len);
55 } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) {
56 self.buf.moveGapRight(self.buf.secondHalf().len);
57 } else if (key.matches('k', .{ .ctrl = true })) {
58 self.deleteToEnd();
59 } else if (key.matches('u', .{ .ctrl = true })) {
60 self.deleteToStart();
61 } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) {
62 self.moveBackwardWordwise();
63 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) {
64 self.moveForwardWordwise();
65 } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) {
66 self.deleteWordBefore();
67 } else if (key.matches('d', .{ .alt = true })) {
68 self.deleteWordAfter();
69 } else if (key.text) |text| {
70 try self.insertSliceAtCursor(text);
71 }
72 },
73 }
74}
75
76/// insert text at the cursor position
77pub fn insertSliceAtCursor(self: *TextInput, data: []const u8) std.mem.Allocator.Error!void {
78 var iter = self.unicode.graphemeIterator(data);
79 while (iter.next()) |text| {
80 try self.buf.insertSliceAtCursor(text.bytes(data));
81 }
82}
83
84pub fn sliceToCursor(self: *TextInput, buf: []u8) []const u8 {
85 assert(buf.len >= self.buf.cursor);
86 @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf());
87 return buf[0..self.buf.cursor];
88}
89
90/// calculates the display width from the draw_offset to the cursor
91pub fn widthToCursor(self: *TextInput, win: Window) usize {
92 var width: usize = 0;
93 const first_half = self.buf.firstHalf();
94 var first_iter = self.unicode.graphemeIterator(first_half);
95 var i: usize = 0;
96 while (first_iter.next()) |grapheme| {
97 defer i += 1;
98 if (i < self.draw_offset) {
99 continue;
100 }
101 const g = grapheme.bytes(first_half);
102 width += win.gwidth(g);
103 }
104 return width;
105}
106
107pub fn cursorLeft(self: *TextInput) void {
108 // We need to find the size of the last grapheme in the first half
109 var iter = self.unicode.graphemeIterator(self.buf.firstHalf());
110 var len: usize = 0;
111 while (iter.next()) |grapheme| {
112 len = grapheme.len;
113 }
114 self.buf.moveGapLeft(len);
115}
116
117pub fn cursorRight(self: *TextInput) void {
118 var iter = self.unicode.graphemeIterator(self.buf.secondHalf());
119 const grapheme = iter.next() orelse return;
120 self.buf.moveGapRight(grapheme.len);
121}
122
123pub fn graphemesBeforeCursor(self: *const TextInput) usize {
124 const first_half = self.buf.firstHalf();
125 var first_iter = self.unicode.graphemeIterator(first_half);
126 var i: usize = 0;
127 while (first_iter.next()) |_| {
128 i += 1;
129 }
130 return i;
131}
132
133pub fn draw(self: *TextInput, win: Window) void {
134 self.drawWithStyle(win, .{});
135}
136
137pub fn drawWithStyle(self: *TextInput, win: Window, style: Cell.Style) void {
138 const cursor_idx = self.graphemesBeforeCursor();
139 if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx;
140 if (win.width == 0) return;
141 while (true) {
142 const width = self.widthToCursor(win);
143 if (width >= win.width) {
144 self.draw_offset +|= width - win.width + 1;
145 continue;
146 } else break;
147 }
148
149 self.prev_cursor_idx = cursor_idx;
150 self.prev_cursor_col = 0;
151
152 // assumption!! the gap is never within a grapheme
153 // one way to _ensure_ this is to move the gap... but that's a cost we probably don't want to pay.
154 const first_half = self.buf.firstHalf();
155 var first_iter = self.unicode.graphemeIterator(first_half);
156 var col: usize = 0;
157 var i: usize = 0;
158 while (first_iter.next()) |grapheme| {
159 if (i < self.draw_offset) {
160 i += 1;
161 continue;
162 }
163 const g = grapheme.bytes(first_half);
164 const w = win.gwidth(g);
165 if (col + w >= win.width) {
166 win.writeCell(win.width - 1, 0, .{
167 .char = ellipsis,
168 .style = style,
169 });
170 break;
171 }
172 win.writeCell(col, 0, .{
173 .char = .{
174 .grapheme = g,
175 .width = w,
176 },
177 .style = style,
178 });
179 col += w;
180 i += 1;
181 if (i == cursor_idx) self.prev_cursor_col = col;
182 }
183 const second_half = self.buf.secondHalf();
184 var second_iter = self.unicode.graphemeIterator(second_half);
185 while (second_iter.next()) |grapheme| {
186 if (i < self.draw_offset) {
187 i += 1;
188 continue;
189 }
190 const g = grapheme.bytes(second_half);
191 const w = win.gwidth(g);
192 if (col + w > win.width) {
193 win.writeCell(win.width - 1, 0, .{
194 .char = ellipsis,
195 .style = style,
196 });
197 break;
198 }
199 win.writeCell(col, 0, .{
200 .char = .{
201 .grapheme = g,
202 .width = w,
203 },
204 .style = style,
205 });
206 col += w;
207 i += 1;
208 if (i == cursor_idx) self.prev_cursor_col = col;
209 }
210 if (self.draw_offset > 0) {
211 win.writeCell(0, 0, .{
212 .char = ellipsis,
213 .style = style,
214 });
215 }
216 win.showCursor(self.prev_cursor_col, 0);
217}
218
219pub fn clearAndFree(self: *TextInput) void {
220 self.buf.clearAndFree();
221 self.reset();
222}
223
224pub fn clearRetainingCapacity(self: *TextInput) void {
225 self.buf.clearRetainingCapacity();
226 self.reset();
227}
228
229pub fn toOwnedSlice(self: *TextInput) ![]const u8 {
230 defer self.reset();
231 return self.buf.toOwnedSlice();
232}
233
234pub fn reset(self: *TextInput) void {
235 self.draw_offset = 0;
236 self.prev_cursor_col = 0;
237 self.prev_cursor_idx = 0;
238}
239
240// returns the number of bytes before the cursor
241pub fn byteOffsetToCursor(self: TextInput) usize {
242 return self.buf.cursor;
243}
244
245pub fn deleteToEnd(self: *TextInput) void {
246 self.buf.growGapRight(self.buf.secondHalf().len);
247}
248
249pub fn deleteToStart(self: *TextInput) void {
250 self.buf.growGapLeft(self.buf.cursor);
251}
252
253pub fn deleteBeforeCursor(self: *TextInput) void {
254 // We need to find the size of the last grapheme in the first half
255 var iter = self.unicode.graphemeIterator(self.buf.firstHalf());
256 var len: usize = 0;
257 while (iter.next()) |grapheme| {
258 len = grapheme.len;
259 }
260 self.buf.growGapLeft(len);
261}
262
263pub fn deleteAfterCursor(self: *TextInput) void {
264 var iter = self.unicode.graphemeIterator(self.buf.secondHalf());
265 const grapheme = iter.next() orelse return;
266 self.buf.growGapRight(grapheme.len);
267}
268
269/// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is
270/// positioned just after the next previous space
271pub fn moveBackwardWordwise(self: *TextInput) void {
272 const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " ");
273 const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last|
274 last + 1
275 else
276 0;
277 self.buf.moveGapLeft(self.buf.cursor - idx);
278}
279
280pub fn moveForwardWordwise(self: *TextInput) void {
281 const second_half = self.buf.secondHalf();
282 var i: usize = 0;
283 while (i < second_half.len and second_half[i] == ' ') : (i += 1) {}
284 const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len;
285 self.buf.moveGapRight(idx);
286}
287
288pub fn deleteWordBefore(self: *TextInput) void {
289 // Store current cursor position. Move one word backward. Delete after the cursor the bytes we
290 // moved
291 const pre = self.buf.cursor;
292 self.moveBackwardWordwise();
293 self.buf.growGapRight(pre - self.buf.cursor);
294}
295
296pub fn deleteWordAfter(self: *TextInput) void {
297 // Store current cursor position. Move one word backward. Delete after the cursor the bytes we
298 // moved
299 const second_half = self.buf.secondHalf();
300 var i: usize = 0;
301 while (i < second_half.len and second_half[i] == ' ') : (i += 1) {}
302 const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len;
303 self.buf.growGapRight(idx);
304}
305
306test "assertion" {
307 const alloc = std.testing.allocator_instance.allocator();
308 const unicode = try Unicode.init(alloc);
309 defer unicode.deinit();
310 const astronaut = "👩🚀";
311 const astronaut_emoji: Key = .{
312 .text = astronaut,
313 .codepoint = try std.unicode.utf8Decode(astronaut[0..4]),
314 };
315 var input = TextInput.init(std.testing.allocator, &unicode);
316 defer input.deinit();
317 for (0..6) |_| {
318 try input.update(.{ .key_press = astronaut_emoji });
319 }
320}
321
322test "sliceToCursor" {
323 const alloc = std.testing.allocator_instance.allocator();
324 const unicode = try Unicode.init(alloc);
325 defer unicode.deinit();
326 var input = init(alloc, &unicode);
327 defer input.deinit();
328 try input.insertSliceAtCursor("hello, world");
329 input.cursorLeft();
330 input.cursorLeft();
331 input.cursorLeft();
332 var buf: [32]u8 = undefined;
333 try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf));
334 input.cursorRight();
335 try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf));
336}
337
338pub const Buffer = struct {
339 allocator: std.mem.Allocator,
340 buffer: []u8,
341 cursor: usize,
342 gap_size: usize,
343
344 pub fn init(allocator: std.mem.Allocator) Buffer {
345 return .{
346 .allocator = allocator,
347 .buffer = &.{},
348 .cursor = 0,
349 .gap_size = 0,
350 };
351 }
352
353 pub fn deinit(self: *Buffer) void {
354 self.allocator.free(self.buffer);
355 }
356
357 pub fn firstHalf(self: Buffer) []const u8 {
358 return self.buffer[0..self.cursor];
359 }
360
361 pub fn secondHalf(self: Buffer) []const u8 {
362 return self.buffer[self.cursor + self.gap_size ..];
363 }
364
365 pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void {
366 // Always grow by 512 bytes
367 const new_size = self.buffer.len + n + 512;
368 // Allocate the new memory
369 const new_memory = try self.allocator.alloc(u8, new_size);
370 // Copy the first half
371 @memcpy(new_memory[0..self.cursor], self.firstHalf());
372 // Copy the second half
373 const second_half = self.secondHalf();
374 @memcpy(new_memory[new_size - second_half.len ..], second_half);
375 self.allocator.free(self.buffer);
376 self.buffer = new_memory;
377 self.gap_size = new_size - second_half.len - self.cursor;
378 }
379
380 pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void {
381 if (slice.len == 0) return;
382 if (self.gap_size <= slice.len) try self.grow(slice.len);
383 @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice);
384 self.cursor += slice.len;
385 self.gap_size -= slice.len;
386 }
387
388 /// Move the gap n bytes to the left
389 pub fn moveGapLeft(self: *Buffer, n: usize) void {
390 const new_idx = self.cursor -| n;
391 const dst = self.buffer[new_idx + self.gap_size ..];
392 const src = self.buffer[new_idx..self.cursor];
393 std.mem.copyForwards(u8, dst, src);
394 self.cursor = new_idx;
395 }
396
397 pub fn moveGapRight(self: *Buffer, n: usize) void {
398 const new_idx = self.cursor + n;
399 const dst = self.buffer[self.cursor..];
400 const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size];
401 std.mem.copyForwards(u8, dst, src);
402 self.cursor = new_idx;
403 }
404
405 /// grow the gap by moving the cursor n bytes to the left
406 pub fn growGapLeft(self: *Buffer, n: usize) void {
407 // gap grows by the delta
408 self.gap_size += n;
409 self.cursor -|= n;
410 }
411
412 /// grow the gap by removing n bytes after the cursor
413 pub fn growGapRight(self: *Buffer, n: usize) void {
414 self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor);
415 }
416
417 pub fn clearAndFree(self: *Buffer) void {
418 self.cursor = 0;
419 self.allocator.free(self.buffer);
420 self.buffer = &.{};
421 self.gap_size = 0;
422 }
423
424 pub fn clearRetainingCapacity(self: *Buffer) void {
425 self.cursor = 0;
426 self.gap_size = self.buffer.len;
427 }
428
429 pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 {
430 const first_half = self.firstHalf();
431 const second_half = self.secondHalf();
432 const buf = try self.allocator.alloc(u8, first_half.len + second_half.len);
433 @memcpy(buf[0..first_half.len], first_half);
434 @memcpy(buf[first_half.len..], second_half);
435 self.clearAndFree();
436 return buf;
437 }
438
439 pub fn realLength(self: *const Buffer) usize {
440 return self.firstHalf().len + self.secondHalf().len;
441 }
442};
443
444test "TextInput.zig: Buffer" {
445 var gap_buf = Buffer.init(std.testing.allocator);
446 defer gap_buf.deinit();
447
448 try gap_buf.insertSliceAtCursor("abc");
449 try std.testing.expectEqualStrings("abc", gap_buf.firstHalf());
450 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
451
452 gap_buf.moveGapLeft(1);
453 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
454 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
455
456 try gap_buf.insertSliceAtCursor(" ");
457 try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf());
458 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
459
460 gap_buf.growGapLeft(1);
461 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
462 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
463 try std.testing.expectEqual(2, gap_buf.cursor);
464
465 gap_buf.growGapRight(1);
466 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
467 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
468 try std.testing.expectEqual(2, gap_buf.cursor);
469}