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