this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

at b58ae3a2fa16b3b7f11d2a7297c73a1e839d035b 757 lines 27 kB view raw
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}