this repo has no description
1const std = @import("std");
2const uucode = @import("uucode");
3const assert = std.debug.assert;
4const Key = @import("../Key.zig");
5const Cell = @import("../Cell.zig");
6const Window = @import("../Window.zig");
7const unicode = @import("../unicode.zig");
8
9const TextInput = @This();
10
11/// The events that this widget handles
12const Event = union(enum) {
13 key_press: Key,
14};
15
16const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };
17
18// Index of our cursor
19buf: Buffer,
20
21/// the number of graphemes to skip when drawing. Used for horizontal scrolling
22draw_offset: u16 = 0,
23/// the column we placed the cursor the last time we drew
24prev_cursor_col: u16 = 0,
25/// the grapheme index of the cursor the last time we drew
26prev_cursor_idx: u16 = 0,
27/// approximate distance from an edge before we scroll
28scroll_offset: u16 = 4,
29
30pub fn init(alloc: std.mem.Allocator) TextInput {
31 return TextInput{
32 .buf = Buffer.init(alloc),
33 };
34}
35
36pub fn deinit(self: *TextInput) void {
37 self.buf.deinit();
38}
39
40pub fn update(self: *TextInput, event: Event) !void {
41 switch (event) {
42 .key_press => |key| {
43 if (key.matches(Key.backspace, .{})) {
44 self.deleteBeforeCursor();
45 } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
46 self.deleteAfterCursor();
47 } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
48 self.cursorLeft();
49 } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) {
50 self.cursorRight();
51 } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) {
52 self.buf.moveGapLeft(self.buf.firstHalf().len);
53 } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) {
54 self.buf.moveGapRight(self.buf.secondHalf().len);
55 } else if (key.matches('k', .{ .ctrl = true })) {
56 self.deleteToEnd();
57 } else if (key.matches('u', .{ .ctrl = true })) {
58 self.deleteToStart();
59 } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) {
60 self.moveBackwardWordwise();
61 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) {
62 self.moveForwardWordwise();
63 } else if (key.matches(Key.backspace, .{ .alt = true })) {
64 self.deleteWordBefore();
65 } else if (key.matches('w', .{ .ctrl = true })) {
66 self.deleteWordBeforeWhitespace();
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 = 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) u16 {
92 var width: u16 = 0;
93 const first_half = self.buf.firstHalf();
94 var first_iter = 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 = 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 = 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) u16 {
124 const first_half = self.buf.firstHalf();
125 var first_iter = unicode.graphemeIterator(first_half);
126 var i: u16 = 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 = unicode.graphemeIterator(first_half);
156 var col: u16 = 0;
157 var i: u16 = 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 = @intCast(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 = 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 = @intCast(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 = 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 = unicode.graphemeIterator(self.buf.secondHalf());
265 const grapheme = iter.next() orelse return;
266 self.buf.growGapRight(grapheme.len);
267}
268
269const DecodedCodepoint = struct {
270 cp: u21,
271 start: usize,
272 len: usize,
273};
274
275fn decodeCodepointAt(bytes: []const u8, start: usize) DecodedCodepoint {
276 const first = bytes[start];
277 const len = std.unicode.utf8ByteSequenceLength(first) catch 1;
278 const capped_len = @min(len, bytes.len - start);
279 const slice = bytes[start .. start + capped_len];
280 const cp = std.unicode.utf8Decode(slice) catch {
281 return .{ .cp = first, .start = start, .len = 1 };
282 };
283 return .{ .cp = cp, .start = start, .len = capped_len };
284}
285
286fn isUtf8ContinuationByte(c: u8) bool {
287 return (c & 0b1100_0000) == 0b1000_0000;
288}
289
290fn decodeCodepointBefore(bytes: []const u8, end: usize) DecodedCodepoint {
291 var start = end - 1;
292 while (start > 0 and isUtf8ContinuationByte(bytes[start])) : (start -= 1) {}
293 const slice = bytes[start..end];
294 const cp = std.unicode.utf8Decode(slice) catch {
295 return .{ .cp = bytes[end - 1], .start = end - 1, .len = 1 };
296 };
297 return .{ .cp = cp, .start = start, .len = end - start };
298}
299
300/// Returns true if the codepoint is a readline-style word constituent.
301fn isWordCodepoint(cp: u21) bool {
302 if (cp == '_') return true;
303 return switch (uucode.get(.general_category, cp)) {
304 .letter_uppercase,
305 .letter_lowercase,
306 .letter_titlecase,
307 .letter_modifier,
308 .letter_other,
309 .number_decimal_digit,
310 .number_letter,
311 .number_other,
312 .mark_nonspacing,
313 .mark_spacing_combining,
314 .mark_enclosing,
315 .punctuation_connector,
316 => true,
317 else => false,
318 };
319}
320
321fn isWhitespaceCodepoint(cp: u21) bool {
322 return switch (cp) {
323 ' ', '\t', '\n', '\r', 0x0b, 0x0c, 0x85 => true,
324 else => switch (uucode.get(.general_category, cp)) {
325 .separator_space,
326 .separator_line,
327 .separator_paragraph,
328 => true,
329 else => false,
330 },
331 };
332}
333
334/// Moves the cursor backward by one word using character-class boundaries.
335/// Skips non-word characters, then skips word characters (matching readline backward-word).
336pub fn moveBackwardWordwise(self: *TextInput) void {
337 const first_half = self.buf.firstHalf();
338 var i: usize = first_half.len;
339 // Skip non-word characters
340 while (i > 0) {
341 const decoded = decodeCodepointBefore(first_half, i);
342 if (isWordCodepoint(decoded.cp)) break;
343 i = decoded.start;
344 }
345 // Skip word characters
346 while (i > 0) {
347 const decoded = decodeCodepointBefore(first_half, i);
348 if (!isWordCodepoint(decoded.cp)) break;
349 i = decoded.start;
350 }
351 self.buf.moveGapLeft(self.buf.cursor - i);
352}
353
354/// Moves the cursor forward by one word using character-class boundaries.
355/// Skips non-word characters, then skips word characters — landing at the end of the next word
356/// (matching readline forward-word).
357pub fn moveForwardWordwise(self: *TextInput) void {
358 const second_half = self.buf.secondHalf();
359 var i: usize = 0;
360 // Skip non-word characters
361 while (i < second_half.len) {
362 const decoded = decodeCodepointAt(second_half, i);
363 if (isWordCodepoint(decoded.cp)) break;
364 i += decoded.len;
365 }
366 // Skip word characters
367 while (i < second_half.len) {
368 const decoded = decodeCodepointAt(second_half, i);
369 if (!isWordCodepoint(decoded.cp)) break;
370 i += decoded.len;
371 }
372 self.buf.moveGapRight(i);
373}
374
375/// Deletes the word before the cursor using character-class boundaries
376/// (matching readline backward-kill-word / Alt+Backspace).
377pub fn deleteWordBefore(self: *TextInput) void {
378 const pre = self.buf.cursor;
379 self.moveBackwardWordwise();
380 self.buf.growGapRight(pre - self.buf.cursor);
381}
382
383/// Deletes the word before the cursor using whitespace boundaries
384/// (matching readline unix-word-rubout / Ctrl+W).
385pub fn deleteWordBeforeWhitespace(self: *TextInput) void {
386 const first_half = self.buf.firstHalf();
387 var i: usize = first_half.len;
388 // Skip trailing whitespace
389 while (i > 0) {
390 const decoded = decodeCodepointBefore(first_half, i);
391 if (!isWhitespaceCodepoint(decoded.cp)) break;
392 i = decoded.start;
393 }
394 // Skip non-whitespace
395 while (i > 0) {
396 const decoded = decodeCodepointBefore(first_half, i);
397 if (isWhitespaceCodepoint(decoded.cp)) break;
398 i = decoded.start;
399 }
400 const to_delete = self.buf.cursor - i;
401 self.buf.moveGapLeft(to_delete);
402 self.buf.growGapRight(to_delete);
403}
404
405/// Deletes the word after the cursor using character-class boundaries
406/// (matching readline kill-word / Alt+D).
407pub fn deleteWordAfter(self: *TextInput) void {
408 const second_half = self.buf.secondHalf();
409 var i: usize = 0;
410 // Skip non-word characters
411 while (i < second_half.len) {
412 const decoded = decodeCodepointAt(second_half, i);
413 if (isWordCodepoint(decoded.cp)) break;
414 i += decoded.len;
415 }
416 // Skip word characters
417 while (i < second_half.len) {
418 const decoded = decodeCodepointAt(second_half, i);
419 if (!isWordCodepoint(decoded.cp)) break;
420 i += decoded.len;
421 }
422 self.buf.growGapRight(i);
423}
424
425test "assertion" {
426 const astronaut = "👩🚀";
427 const astronaut_emoji: Key = .{
428 .text = astronaut,
429 .codepoint = try std.unicode.utf8Decode(astronaut[0..4]),
430 };
431 var input = TextInput.init(std.testing.allocator);
432 defer input.deinit();
433 for (0..6) |_| {
434 try input.update(.{ .key_press = astronaut_emoji });
435 }
436}
437
438test "sliceToCursor" {
439 var input = init(std.testing.allocator);
440 defer input.deinit();
441 try input.insertSliceAtCursor("hello, world");
442 input.cursorLeft();
443 input.cursorLeft();
444 input.cursorLeft();
445 var buf: [32]u8 = undefined;
446 try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf));
447 input.cursorRight();
448 try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf));
449}
450
451pub const Buffer = struct {
452 allocator: std.mem.Allocator,
453 buffer: []u8,
454 cursor: usize,
455 gap_size: usize,
456
457 pub fn init(allocator: std.mem.Allocator) Buffer {
458 return .{
459 .allocator = allocator,
460 .buffer = &.{},
461 .cursor = 0,
462 .gap_size = 0,
463 };
464 }
465
466 pub fn deinit(self: *Buffer) void {
467 self.allocator.free(self.buffer);
468 }
469
470 pub fn firstHalf(self: Buffer) []const u8 {
471 return self.buffer[0..self.cursor];
472 }
473
474 pub fn secondHalf(self: Buffer) []const u8 {
475 return self.buffer[self.cursor + self.gap_size ..];
476 }
477
478 pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void {
479 // Always grow by 512 bytes
480 const new_size = self.buffer.len + n + 512;
481 // Allocate the new memory
482 const new_memory = try self.allocator.alloc(u8, new_size);
483 // Copy the first half
484 @memcpy(new_memory[0..self.cursor], self.firstHalf());
485 // Copy the second half
486 const second_half = self.secondHalf();
487 @memcpy(new_memory[new_size - second_half.len ..], second_half);
488 self.allocator.free(self.buffer);
489 self.buffer = new_memory;
490 self.gap_size = new_size - second_half.len - self.cursor;
491 }
492
493 pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void {
494 if (slice.len == 0) return;
495 if (self.gap_size <= slice.len) try self.grow(slice.len);
496 @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice);
497 self.cursor += slice.len;
498 self.gap_size -= slice.len;
499 }
500
501 /// Move the gap n bytes to the left
502 pub fn moveGapLeft(self: *Buffer, n: usize) void {
503 const new_idx = self.cursor -| n;
504 const dst = self.buffer[new_idx + self.gap_size ..];
505 const src = self.buffer[new_idx..self.cursor];
506 std.mem.copyForwards(u8, dst, src);
507 self.cursor = new_idx;
508 }
509
510 pub fn moveGapRight(self: *Buffer, n: usize) void {
511 const new_idx = self.cursor + n;
512 const dst = self.buffer[self.cursor..];
513 const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size];
514 std.mem.copyForwards(u8, dst, src);
515 self.cursor = new_idx;
516 }
517
518 /// grow the gap by moving the cursor n bytes to the left
519 pub fn growGapLeft(self: *Buffer, n: usize) void {
520 // gap grows by the delta
521 self.gap_size += n;
522 self.cursor -|= n;
523 }
524
525 /// grow the gap by removing n bytes after the cursor
526 pub fn growGapRight(self: *Buffer, n: usize) void {
527 self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor);
528 }
529
530 pub fn clearAndFree(self: *Buffer) void {
531 self.cursor = 0;
532 self.allocator.free(self.buffer);
533 self.buffer = &.{};
534 self.gap_size = 0;
535 }
536
537 pub fn clearRetainingCapacity(self: *Buffer) void {
538 self.cursor = 0;
539 self.gap_size = self.buffer.len;
540 }
541
542 pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 {
543 const first_half = self.firstHalf();
544 const second_half = self.secondHalf();
545 const buf = try self.allocator.alloc(u8, first_half.len + second_half.len);
546 @memcpy(buf[0..first_half.len], first_half);
547 @memcpy(buf[first_half.len..], second_half);
548 self.clearAndFree();
549 return buf;
550 }
551
552 pub fn realLength(self: *const Buffer) usize {
553 return self.firstHalf().len + self.secondHalf().len;
554 }
555};
556
557test "moveBackwardWordwise stops at word boundary" {
558 var input = TextInput.init(std.testing.allocator);
559 defer input.deinit();
560 try input.insertSliceAtCursor("hello-world");
561 // Cursor is at end: "hello-world|"
562 input.moveBackwardWordwise();
563 // Should stop at start of "world": "hello-|world"
564 try std.testing.expectEqualStrings("hello-", input.buf.firstHalf());
565 try std.testing.expectEqualStrings("world", input.buf.secondHalf());
566 input.moveBackwardWordwise();
567 // Should skip "-" and stop at start of "hello": "|hello-world"
568 try std.testing.expectEqualStrings("", input.buf.firstHalf());
569 try std.testing.expectEqualStrings("hello-world", input.buf.secondHalf());
570}
571
572test "moveForwardWordwise stops at end of word" {
573 var input = TextInput.init(std.testing.allocator);
574 defer input.deinit();
575 try input.insertSliceAtCursor("hello-world");
576 // Move cursor to start
577 input.buf.moveGapLeft(input.buf.firstHalf().len);
578 // Cursor at start: "|hello-world"
579 input.moveForwardWordwise();
580 // Should stop at end of "hello": "hello|-world"
581 try std.testing.expectEqualStrings("hello", input.buf.firstHalf());
582 try std.testing.expectEqualStrings("-world", input.buf.secondHalf());
583 input.moveForwardWordwise();
584 // Should skip "-" then stop at end of "world": "hello-world|"
585 try std.testing.expectEqualStrings("hello-world", input.buf.firstHalf());
586 try std.testing.expectEqualStrings("", input.buf.secondHalf());
587}
588
589test "moveBackwardWordwise with path separators" {
590 var input = TextInput.init(std.testing.allocator);
591 defer input.deinit();
592 try input.insertSliceAtCursor("/usr/local/bin");
593 input.moveBackwardWordwise();
594 try std.testing.expectEqualStrings("/usr/local/", input.buf.firstHalf());
595 input.moveBackwardWordwise();
596 try std.testing.expectEqualStrings("/usr/", input.buf.firstHalf());
597 input.moveBackwardWordwise();
598 try std.testing.expectEqualStrings("/", input.buf.firstHalf());
599 input.moveBackwardWordwise();
600 try std.testing.expectEqualStrings("", input.buf.firstHalf());
601}
602
603test "moveForwardWordwise with dots" {
604 var input = TextInput.init(std.testing.allocator);
605 defer input.deinit();
606 try input.insertSliceAtCursor("foo.bar.baz");
607 input.buf.moveGapLeft(input.buf.firstHalf().len);
608 input.moveForwardWordwise();
609 // Stops at end of "foo": "foo|.bar.baz"
610 try std.testing.expectEqualStrings("foo", input.buf.firstHalf());
611 input.moveForwardWordwise();
612 // Skips "." then stops at end of "bar": "foo.bar|.baz"
613 try std.testing.expectEqualStrings("foo.bar", input.buf.firstHalf());
614 input.moveForwardWordwise();
615 // Skips "." then stops at end of "baz": "foo.bar.baz|"
616 try std.testing.expectEqualStrings("foo.bar.baz", input.buf.firstHalf());
617}
618
619test "deleteWordBefore with hyphens" {
620 var input = TextInput.init(std.testing.allocator);
621 defer input.deinit();
622 try input.insertSliceAtCursor("hello-world");
623 input.deleteWordBefore();
624 // Should delete "world" only: "hello-|"
625 try std.testing.expectEqualStrings("hello-", input.buf.firstHalf());
626 try std.testing.expectEqualStrings("", input.buf.secondHalf());
627 input.deleteWordBefore();
628 // Should skip "-" and delete "hello": "|"
629 try std.testing.expectEqualStrings("", input.buf.firstHalf());
630}
631
632test "deleteWordBeforeWhitespace deletes to whitespace" {
633 var input = TextInput.init(std.testing.allocator);
634 defer input.deinit();
635 try input.insertSliceAtCursor("hello-world foo.bar");
636 input.deleteWordBeforeWhitespace();
637 // Should delete "foo.bar" (entire whitespace-delimited word)
638 try std.testing.expectEqualStrings("hello-world ", input.buf.firstHalf());
639 input.deleteWordBeforeWhitespace();
640 // Should delete " hello-world"
641 try std.testing.expectEqualStrings("", input.buf.firstHalf());
642}
643
644test "deleteWordAfter with mixed punctuation" {
645 var input = TextInput.init(std.testing.allocator);
646 defer input.deinit();
647 try input.insertSliceAtCursor("foo.bar baz");
648 input.buf.moveGapLeft(input.buf.firstHalf().len);
649 input.deleteWordAfter();
650 // kill-word: skip non-word (none), skip word "foo" → delete "foo"
651 try std.testing.expectEqualStrings("", input.buf.firstHalf());
652 try std.testing.expectEqualStrings(".bar baz", input.buf.secondHalf());
653 input.deleteWordAfter();
654 // kill-word: skip "." (non-word), skip "bar" (word) → delete ".bar"
655 try std.testing.expectEqualStrings("", input.buf.firstHalf());
656 try std.testing.expectEqualStrings(" baz", input.buf.secondHalf());
657}
658
659test "word motion with underscores treats them as word chars" {
660 var input = TextInput.init(std.testing.allocator);
661 defer input.deinit();
662 try input.insertSliceAtCursor("hello_world-test");
663 input.moveBackwardWordwise();
664 // "test" is a word, should stop before it: "hello_world-|test"
665 try std.testing.expectEqualStrings("hello_world-", input.buf.firstHalf());
666 input.moveBackwardWordwise();
667 // "hello_world" is one word (underscore is word char): "|hello_world-test"
668 try std.testing.expectEqualStrings("", input.buf.firstHalf());
669}
670
671test "word motion with non-ASCII text" {
672 var input = TextInput.init(std.testing.allocator);
673 defer input.deinit();
674 // "café-latte" — the é is multi-byte UTF-8, should not split inside it
675 try input.insertSliceAtCursor("café-latte");
676 input.moveBackwardWordwise();
677 try std.testing.expectEqualStrings("café-", input.buf.firstHalf());
678 try std.testing.expectEqualStrings("latte", input.buf.secondHalf());
679 input.moveBackwardWordwise();
680 try std.testing.expectEqualStrings("", input.buf.firstHalf());
681
682 // Forward from start
683 input.moveForwardWordwise();
684 // Should stop at end of "café"
685 try std.testing.expectEqualStrings("caf\xc3\xa9", input.buf.firstHalf());
686 try std.testing.expectEqualStrings("-latte", input.buf.secondHalf());
687}
688
689test "non-ASCII punctuation acts as a separator" {
690 var input = TextInput.init(std.testing.allocator);
691 defer input.deinit();
692 try input.insertSliceAtCursor("hello\u{2014}world");
693 input.moveBackwardWordwise();
694 try std.testing.expectEqualStrings("hello\u{2014}", input.buf.firstHalf());
695 try std.testing.expectEqualStrings("world", input.buf.secondHalf());
696
697 input.buf.moveGapLeft(input.buf.firstHalf().len);
698 input.moveForwardWordwise();
699 try std.testing.expectEqualStrings("hello", input.buf.firstHalf());
700 try std.testing.expectEqualStrings("\u{2014}world", input.buf.secondHalf());
701}
702
703test "deleteWordBeforeWhitespace handles unicode whitespace" {
704 var input = TextInput.init(std.testing.allocator);
705 defer input.deinit();
706 try input.insertSliceAtCursor("hello\u{3000}world");
707 input.deleteWordBeforeWhitespace();
708 try std.testing.expectEqualStrings("hello\u{3000}", input.buf.firstHalf());
709 try std.testing.expectEqualStrings("", input.buf.secondHalf());
710}
711
712test "deleteWordBefore with non-ASCII text" {
713 var input = TextInput.init(std.testing.allocator);
714 defer input.deinit();
715 try input.insertSliceAtCursor("über-cool");
716 input.deleteWordBefore();
717 try std.testing.expectEqualStrings("über-", input.buf.firstHalf());
718 input.deleteWordBefore();
719 try std.testing.expectEqualStrings("", input.buf.firstHalf());
720}
721
722test "word motion with spaces" {
723 var input = TextInput.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 input.moveBackwardWordwise();
729 try std.testing.expectEqualStrings("", input.buf.firstHalf());
730}
731
732test "TextInput.zig: Buffer" {
733 var gap_buf = Buffer.init(std.testing.allocator);
734 defer gap_buf.deinit();
735
736 try gap_buf.insertSliceAtCursor("abc");
737 try std.testing.expectEqualStrings("abc", gap_buf.firstHalf());
738 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
739
740 gap_buf.moveGapLeft(1);
741 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
742 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
743
744 try gap_buf.insertSliceAtCursor(" ");
745 try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf());
746 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
747
748 gap_buf.growGapLeft(1);
749 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
750 try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
751 try std.testing.expectEqual(2, gap_buf.cursor);
752
753 gap_buf.growGapRight(1);
754 try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
755 try std.testing.expectEqualStrings("", gap_buf.secondHalf());
756 try std.testing.expectEqual(2, gap_buf.cursor);
757}