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