this repo has no description
1const std = @import("std");
2const uucode = @import("uucode");
3const vaxis = @import("../main.zig");
4
5const vxfw = @import("vxfw.zig");
6
7const assert = std.debug.assert;
8
9const Allocator = std.mem.Allocator;
10const Key = vaxis.Key;
11const Cell = vaxis.Cell;
12const Window = vaxis.Window;
13const unicode = vaxis.unicode;
14
15const TextField = @This();
16
17const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };
18
19// Index of our cursor
20buf: Buffer,
21
22/// Style to draw the TextField with
23style: vaxis.Style = .{},
24
25/// the number of graphemes to skip when drawing. Used for horizontal scrolling
26draw_offset: u16 = 0,
27/// the column we placed the cursor the last time we drew
28prev_cursor_col: u16 = 0,
29/// the grapheme index of the cursor the last time we drew
30prev_cursor_idx: u16 = 0,
31/// approximate distance from an edge before we scroll
32scroll_offset: u4 = 4,
33/// Previous width we drew at
34prev_width: u16 = 0,
35
36previous_val: []const u8 = "",
37
38userdata: ?*anyopaque = null,
39onChange: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null,
40onSubmit: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null,
41
42pub fn init(alloc: std.mem.Allocator) TextField {
43 return TextField{
44 .buf = Buffer.init(alloc),
45 };
46}
47
48pub fn deinit(self: *TextField) void {
49 self.buf.allocator.free(self.previous_val);
50 self.buf.deinit();
51}
52
53pub fn widget(self: *TextField) vxfw.Widget {
54 return .{
55 .userdata = self,
56 .eventHandler = typeErasedEventHandler,
57 .drawFn = typeErasedDrawFn,
58 };
59}
60
61fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
62 const self: *TextField = @ptrCast(@alignCast(ptr));
63 return self.handleEvent(ctx, event);
64}
65
66pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
67 switch (event) {
68 .focus_out, .focus_in => ctx.redraw = true,
69 .key_press => |key| {
70 if (key.matches(Key.backspace, .{})) {
71 self.deleteBeforeCursor();
72 return self.checkChanged(ctx);
73 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
74 self.deleteAfterCursor();
75 return self.checkChanged(ctx);
76 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
77 self.cursorLeft();
78 return ctx.consumeAndRedraw();
79 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) {
80 self.cursorRight();
81 return ctx.consumeAndRedraw();
82 } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) {
83 self.buf.moveGapLeft(self.buf.firstHalf().len);
84 return ctx.consumeAndRedraw();
85 } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) {
86 self.buf.moveGapRight(self.buf.secondHalf().len);
87 return ctx.consumeAndRedraw();
88 } else if (key.matches('k', .{ .ctrl = true })) {
89 self.deleteToEnd();
90 return self.checkChanged(ctx);
91 } else if (key.matches('u', .{ .ctrl = true })) {
92 self.deleteToStart();
93 return self.checkChanged(ctx);
94 } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) {
95 self.moveBackwardWordwise();
96 return ctx.consumeAndRedraw();
97 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) {
98 self.moveForwardWordwise();
99 return ctx.consumeAndRedraw();
100 } else if (key.matches(Key.backspace, .{ .alt = true })) {
101 self.deleteWordBefore();
102 return self.checkChanged(ctx);
103 } else if (key.matches('w', .{ .ctrl = true })) {
104 self.deleteWordBeforeWhitespace();
105 return self.checkChanged(ctx);
106 } else if (key.matches('d', .{ .alt = true })) {
107 self.deleteWordAfter();
108 return self.checkChanged(ctx);
109 } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
110 if (self.onSubmit) |onSubmit| {
111 const value = try self.toOwnedSlice();
112 // Get a ref to the allocator in case onSubmit deinits the TextField
113 const allocator = self.buf.allocator;
114 defer allocator.free(value);
115 try onSubmit(self.userdata, ctx, value);
116 return ctx.consumeAndRedraw();
117 }
118 } else if (key.text) |text| {
119 try self.insertSliceAtCursor(text);
120 return self.checkChanged(ctx);
121 }
122 },
123 else => {},
124 }
125}
126
127fn checkChanged(self: *TextField, ctx: *vxfw.EventContext) anyerror!void {
128 ctx.consumeAndRedraw();
129 const onChange = self.onChange orelse return;
130 const new = try self.buf.dupe();
131 defer {
132 self.buf.allocator.free(self.previous_val);
133 self.previous_val = new;
134 }
135 if (std.mem.eql(u8, new, self.previous_val)) return;
136 try onChange(self.userdata, ctx, new);
137}
138
139/// insert text at the cursor position
140pub fn insertSliceAtCursor(self: *TextField, data: []const u8) std.mem.Allocator.Error!void {
141 var iter = unicode.graphemeIterator(data);
142 while (iter.next()) |text| {
143 try self.buf.insertSliceAtCursor(text.bytes(data));
144 }
145}
146
147pub fn sliceToCursor(self: *TextField, buf: []u8) []const u8 {
148 assert(buf.len >= self.buf.cursor);
149 @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf());
150 return buf[0..self.buf.cursor];
151}
152
153/// calculates the display width from the draw_offset to the cursor
154pub fn widthToCursor(self: *TextField, ctx: vxfw.DrawContext) u16 {
155 var width: u16 = 0;
156 const first_half = self.buf.firstHalf();
157 var first_iter = unicode.graphemeIterator(first_half);
158 var i: usize = 0;
159 while (first_iter.next()) |grapheme| {
160 defer i += 1;
161 if (i < self.draw_offset) {
162 continue;
163 }
164 const g = grapheme.bytes(first_half);
165 width += @intCast(ctx.stringWidth(g));
166 }
167 return width;
168}
169
170pub fn cursorLeft(self: *TextField) void {
171 // We need to find the size of the last grapheme in the first half
172 var iter = unicode.graphemeIterator(self.buf.firstHalf());
173 var len: usize = 0;
174 while (iter.next()) |grapheme| {
175 len = grapheme.len;
176 }
177 self.buf.moveGapLeft(len);
178}
179
180pub fn cursorRight(self: *TextField) void {
181 var iter = unicode.graphemeIterator(self.buf.secondHalf());
182 const grapheme = iter.next() orelse return;
183 self.buf.moveGapRight(grapheme.len);
184}
185
186pub fn graphemesBeforeCursor(self: *const TextField) u16 {
187 const first_half = self.buf.firstHalf();
188 var first_iter = unicode.graphemeIterator(first_half);
189 var i: u16 = 0;
190 while (first_iter.next()) |_| {
191 i += 1;
192 }
193 return i;
194}
195
196fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
197 const self: *TextField = @ptrCast(@alignCast(ptr));
198 return self.draw(ctx);
199}
200
201pub fn draw(self: *TextField, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
202 std.debug.assert(ctx.max.width != null);
203 const max_width = ctx.max.width.?;
204 if (max_width != self.prev_width) {
205 self.prev_width = max_width;
206 self.draw_offset = 0;
207 self.prev_cursor_col = 0;
208 }
209 // Create a surface with max width and a minimum height of 1.
210 var surface = try vxfw.Surface.init(
211 ctx.arena,
212 self.widget(),
213 .{ .width = max_width, .height = @max(ctx.min.height, 1) },
214 );
215
216 const base: vaxis.Cell = .{ .style = self.style };
217 @memset(surface.buffer, base);
218 const style = self.style;
219 const cursor_idx = self.graphemesBeforeCursor();
220 if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx;
221 if (max_width == 0) return surface;
222 while (true) {
223 const width = self.widthToCursor(ctx);
224 if (width >= max_width) {
225 self.draw_offset +|= width - max_width + 1;
226 continue;
227 } else break;
228 }
229
230 self.prev_cursor_idx = cursor_idx;
231 self.prev_cursor_col = 0;
232
233 const first_half = self.buf.firstHalf();
234 var first_iter = unicode.graphemeIterator(first_half);
235 var col: u16 = 0;
236 var i: u16 = 0;
237 while (first_iter.next()) |grapheme| {
238 if (i < self.draw_offset) {
239 i += 1;
240 continue;
241 }
242 const g = grapheme.bytes(first_half);
243 const w: u8 = @intCast(ctx.stringWidth(g));
244 if (col + w >= max_width) {
245 surface.writeCell(max_width - 1, 0, .{
246 .char = ellipsis,
247 .style = style,
248 });
249 break;
250 }
251 surface.writeCell(@intCast(col), 0, .{
252 .char = .{
253 .grapheme = g,
254 .width = w,
255 },
256 .style = style,
257 });
258 col += w;
259 i += 1;
260 if (i == cursor_idx) self.prev_cursor_col = col;
261 }
262 const second_half = self.buf.secondHalf();
263 var second_iter = unicode.graphemeIterator(second_half);
264 while (second_iter.next()) |grapheme| {
265 if (i < self.draw_offset) {
266 i += 1;
267 continue;
268 }
269 const g = grapheme.bytes(second_half);
270 const w: u8 = @intCast(ctx.stringWidth(g));
271 if (col + w > max_width) {
272 surface.writeCell(max_width - 1, 0, .{
273 .char = ellipsis,
274 .style = style,
275 });
276 break;
277 }
278 surface.writeCell(@intCast(col), 0, .{
279 .char = .{
280 .grapheme = g,
281 .width = w,
282 },
283 .style = style,
284 });
285 col += w;
286 i += 1;
287 if (i == cursor_idx) self.prev_cursor_col = col;
288 }
289 if (self.draw_offset > 0) {
290 surface.writeCell(0, 0, .{
291 .char = ellipsis,
292 .style = style,
293 });
294 }
295 surface.cursor = .{ .col = @intCast(self.prev_cursor_col), .row = 0 };
296 return surface;
297 // win.showCursor(self.prev_cursor_col, 0);
298}
299
300pub fn clearAndFree(self: *TextField) void {
301 self.buf.clearAndFree();
302 self.reset();
303}
304
305pub fn clearRetainingCapacity(self: *TextField) void {
306 self.buf.clearRetainingCapacity();
307 self.reset();
308}
309
310pub fn toOwnedSlice(self: *TextField) ![]const u8 {
311 defer self.reset();
312 return self.buf.toOwnedSlice();
313}
314
315pub fn reset(self: *TextField) void {
316 self.draw_offset = 0;
317 self.prev_cursor_col = 0;
318 self.prev_cursor_idx = 0;
319}
320
321// returns the number of bytes before the cursor
322pub fn byteOffsetToCursor(self: TextField) usize {
323 return self.buf.cursor;
324}
325
326pub fn deleteToEnd(self: *TextField) void {
327 self.buf.growGapRight(self.buf.secondHalf().len);
328}
329
330pub fn deleteToStart(self: *TextField) void {
331 self.buf.growGapLeft(self.buf.cursor);
332}
333
334pub fn deleteBeforeCursor(self: *TextField) void {
335 // We need to find the size of the last grapheme in the first half
336 var iter = unicode.graphemeIterator(self.buf.firstHalf());
337 var len: usize = 0;
338 while (iter.next()) |grapheme| {
339 len = grapheme.len;
340 }
341 self.buf.growGapLeft(len);
342}
343
344pub fn deleteAfterCursor(self: *TextField) void {
345 var iter = unicode.graphemeIterator(self.buf.secondHalf());
346 const grapheme = iter.next() orelse return;
347 self.buf.growGapRight(grapheme.len);
348}
349
350const DecodedCodepoint = struct {
351 cp: u21,
352 start: usize,
353 len: usize,
354};
355
356fn decodeCodepointAt(bytes: []const u8, start: usize) DecodedCodepoint {
357 const first = bytes[start];
358 const len = std.unicode.utf8ByteSequenceLength(first) catch 1;
359 const capped_len = @min(len, bytes.len - start);
360 const slice = bytes[start .. start + capped_len];
361 const cp = std.unicode.utf8Decode(slice) catch {
362 return .{ .cp = first, .start = start, .len = 1 };
363 };
364 return .{ .cp = cp, .start = start, .len = capped_len };
365}
366
367fn isUtf8ContinuationByte(c: u8) bool {
368 return (c & 0b1100_0000) == 0b1000_0000;
369}
370
371fn decodeCodepointBefore(bytes: []const u8, end: usize) DecodedCodepoint {
372 var start = end - 1;
373 while (start > 0 and isUtf8ContinuationByte(bytes[start])) : (start -= 1) {}
374 const slice = bytes[start..end];
375 const cp = std.unicode.utf8Decode(slice) catch {
376 return .{ .cp = bytes[end - 1], .start = end - 1, .len = 1 };
377 };
378 return .{ .cp = cp, .start = start, .len = end - start };
379}
380
381/// Returns true if the codepoint is a readline-style word constituent.
382fn isWordCodepoint(cp: u21) bool {
383 if (cp == '_') return true;
384 return switch (uucode.get(.general_category, cp)) {
385 .letter_uppercase,
386 .letter_lowercase,
387 .letter_titlecase,
388 .letter_modifier,
389 .letter_other,
390 .number_decimal_digit,
391 .number_letter,
392 .number_other,
393 .mark_nonspacing,
394 .mark_spacing_combining,
395 .mark_enclosing,
396 .punctuation_connector,
397 => true,
398 else => false,
399 };
400}
401
402fn isWhitespaceCodepoint(cp: u21) bool {
403 return switch (cp) {
404 ' ', '\t', '\n', '\r', 0x0b, 0x0c, 0x85 => true,
405 else => switch (uucode.get(.general_category, cp)) {
406 .separator_space,
407 .separator_line,
408 .separator_paragraph,
409 => true,
410 else => false,
411 },
412 };
413}
414
415/// Moves the cursor backward by one word using character-class boundaries.
416/// Skips non-word characters, then skips word characters (matching readline backward-word).
417pub fn moveBackwardWordwise(self: *TextField) void {
418 const first_half = self.buf.firstHalf();
419 var i: usize = first_half.len;
420 // Skip non-word characters
421 while (i > 0) {
422 const decoded = decodeCodepointBefore(first_half, i);
423 if (isWordCodepoint(decoded.cp)) break;
424 i = decoded.start;
425 }
426 // Skip word characters
427 while (i > 0) {
428 const decoded = decodeCodepointBefore(first_half, i);
429 if (!isWordCodepoint(decoded.cp)) break;
430 i = decoded.start;
431 }
432 self.buf.moveGapLeft(self.buf.cursor - i);
433}
434
435/// Moves the cursor forward by one word using character-class boundaries.
436/// Skips non-word characters, then skips word characters — landing at the end of the next word
437/// (matching readline forward-word).
438pub fn moveForwardWordwise(self: *TextField) void {
439 const second_half = self.buf.secondHalf();
440 var i: usize = 0;
441 // Skip non-word characters
442 while (i < second_half.len) {
443 const decoded = decodeCodepointAt(second_half, i);
444 if (isWordCodepoint(decoded.cp)) break;
445 i += decoded.len;
446 }
447 // Skip word characters
448 while (i < second_half.len) {
449 const decoded = decodeCodepointAt(second_half, i);
450 if (!isWordCodepoint(decoded.cp)) break;
451 i += decoded.len;
452 }
453 self.buf.moveGapRight(i);
454}
455
456/// Deletes the word before the cursor using character-class boundaries
457/// (matching readline backward-kill-word / Alt+Backspace).
458pub fn deleteWordBefore(self: *TextField) void {
459 const pre = self.buf.cursor;
460 self.moveBackwardWordwise();
461 self.buf.growGapRight(pre - self.buf.cursor);
462}
463
464/// Deletes the word before the cursor using whitespace boundaries
465/// (matching readline unix-word-rubout / Ctrl+W).
466pub fn deleteWordBeforeWhitespace(self: *TextField) void {
467 const first_half = self.buf.firstHalf();
468 var i: usize = first_half.len;
469 // Skip trailing whitespace
470 while (i > 0) {
471 const decoded = decodeCodepointBefore(first_half, i);
472 if (!isWhitespaceCodepoint(decoded.cp)) break;
473 i = decoded.start;
474 }
475 // Skip non-whitespace
476 while (i > 0) {
477 const decoded = decodeCodepointBefore(first_half, i);
478 if (isWhitespaceCodepoint(decoded.cp)) break;
479 i = decoded.start;
480 }
481 const to_delete = self.buf.cursor - i;
482 self.buf.moveGapLeft(to_delete);
483 self.buf.growGapRight(to_delete);
484}
485
486/// Deletes the word after the cursor using character-class boundaries
487/// (matching readline kill-word / Alt+D).
488pub fn deleteWordAfter(self: *TextField) void {
489 const second_half = self.buf.secondHalf();
490 var i: usize = 0;
491 // Skip non-word characters
492 while (i < second_half.len) {
493 const decoded = decodeCodepointAt(second_half, i);
494 if (isWordCodepoint(decoded.cp)) break;
495 i += decoded.len;
496 }
497 // Skip word characters
498 while (i < second_half.len) {
499 const decoded = decodeCodepointAt(second_half, i);
500 if (!isWordCodepoint(decoded.cp)) break;
501 i += decoded.len;
502 }
503 self.buf.growGapRight(i);
504}
505
506test "sliceToCursor" {
507 var input = init(std.testing.allocator);
508 defer input.deinit();
509 try input.insertSliceAtCursor("hello, world");
510 input.cursorLeft();
511 input.cursorLeft();
512 input.cursorLeft();
513 var buf: [32]u8 = undefined;
514 try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf));
515 input.cursorRight();
516 try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf));
517}
518
519pub const Buffer = struct {
520 allocator: std.mem.Allocator,
521 buffer: []u8,
522 cursor: usize,
523 gap_size: usize,
524
525 pub fn init(allocator: std.mem.Allocator) Buffer {
526 return .{
527 .allocator = allocator,
528 .buffer = &.{},
529 .cursor = 0,
530 .gap_size = 0,
531 };
532 }
533
534 pub fn deinit(self: *Buffer) void {
535 self.allocator.free(self.buffer);
536 }
537
538 pub fn firstHalf(self: Buffer) []const u8 {
539 return self.buffer[0..self.cursor];
540 }
541
542 pub fn secondHalf(self: Buffer) []const u8 {
543 return self.buffer[self.cursor + self.gap_size ..];
544 }
545
546 pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void {
547 // Always grow by 512 bytes
548 const new_size = self.buffer.len + n + 512;
549 // Allocate the new memory
550 const new_memory = try self.allocator.alloc(u8, new_size);
551 // Copy the first half
552 @memcpy(new_memory[0..self.cursor], self.firstHalf());
553 // Copy the second half
554 const second_half = self.secondHalf();
555 @memcpy(new_memory[new_size - second_half.len ..], second_half);
556 self.allocator.free(self.buffer);
557 self.buffer = new_memory;
558 self.gap_size = new_size - second_half.len - self.cursor;
559 }
560
561 pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void {
562 if (slice.len == 0) return;
563 if (self.gap_size <= slice.len) try self.grow(slice.len);
564 @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice);
565 self.cursor += slice.len;
566 self.gap_size -= slice.len;
567 }
568
569 /// Move the gap n bytes to the left
570 pub fn moveGapLeft(self: *Buffer, n: usize) void {
571 const new_idx = self.cursor -| n;
572 const dst = self.buffer[new_idx + self.gap_size ..];
573 const src = self.buffer[new_idx..self.cursor];
574 std.mem.copyForwards(u8, dst, src);
575 self.cursor = new_idx;
576 }
577
578 pub fn moveGapRight(self: *Buffer, n: usize) void {
579 const new_idx = self.cursor + n;
580 const dst = self.buffer[self.cursor..];
581 const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size];
582 std.mem.copyForwards(u8, dst, src);
583 self.cursor = new_idx;
584 }
585
586 /// grow the gap by moving the cursor n bytes to the left
587 pub fn growGapLeft(self: *Buffer, n: usize) void {
588 // gap grows by the delta
589 self.gap_size += n;
590 self.cursor -|= n;
591 }
592
593 /// grow the gap by removing n bytes after the cursor
594 pub fn growGapRight(self: *Buffer, n: usize) void {
595 self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor);
596 }
597
598 pub fn clearAndFree(self: *Buffer) void {
599 self.cursor = 0;
600 self.allocator.free(self.buffer);
601 self.buffer = &.{};
602 self.gap_size = 0;
603 }
604
605 pub fn clearRetainingCapacity(self: *Buffer) void {
606 self.cursor = 0;
607 self.gap_size = self.buffer.len;
608 }
609
610 pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 {
611 const slice = try self.dupe();
612 self.clearAndFree();
613 return slice;
614 }
615
616 pub fn realLength(self: *const Buffer) usize {
617 return self.firstHalf().len + self.secondHalf().len;
618 }
619
620 pub fn dupe(self: *const Buffer) std.mem.Allocator.Error![]const u8 {
621 const first_half = self.firstHalf();
622 const second_half = self.secondHalf();
623 const buf = try self.allocator.alloc(u8, first_half.len + second_half.len);
624 @memcpy(buf[0..first_half.len], first_half);
625 @memcpy(buf[first_half.len..], second_half);
626 return buf;
627 }
628};
629
630test "TextField.zig: Buffer" {
631 var gap_buf = Buffer.init(std.testing.allocator);
632 defer gap_buf.deinit();
633
634 try gap_buf.insertSliceAtCursor("abc");
635 try std.testing.expectEqualStrings("abc", gap_buf.firstHalf());
636 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
637
638 gap_buf.moveGapLeft(1);
639 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
640 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
641
642 try gap_buf.insertSliceAtCursor(" ");
643 try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf());
644 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
645
646 gap_buf.growGapLeft(1);
647 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
648 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
649 try std.testing.expectEqual(2, gap_buf.cursor);
650
651 gap_buf.growGapRight(1);
652 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
653 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
654 try std.testing.expectEqual(2, gap_buf.cursor);
655}
656
657test TextField {
658 const io = std.testing.io;
659
660 // Boiler plate draw context init
661 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
662 defer arena.deinit();
663 vxfw.DrawContext.init(.unicode);
664
665 // Create some object which reacts to text field changes
666 const Foo = struct {
667 allocator: std.mem.Allocator,
668 text: []const u8,
669
670 fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, str: []const u8) anyerror!void {
671 const foo: *@This() = @ptrCast(@alignCast(ptr));
672 foo.text = try foo.allocator.dupe(u8, str);
673 ctx.consumeAndRedraw();
674 }
675 };
676 var foo: Foo = .{ .text = "", .allocator = arena.allocator() };
677
678 // Text field expands to the width, so it can't be null. It is always 1 line tall
679 const draw_ctx: vxfw.DrawContext = .{
680 .arena = arena.allocator(),
681 .min = .{},
682 .max = .{ .width = 8, .height = 1 },
683 .cell_size = .{ .width = 10, .height = 20 },
684 };
685 _ = draw_ctx;
686
687 var ctx: vxfw.EventContext = .{
688 .io = io,
689 .alloc = arena.allocator(),
690 .cmds = .empty,
691 };
692
693 // Enough boiler plate...Create the text field
694 var text_field = TextField.init(std.testing.allocator);
695 defer text_field.deinit();
696 text_field.onChange = Foo.onChange;
697 text_field.onSubmit = Foo.onChange;
698 text_field.userdata = &foo;
699
700 const tf_widget = text_field.widget();
701 // Send some key events to the widget
702 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'H', .text = "H" } });
703 // The foo object stores the last text that we saw from an onChange call
704 try std.testing.expectEqualStrings("H", foo.text);
705 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'e', .text = "e" } });
706 try std.testing.expectEqualStrings("He", foo.text);
707 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } });
708 try std.testing.expectEqualStrings("Hel", foo.text);
709 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } });
710 try std.testing.expectEqualStrings("Hell", foo.text);
711 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'o', .text = "o" } });
712 try std.testing.expectEqualStrings("Hello", foo.text);
713
714 // An arrow moves the cursor. The text doesn't change
715 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.left } });
716 try std.testing.expectEqualStrings("Hello", foo.text);
717
718 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } });
719 try std.testing.expectEqualStrings("Hell_o", foo.text);
720}
721
722test "moveBackwardWordwise stops at word boundary" {
723 var input = TextField.init(std.testing.allocator);
724 defer input.deinit();
725 try input.insertSliceAtCursor("hello-world");
726 input.moveBackwardWordwise();
727 try std.testing.expectEqualStrings("hello-", input.buf.firstHalf());
728 try std.testing.expectEqualStrings("world", input.buf.secondHalf());
729 input.moveBackwardWordwise();
730 try std.testing.expectEqualStrings("", input.buf.firstHalf());
731 try std.testing.expectEqualStrings("hello-world", input.buf.secondHalf());
732}
733
734test "moveForwardWordwise stops at end of word" {
735 var input = TextField.init(std.testing.allocator);
736 defer input.deinit();
737 try input.insertSliceAtCursor("hello-world");
738 input.buf.moveGapLeft(input.buf.firstHalf().len);
739 input.moveForwardWordwise();
740 // Stops at end of "hello": "hello|-world"
741 try std.testing.expectEqualStrings("hello", input.buf.firstHalf());
742 try std.testing.expectEqualStrings("-world", input.buf.secondHalf());
743 input.moveForwardWordwise();
744 // Skips "-" then stops at end of "world": "hello-world|"
745 try std.testing.expectEqualStrings("hello-world", input.buf.firstHalf());
746 try std.testing.expectEqualStrings("", input.buf.secondHalf());
747}
748
749test "moveBackwardWordwise with path separators" {
750 var input = TextField.init(std.testing.allocator);
751 defer input.deinit();
752 try input.insertSliceAtCursor("/usr/local/bin");
753 input.moveBackwardWordwise();
754 try std.testing.expectEqualStrings("/usr/local/", input.buf.firstHalf());
755 input.moveBackwardWordwise();
756 try std.testing.expectEqualStrings("/usr/", input.buf.firstHalf());
757 input.moveBackwardWordwise();
758 try std.testing.expectEqualStrings("/", input.buf.firstHalf());
759 input.moveBackwardWordwise();
760 try std.testing.expectEqualStrings("", input.buf.firstHalf());
761}
762
763test "deleteWordBefore with hyphens" {
764 var input = TextField.init(std.testing.allocator);
765 defer input.deinit();
766 try input.insertSliceAtCursor("hello-world");
767 input.deleteWordBefore();
768 try std.testing.expectEqualStrings("hello-", input.buf.firstHalf());
769 try std.testing.expectEqualStrings("", input.buf.secondHalf());
770 input.deleteWordBefore();
771 try std.testing.expectEqualStrings("", input.buf.firstHalf());
772}
773
774test "deleteWordBeforeWhitespace deletes to whitespace" {
775 var input = TextField.init(std.testing.allocator);
776 defer input.deinit();
777 try input.insertSliceAtCursor("hello-world foo.bar");
778 input.deleteWordBeforeWhitespace();
779 try std.testing.expectEqualStrings("hello-world ", input.buf.firstHalf());
780 input.deleteWordBeforeWhitespace();
781 try std.testing.expectEqualStrings("", input.buf.firstHalf());
782}
783
784test "deleteWordAfter with mixed punctuation" {
785 var input = TextField.init(std.testing.allocator);
786 defer input.deinit();
787 try input.insertSliceAtCursor("foo.bar baz");
788 input.buf.moveGapLeft(input.buf.firstHalf().len);
789 input.deleteWordAfter();
790 try std.testing.expectEqualStrings("", input.buf.firstHalf());
791 try std.testing.expectEqualStrings(".bar baz", input.buf.secondHalf());
792 input.deleteWordAfter();
793 try std.testing.expectEqualStrings("", input.buf.firstHalf());
794 try std.testing.expectEqualStrings(" baz", input.buf.secondHalf());
795}
796
797test "moveForwardWordwise with dots" {
798 var input = TextField.init(std.testing.allocator);
799 defer input.deinit();
800 try input.insertSliceAtCursor("foo.bar.baz");
801 input.buf.moveGapLeft(input.buf.firstHalf().len);
802 input.moveForwardWordwise();
803 try std.testing.expectEqualStrings("foo", input.buf.firstHalf());
804 input.moveForwardWordwise();
805 try std.testing.expectEqualStrings("foo.bar", input.buf.firstHalf());
806 input.moveForwardWordwise();
807 try std.testing.expectEqualStrings("foo.bar.baz", input.buf.firstHalf());
808}
809
810test "word motion with underscores treats them as word chars" {
811 var input = TextField.init(std.testing.allocator);
812 defer input.deinit();
813 try input.insertSliceAtCursor("hello_world-test");
814 input.moveBackwardWordwise();
815 try std.testing.expectEqualStrings("hello_world-", input.buf.firstHalf());
816 input.moveBackwardWordwise();
817 try std.testing.expectEqualStrings("", input.buf.firstHalf());
818}
819
820test "word motion with non-ASCII text" {
821 var input = TextField.init(std.testing.allocator);
822 defer input.deinit();
823 try input.insertSliceAtCursor("café-latte");
824 input.moveBackwardWordwise();
825 try std.testing.expectEqualStrings("café-", input.buf.firstHalf());
826 try std.testing.expectEqualStrings("latte", input.buf.secondHalf());
827 input.moveBackwardWordwise();
828 try std.testing.expectEqualStrings("", input.buf.firstHalf());
829
830 input.moveForwardWordwise();
831 try std.testing.expectEqualStrings("caf\xc3\xa9", input.buf.firstHalf());
832 try std.testing.expectEqualStrings("-latte", input.buf.secondHalf());
833}
834
835test "non-ASCII punctuation acts as a separator" {
836 var input = TextField.init(std.testing.allocator);
837 defer input.deinit();
838 try input.insertSliceAtCursor("hello\u{2014}world");
839 input.moveBackwardWordwise();
840 try std.testing.expectEqualStrings("hello\u{2014}", input.buf.firstHalf());
841 try std.testing.expectEqualStrings("world", input.buf.secondHalf());
842
843 input.buf.moveGapLeft(input.buf.firstHalf().len);
844 input.moveForwardWordwise();
845 try std.testing.expectEqualStrings("hello", input.buf.firstHalf());
846 try std.testing.expectEqualStrings("\u{2014}world", input.buf.secondHalf());
847}
848
849test "deleteWordBeforeWhitespace handles unicode whitespace" {
850 var input = TextField.init(std.testing.allocator);
851 defer input.deinit();
852 try input.insertSliceAtCursor("hello\u{3000}world");
853 input.deleteWordBeforeWhitespace();
854 try std.testing.expectEqualStrings("hello\u{3000}", input.buf.firstHalf());
855 try std.testing.expectEqualStrings("", input.buf.secondHalf());
856}
857
858test "deleteWordBefore with non-ASCII text" {
859 var input = TextField.init(std.testing.allocator);
860 defer input.deinit();
861 try input.insertSliceAtCursor("über-cool");
862 input.deleteWordBefore();
863 try std.testing.expectEqualStrings("über-", input.buf.firstHalf());
864 input.deleteWordBefore();
865 try std.testing.expectEqualStrings("", input.buf.firstHalf());
866}
867
868test "word motion with spaces" {
869 var input = TextField.init(std.testing.allocator);
870 defer input.deinit();
871 try input.insertSliceAtCursor("hello world");
872 input.moveBackwardWordwise();
873 try std.testing.expectEqualStrings("hello ", input.buf.firstHalf());
874 input.moveBackwardWordwise();
875 try std.testing.expectEqualStrings("", input.buf.firstHalf());
876}
877
878test "refAllDecls" {
879 std.testing.refAllDecls(@This());
880}