a modern tui library written in zig
1const std = @import("std");
2const vaxis = @import("../main.zig");
3
4const vxfw = @import("vxfw.zig");
5
6const assert = std.debug.assert;
7
8const Allocator = std.mem.Allocator;
9const Key = vaxis.Key;
10const Cell = vaxis.Cell;
11const Window = vaxis.Window;
12const unicode = vaxis.unicode;
13
14const TextField = @This();
15
16const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };
17
18// Index of our cursor
19buf: Buffer,
20
21/// Style to draw the TextField with
22style: vaxis.Style = .{},
23
24/// the number of graphemes to skip when drawing. Used for horizontal scrolling
25draw_offset: u16 = 0,
26/// the column we placed the cursor the last time we drew
27prev_cursor_col: u16 = 0,
28/// the grapheme index of the cursor the last time we drew
29prev_cursor_idx: u16 = 0,
30/// approximate distance from an edge before we scroll
31scroll_offset: u4 = 4,
32/// Previous width we drew at
33prev_width: u16 = 0,
34
35previous_val: []const u8 = "",
36
37userdata: ?*anyopaque = null,
38onChange: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null,
39onSubmit: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null,
40
41pub fn init(alloc: std.mem.Allocator) TextField {
42 return TextField{
43 .buf = Buffer.init(alloc),
44 };
45}
46
47pub fn deinit(self: *TextField) void {
48 self.buf.allocator.free(self.previous_val);
49 self.buf.deinit();
50}
51
52pub fn widget(self: *TextField) vxfw.Widget {
53 return .{
54 .userdata = self,
55 .eventHandler = typeErasedEventHandler,
56 .drawFn = typeErasedDrawFn,
57 };
58}
59
60fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
61 const self: *TextField = @ptrCast(@alignCast(ptr));
62 return self.handleEvent(ctx, event);
63}
64
65pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
66 switch (event) {
67 .focus_out, .focus_in => ctx.redraw = true,
68 .key_press => |key| {
69 if (key.matches(Key.backspace, .{})) {
70 self.deleteBeforeCursor();
71 return self.checkChanged(ctx);
72 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
73 self.deleteAfterCursor();
74 return self.checkChanged(ctx);
75 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
76 self.cursorLeft();
77 return ctx.consumeAndRedraw();
78 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) {
79 self.cursorRight();
80 return ctx.consumeAndRedraw();
81 } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) {
82 self.buf.moveGapLeft(self.buf.firstHalf().len);
83 return ctx.consumeAndRedraw();
84 } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) {
85 self.buf.moveGapRight(self.buf.secondHalf().len);
86 return ctx.consumeAndRedraw();
87 } else if (key.matches('k', .{ .ctrl = true })) {
88 self.deleteToEnd();
89 return self.checkChanged(ctx);
90 } else if (key.matches('u', .{ .ctrl = true })) {
91 self.deleteToStart();
92 return self.checkChanged(ctx);
93 } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) {
94 self.moveBackwardWordwise();
95 return ctx.consumeAndRedraw();
96 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) {
97 self.moveForwardWordwise();
98 return ctx.consumeAndRedraw();
99 } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) {
100 self.deleteWordBefore();
101 return self.checkChanged(ctx);
102 } else if (key.matches('d', .{ .alt = true })) {
103 self.deleteWordAfter();
104 return self.checkChanged(ctx);
105 } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
106 if (self.onSubmit) |onSubmit| {
107 const value = try self.toOwnedSlice();
108 // Get a ref to the allocator in case onSubmit deinits the TextField
109 const allocator = self.buf.allocator;
110 defer allocator.free(value);
111 try onSubmit(self.userdata, ctx, value);
112 return ctx.consumeAndRedraw();
113 }
114 } else if (key.text) |text| {
115 try self.insertSliceAtCursor(text);
116 return self.checkChanged(ctx);
117 }
118 },
119 else => {},
120 }
121}
122
123fn checkChanged(self: *TextField, ctx: *vxfw.EventContext) anyerror!void {
124 ctx.consumeAndRedraw();
125 const onChange = self.onChange orelse return;
126 const new = try self.buf.dupe();
127 defer {
128 self.buf.allocator.free(self.previous_val);
129 self.previous_val = new;
130 }
131 if (std.mem.eql(u8, new, self.previous_val)) return;
132 try onChange(self.userdata, ctx, new);
133}
134
135/// insert text at the cursor position
136pub fn insertSliceAtCursor(self: *TextField, data: []const u8) std.mem.Allocator.Error!void {
137 var iter = unicode.graphemeIterator(data);
138 while (iter.next()) |text| {
139 try self.buf.insertSliceAtCursor(text.bytes(data));
140 }
141}
142
143pub fn sliceToCursor(self: *TextField, buf: []u8) []const u8 {
144 assert(buf.len >= self.buf.cursor);
145 @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf());
146 return buf[0..self.buf.cursor];
147}
148
149/// calculates the display width from the draw_offset to the cursor
150pub fn widthToCursor(self: *TextField, ctx: vxfw.DrawContext) u16 {
151 var width: u16 = 0;
152 const first_half = self.buf.firstHalf();
153 var first_iter = unicode.graphemeIterator(first_half);
154 var i: usize = 0;
155 while (first_iter.next()) |grapheme| {
156 defer i += 1;
157 if (i < self.draw_offset) {
158 continue;
159 }
160 const g = grapheme.bytes(first_half);
161 width += @intCast(ctx.stringWidth(g));
162 }
163 return width;
164}
165
166pub fn cursorLeft(self: *TextField) void {
167 // We need to find the size of the last grapheme in the first half
168 var iter = unicode.graphemeIterator(self.buf.firstHalf());
169 var len: usize = 0;
170 while (iter.next()) |grapheme| {
171 len = grapheme.len;
172 }
173 self.buf.moveGapLeft(len);
174}
175
176pub fn cursorRight(self: *TextField) void {
177 var iter = unicode.graphemeIterator(self.buf.secondHalf());
178 const grapheme = iter.next() orelse return;
179 self.buf.moveGapRight(grapheme.len);
180}
181
182pub fn graphemesBeforeCursor(self: *const TextField) u16 {
183 const first_half = self.buf.firstHalf();
184 var first_iter = unicode.graphemeIterator(first_half);
185 var i: u16 = 0;
186 while (first_iter.next()) |_| {
187 i += 1;
188 }
189 return i;
190}
191
192fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
193 const self: *TextField = @ptrCast(@alignCast(ptr));
194 return self.draw(ctx);
195}
196
197pub fn draw(self: *TextField, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
198 std.debug.assert(ctx.max.width != null);
199 const max_width = ctx.max.width.?;
200 if (max_width != self.prev_width) {
201 self.prev_width = max_width;
202 self.draw_offset = 0;
203 self.prev_cursor_col = 0;
204 }
205 // Create a surface with max width and a minimum height of 1.
206 var surface = try vxfw.Surface.init(
207 ctx.arena,
208 self.widget(),
209 .{ .width = max_width, .height = @max(ctx.min.height, 1) },
210 );
211
212 const base: vaxis.Cell = .{ .style = self.style };
213 @memset(surface.buffer, base);
214 const style = self.style;
215 const cursor_idx = self.graphemesBeforeCursor();
216 if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx;
217 if (max_width == 0) return surface;
218 while (true) {
219 const width = self.widthToCursor(ctx);
220 if (width >= max_width) {
221 self.draw_offset +|= width - max_width + 1;
222 continue;
223 } else break;
224 }
225
226 self.prev_cursor_idx = cursor_idx;
227 self.prev_cursor_col = 0;
228
229 const first_half = self.buf.firstHalf();
230 var first_iter = unicode.graphemeIterator(first_half);
231 var col: u16 = 0;
232 var i: u16 = 0;
233 while (first_iter.next()) |grapheme| {
234 if (i < self.draw_offset) {
235 i += 1;
236 continue;
237 }
238 const g = grapheme.bytes(first_half);
239 const w: u8 = @intCast(ctx.stringWidth(g));
240 if (col + w >= max_width) {
241 surface.writeCell(max_width - 1, 0, .{
242 .char = ellipsis,
243 .style = style,
244 });
245 break;
246 }
247 surface.writeCell(@intCast(col), 0, .{
248 .char = .{
249 .grapheme = g,
250 .width = w,
251 },
252 .style = style,
253 });
254 col += w;
255 i += 1;
256 if (i == cursor_idx) self.prev_cursor_col = col;
257 }
258 const second_half = self.buf.secondHalf();
259 var second_iter = unicode.graphemeIterator(second_half);
260 while (second_iter.next()) |grapheme| {
261 if (i < self.draw_offset) {
262 i += 1;
263 continue;
264 }
265 const g = grapheme.bytes(second_half);
266 const w: u8 = @intCast(ctx.stringWidth(g));
267 if (col + w > max_width) {
268 surface.writeCell(max_width - 1, 0, .{
269 .char = ellipsis,
270 .style = style,
271 });
272 break;
273 }
274 surface.writeCell(@intCast(col), 0, .{
275 .char = .{
276 .grapheme = g,
277 .width = w,
278 },
279 .style = style,
280 });
281 col += w;
282 i += 1;
283 if (i == cursor_idx) self.prev_cursor_col = col;
284 }
285 if (self.draw_offset > 0) {
286 surface.writeCell(0, 0, .{
287 .char = ellipsis,
288 .style = style,
289 });
290 }
291 surface.cursor = .{ .col = @intCast(self.prev_cursor_col), .row = 0 };
292 return surface;
293 // win.showCursor(self.prev_cursor_col, 0);
294}
295
296pub fn clearAndFree(self: *TextField) void {
297 self.buf.clearAndFree();
298 self.reset();
299}
300
301pub fn clearRetainingCapacity(self: *TextField) void {
302 self.buf.clearRetainingCapacity();
303 self.reset();
304}
305
306pub fn toOwnedSlice(self: *TextField) ![]const u8 {
307 defer self.reset();
308 return self.buf.toOwnedSlice();
309}
310
311pub fn reset(self: *TextField) void {
312 self.draw_offset = 0;
313 self.prev_cursor_col = 0;
314 self.prev_cursor_idx = 0;
315}
316
317// returns the number of bytes before the cursor
318pub fn byteOffsetToCursor(self: TextField) usize {
319 return self.buf.cursor;
320}
321
322pub fn deleteToEnd(self: *TextField) void {
323 self.buf.growGapRight(self.buf.secondHalf().len);
324}
325
326pub fn deleteToStart(self: *TextField) void {
327 self.buf.growGapLeft(self.buf.cursor);
328}
329
330pub fn deleteBeforeCursor(self: *TextField) void {
331 // We need to find the size of the last grapheme in the first half
332 var iter = unicode.graphemeIterator(self.buf.firstHalf());
333 var len: usize = 0;
334 while (iter.next()) |grapheme| {
335 len = grapheme.len;
336 }
337 self.buf.growGapLeft(len);
338}
339
340pub fn deleteAfterCursor(self: *TextField) void {
341 var iter = unicode.graphemeIterator(self.buf.secondHalf());
342 const grapheme = iter.next() orelse return;
343 self.buf.growGapRight(grapheme.len);
344}
345
346/// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is
347/// positioned just after the next previous space
348pub fn moveBackwardWordwise(self: *TextField) void {
349 const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " ");
350 const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last|
351 last + 1
352 else
353 0;
354 self.buf.moveGapLeft(self.buf.cursor - idx);
355}
356
357pub fn moveForwardWordwise(self: *TextField) void {
358 const second_half = self.buf.secondHalf();
359 var i: usize = 0;
360 while (i < second_half.len and second_half[i] == ' ') : (i += 1) {}
361 const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len;
362 self.buf.moveGapRight(idx);
363}
364
365pub fn deleteWordBefore(self: *TextField) void {
366 // Store current cursor position. Move one word backward. Delete after the cursor the bytes we
367 // moved
368 const pre = self.buf.cursor;
369 self.moveBackwardWordwise();
370 self.buf.growGapRight(pre - self.buf.cursor);
371}
372
373pub fn deleteWordAfter(self: *TextField) void {
374 // Store current cursor position. Move one word backward. Delete after the cursor the bytes we
375 // moved
376 const second_half = self.buf.secondHalf();
377 var i: usize = 0;
378 while (i < second_half.len and second_half[i] == ' ') : (i += 1) {}
379 const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len;
380 self.buf.growGapRight(idx);
381}
382
383test "sliceToCursor" {
384 var input = init(std.testing.allocator);
385 defer input.deinit();
386 try input.insertSliceAtCursor("hello, world");
387 input.cursorLeft();
388 input.cursorLeft();
389 input.cursorLeft();
390 var buf: [32]u8 = undefined;
391 try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf));
392 input.cursorRight();
393 try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf));
394}
395
396pub const Buffer = struct {
397 allocator: std.mem.Allocator,
398 buffer: []u8,
399 cursor: usize,
400 gap_size: usize,
401
402 pub fn init(allocator: std.mem.Allocator) Buffer {
403 return .{
404 .allocator = allocator,
405 .buffer = &.{},
406 .cursor = 0,
407 .gap_size = 0,
408 };
409 }
410
411 pub fn deinit(self: *Buffer) void {
412 self.allocator.free(self.buffer);
413 }
414
415 pub fn firstHalf(self: Buffer) []const u8 {
416 return self.buffer[0..self.cursor];
417 }
418
419 pub fn secondHalf(self: Buffer) []const u8 {
420 return self.buffer[self.cursor + self.gap_size ..];
421 }
422
423 pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void {
424 // Always grow by 512 bytes
425 const new_size = self.buffer.len + n + 512;
426 // Allocate the new memory
427 const new_memory = try self.allocator.alloc(u8, new_size);
428 // Copy the first half
429 @memcpy(new_memory[0..self.cursor], self.firstHalf());
430 // Copy the second half
431 const second_half = self.secondHalf();
432 @memcpy(new_memory[new_size - second_half.len ..], second_half);
433 self.allocator.free(self.buffer);
434 self.buffer = new_memory;
435 self.gap_size = new_size - second_half.len - self.cursor;
436 }
437
438 pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void {
439 if (slice.len == 0) return;
440 if (self.gap_size <= slice.len) try self.grow(slice.len);
441 @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice);
442 self.cursor += slice.len;
443 self.gap_size -= slice.len;
444 }
445
446 /// Move the gap n bytes to the left
447 pub fn moveGapLeft(self: *Buffer, n: usize) void {
448 const new_idx = self.cursor -| n;
449 const dst = self.buffer[new_idx + self.gap_size ..];
450 const src = self.buffer[new_idx..self.cursor];
451 std.mem.copyForwards(u8, dst, src);
452 self.cursor = new_idx;
453 }
454
455 pub fn moveGapRight(self: *Buffer, n: usize) void {
456 const new_idx = self.cursor + n;
457 const dst = self.buffer[self.cursor..];
458 const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size];
459 std.mem.copyForwards(u8, dst, src);
460 self.cursor = new_idx;
461 }
462
463 /// grow the gap by moving the cursor n bytes to the left
464 pub fn growGapLeft(self: *Buffer, n: usize) void {
465 // gap grows by the delta
466 self.gap_size += n;
467 self.cursor -|= n;
468 }
469
470 /// grow the gap by removing n bytes after the cursor
471 pub fn growGapRight(self: *Buffer, n: usize) void {
472 self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor);
473 }
474
475 pub fn clearAndFree(self: *Buffer) void {
476 self.cursor = 0;
477 self.allocator.free(self.buffer);
478 self.buffer = &.{};
479 self.gap_size = 0;
480 }
481
482 pub fn clearRetainingCapacity(self: *Buffer) void {
483 self.cursor = 0;
484 self.gap_size = self.buffer.len;
485 }
486
487 pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 {
488 const slice = try self.dupe();
489 self.clearAndFree();
490 return slice;
491 }
492
493 pub fn realLength(self: *const Buffer) usize {
494 return self.firstHalf().len + self.secondHalf().len;
495 }
496
497 pub fn dupe(self: *const Buffer) std.mem.Allocator.Error![]const u8 {
498 const first_half = self.firstHalf();
499 const second_half = self.secondHalf();
500 const buf = try self.allocator.alloc(u8, first_half.len + second_half.len);
501 @memcpy(buf[0..first_half.len], first_half);
502 @memcpy(buf[first_half.len..], second_half);
503 return buf;
504 }
505};
506
507test "TextField.zig: Buffer" {
508 var gap_buf = Buffer.init(std.testing.allocator);
509 defer gap_buf.deinit();
510
511 try gap_buf.insertSliceAtCursor("abc");
512 try std.testing.expectEqualStrings("abc", gap_buf.firstHalf());
513 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
514
515 gap_buf.moveGapLeft(1);
516 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
517 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
518
519 try gap_buf.insertSliceAtCursor(" ");
520 try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf());
521 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
522
523 gap_buf.growGapLeft(1);
524 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
525 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
526 try std.testing.expectEqual(2, gap_buf.cursor);
527
528 gap_buf.growGapRight(1);
529 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
530 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
531 try std.testing.expectEqual(2, gap_buf.cursor);
532}
533
534test TextField {
535 // Boiler plate draw context init
536 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
537 defer arena.deinit();
538 vxfw.DrawContext.init(.unicode);
539
540 // Create some object which reacts to text field changes
541 const Foo = struct {
542 allocator: std.mem.Allocator,
543 text: []const u8,
544
545 fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, str: []const u8) anyerror!void {
546 const foo: *@This() = @ptrCast(@alignCast(ptr));
547 foo.text = try foo.allocator.dupe(u8, str);
548 ctx.consumeAndRedraw();
549 }
550 };
551 var foo: Foo = .{ .text = "", .allocator = arena.allocator() };
552
553 // Text field expands to the width, so it can't be null. It is always 1 line tall
554 const draw_ctx: vxfw.DrawContext = .{
555 .arena = arena.allocator(),
556 .min = .{},
557 .max = .{ .width = 8, .height = 1 },
558 .cell_size = .{ .width = 10, .height = 20 },
559 };
560 _ = draw_ctx;
561
562 var ctx: vxfw.EventContext = .{
563 .alloc = arena.allocator(),
564 .cmds = .empty,
565 };
566
567 // Enough boiler plate...Create the text field
568 var text_field = TextField.init(std.testing.allocator);
569 defer text_field.deinit();
570 text_field.onChange = Foo.onChange;
571 text_field.onSubmit = Foo.onChange;
572 text_field.userdata = &foo;
573
574 const tf_widget = text_field.widget();
575 // Send some key events to the widget
576 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'H', .text = "H" } });
577 // The foo object stores the last text that we saw from an onChange call
578 try std.testing.expectEqualStrings("H", foo.text);
579 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'e', .text = "e" } });
580 try std.testing.expectEqualStrings("He", foo.text);
581 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } });
582 try std.testing.expectEqualStrings("Hel", foo.text);
583 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } });
584 try std.testing.expectEqualStrings("Hell", foo.text);
585 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'o', .text = "o" } });
586 try std.testing.expectEqualStrings("Hello", foo.text);
587
588 // An arrow moves the cursor. The text doesn't change
589 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.left } });
590 try std.testing.expectEqualStrings("Hello", foo.text);
591
592 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } });
593 try std.testing.expectEqualStrings("Hell_o", foo.text);
594}
595
596test "refAllDecls" {
597 std.testing.refAllDecls(@This());
598}