this repo has no description
13
fork

Configure Feed

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

fix: use readline-compatible word boundaries in text input widgets

Word motion (Alt+B/F) and word deletion (Alt+Backspace, Alt+D) now use
character-class boundaries (alnum + underscore) instead of space-only
boundaries, matching GNU readline's backward-word/forward-word behavior.

This means Alt+Backspace on "hello-world" now deletes just "world"
(stopping at the hyphen) rather than the entire "hello-world".

Ctrl+W retains whitespace-delimited behavior (unix-word-rubout) as a
separate code path, and now also recognizes tabs and other whitespace,
not just spaces.

Both TextInput (immediate-mode) and TextField (vxfw) are updated with
identical logic.

authored by

Mike Bannister and committed by
Tim Culverhouse
37b7d895 3d37f043

+308 -38
+132 -19
src/vxfw/TextField.zig
··· 96 96 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 97 97 self.moveForwardWordwise(); 98 98 return ctx.consumeAndRedraw(); 99 - } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) { 99 + } else if (key.matches(Key.backspace, .{ .alt = true })) { 100 100 self.deleteWordBefore(); 101 + return self.checkChanged(ctx); 102 + } else if (key.matches('w', .{ .ctrl = true })) { 103 + self.deleteWordBeforeWhitespace(); 101 104 return self.checkChanged(ctx); 102 105 } else if (key.matches('d', .{ .alt = true })) { 103 106 self.deleteWordAfter(); ··· 343 346 self.buf.growGapRight(grapheme.len); 344 347 } 345 348 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 349 + /// Returns true if the byte is a word constituent (alnum or underscore), 350 + /// matching readline/emacs word character classes. 351 + fn isWordChar(c: u8) bool { 352 + return std.ascii.isAlphanumeric(c) or c == '_'; 353 + } 354 + 355 + /// Moves the cursor backward by one word using character-class boundaries. 356 + /// Skips non-word characters, then skips word characters (matching readline backward-word). 348 357 pub 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); 358 + const first_half = self.buf.firstHalf(); 359 + var i: usize = first_half.len; 360 + // Skip non-word characters 361 + while (i > 0 and !isWordChar(first_half[i - 1])) : (i -= 1) {} 362 + // Skip word characters 363 + while (i > 0 and isWordChar(first_half[i - 1])) : (i -= 1) {} 364 + self.buf.moveGapLeft(self.buf.cursor - i); 355 365 } 356 366 367 + /// Moves the cursor forward by one word using character-class boundaries. 368 + /// Skips word characters, then skips non-word characters (matching readline forward-word). 357 369 pub fn moveForwardWordwise(self: *TextField) void { 358 370 const second_half = self.buf.secondHalf(); 359 371 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); 372 + // Skip word characters 373 + while (i < second_half.len and isWordChar(second_half[i])) : (i += 1) {} 374 + // Skip non-word characters 375 + while (i < second_half.len and !isWordChar(second_half[i])) : (i += 1) {} 376 + self.buf.moveGapRight(i); 363 377 } 364 378 379 + /// Deletes the word before the cursor using character-class boundaries 380 + /// (matching readline backward-kill-word / Alt+Backspace). 365 381 pub fn deleteWordBefore(self: *TextField) void { 366 - // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 367 - // moved 368 382 const pre = self.buf.cursor; 369 383 self.moveBackwardWordwise(); 370 384 self.buf.growGapRight(pre - self.buf.cursor); 371 385 } 372 386 387 + /// Deletes the word before the cursor using whitespace boundaries 388 + /// (matching readline unix-word-rubout / Ctrl+W). 389 + pub fn deleteWordBeforeWhitespace(self: *TextField) void { 390 + const first_half = self.buf.firstHalf(); 391 + var i: usize = first_half.len; 392 + // Skip trailing whitespace 393 + while (i > 0 and std.ascii.isWhitespace(first_half[i - 1])) : (i -= 1) {} 394 + // Skip non-whitespace 395 + while (i > 0 and !std.ascii.isWhitespace(first_half[i - 1])) : (i -= 1) {} 396 + const to_delete = self.buf.cursor - i; 397 + self.buf.moveGapLeft(to_delete); 398 + self.buf.growGapRight(to_delete); 399 + } 400 + 401 + /// Deletes the word after the cursor using character-class boundaries 402 + /// (matching readline kill-word / Alt+D). 373 403 pub fn deleteWordAfter(self: *TextField) void { 374 - // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 375 - // moved 376 404 const second_half = self.buf.secondHalf(); 377 405 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); 406 + // Skip non-word characters 407 + while (i < second_half.len and !isWordChar(second_half[i])) : (i += 1) {} 408 + // Skip word characters 409 + while (i < second_half.len and isWordChar(second_half[i])) : (i += 1) {} 410 + self.buf.growGapRight(i); 381 411 } 382 412 383 413 test "sliceToCursor" { ··· 591 621 592 622 try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } }); 593 623 try std.testing.expectEqualStrings("Hell_o", foo.text); 624 + } 625 + 626 + test "moveBackwardWordwise stops at word boundary" { 627 + var input = TextField.init(std.testing.allocator); 628 + defer input.deinit(); 629 + try input.insertSliceAtCursor("hello-world"); 630 + input.moveBackwardWordwise(); 631 + try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 632 + try std.testing.expectEqualStrings("world", input.buf.secondHalf()); 633 + input.moveBackwardWordwise(); 634 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 635 + try std.testing.expectEqualStrings("hello-world", input.buf.secondHalf()); 636 + } 637 + 638 + test "moveForwardWordwise stops at word boundary" { 639 + var input = TextField.init(std.testing.allocator); 640 + defer input.deinit(); 641 + try input.insertSliceAtCursor("hello-world"); 642 + input.buf.moveGapLeft(input.buf.firstHalf().len); 643 + input.moveForwardWordwise(); 644 + try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 645 + try std.testing.expectEqualStrings("world", input.buf.secondHalf()); 646 + input.moveForwardWordwise(); 647 + try std.testing.expectEqualStrings("hello-world", input.buf.firstHalf()); 648 + try std.testing.expectEqualStrings("", input.buf.secondHalf()); 649 + } 650 + 651 + test "moveBackwardWordwise with path separators" { 652 + var input = TextField.init(std.testing.allocator); 653 + defer input.deinit(); 654 + try input.insertSliceAtCursor("/usr/local/bin"); 655 + input.moveBackwardWordwise(); 656 + try std.testing.expectEqualStrings("/usr/local/", input.buf.firstHalf()); 657 + input.moveBackwardWordwise(); 658 + try std.testing.expectEqualStrings("/usr/", input.buf.firstHalf()); 659 + input.moveBackwardWordwise(); 660 + try std.testing.expectEqualStrings("/", input.buf.firstHalf()); 661 + input.moveBackwardWordwise(); 662 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 663 + } 664 + 665 + test "deleteWordBefore with hyphens" { 666 + var input = TextField.init(std.testing.allocator); 667 + defer input.deinit(); 668 + try input.insertSliceAtCursor("hello-world"); 669 + input.deleteWordBefore(); 670 + try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 671 + try std.testing.expectEqualStrings("", input.buf.secondHalf()); 672 + input.deleteWordBefore(); 673 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 674 + } 675 + 676 + test "deleteWordBeforeWhitespace deletes to whitespace" { 677 + var input = TextField.init(std.testing.allocator); 678 + defer input.deinit(); 679 + try input.insertSliceAtCursor("hello-world foo.bar"); 680 + input.deleteWordBeforeWhitespace(); 681 + try std.testing.expectEqualStrings("hello-world ", input.buf.firstHalf()); 682 + input.deleteWordBeforeWhitespace(); 683 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 684 + } 685 + 686 + test "deleteWordAfter with mixed punctuation" { 687 + var input = TextField.init(std.testing.allocator); 688 + defer input.deinit(); 689 + try input.insertSliceAtCursor("foo.bar baz"); 690 + input.buf.moveGapLeft(input.buf.firstHalf().len); 691 + input.deleteWordAfter(); 692 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 693 + try std.testing.expectEqualStrings(".bar baz", input.buf.secondHalf()); 694 + input.deleteWordAfter(); 695 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 696 + try std.testing.expectEqualStrings(" baz", input.buf.secondHalf()); 697 + } 698 + 699 + test "word motion with underscores treats them as word chars" { 700 + var input = TextField.init(std.testing.allocator); 701 + defer input.deinit(); 702 + try input.insertSliceAtCursor("hello_world-test"); 703 + input.moveBackwardWordwise(); 704 + try std.testing.expectEqualStrings("hello_world-", input.buf.firstHalf()); 705 + input.moveBackwardWordwise(); 706 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 594 707 } 595 708 596 709 test "refAllDecls" {
+176 -19
src/widgets/TextInput.zig
··· 59 59 self.moveBackwardWordwise(); 60 60 } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 61 61 self.moveForwardWordwise(); 62 - } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) { 62 + } else if (key.matches(Key.backspace, .{ .alt = true })) { 63 63 self.deleteWordBefore(); 64 + } else if (key.matches('w', .{ .ctrl = true })) { 65 + self.deleteWordBeforeWhitespace(); 64 66 } else if (key.matches('d', .{ .alt = true })) { 65 67 self.deleteWordAfter(); 66 68 } else if (key.text) |text| { ··· 263 265 self.buf.growGapRight(grapheme.len); 264 266 } 265 267 266 - /// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is 267 - /// positioned just after the next previous space 268 + /// Returns true if the byte is a word constituent (alnum or underscore), 269 + /// matching readline/emacs word character classes. 270 + fn isWordChar(c: u8) bool { 271 + return std.ascii.isAlphanumeric(c) or c == '_'; 272 + } 273 + 274 + /// Moves the cursor backward by one word using character-class boundaries. 275 + /// Skips non-word characters, then skips word characters (matching readline backward-word). 268 276 pub fn moveBackwardWordwise(self: *TextInput) void { 269 - const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " "); 270 - const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last| 271 - last + 1 272 - else 273 - 0; 274 - self.buf.moveGapLeft(self.buf.cursor - idx); 277 + const first_half = self.buf.firstHalf(); 278 + var i: usize = first_half.len; 279 + // Skip non-word characters 280 + while (i > 0 and !isWordChar(first_half[i - 1])) : (i -= 1) {} 281 + // Skip word characters 282 + while (i > 0 and isWordChar(first_half[i - 1])) : (i -= 1) {} 283 + self.buf.moveGapLeft(self.buf.cursor - i); 275 284 } 276 285 286 + /// Moves the cursor forward by one word using character-class boundaries. 287 + /// Skips word characters, then skips non-word characters (matching readline forward-word). 277 288 pub fn moveForwardWordwise(self: *TextInput) void { 278 289 const second_half = self.buf.secondHalf(); 279 290 var i: usize = 0; 280 - while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 281 - const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 282 - self.buf.moveGapRight(idx); 291 + // Skip word characters 292 + while (i < second_half.len and isWordChar(second_half[i])) : (i += 1) {} 293 + // Skip non-word characters 294 + while (i < second_half.len and !isWordChar(second_half[i])) : (i += 1) {} 295 + self.buf.moveGapRight(i); 283 296 } 284 297 298 + /// Deletes the word before the cursor using character-class boundaries 299 + /// (matching readline backward-kill-word / Alt+Backspace). 285 300 pub fn deleteWordBefore(self: *TextInput) void { 286 - // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 287 - // moved 288 301 const pre = self.buf.cursor; 289 302 self.moveBackwardWordwise(); 290 303 self.buf.growGapRight(pre - self.buf.cursor); 291 304 } 292 305 306 + /// Deletes the word before the cursor using whitespace boundaries 307 + /// (matching readline unix-word-rubout / Ctrl+W). 308 + pub fn deleteWordBeforeWhitespace(self: *TextInput) void { 309 + const first_half = self.buf.firstHalf(); 310 + var i: usize = first_half.len; 311 + // Skip trailing whitespace 312 + while (i > 0 and std.ascii.isWhitespace(first_half[i - 1])) : (i -= 1) {} 313 + // Skip non-whitespace 314 + while (i > 0 and !std.ascii.isWhitespace(first_half[i - 1])) : (i -= 1) {} 315 + const to_delete = self.buf.cursor - i; 316 + self.buf.moveGapLeft(to_delete); 317 + self.buf.growGapRight(to_delete); 318 + } 319 + 320 + /// Deletes the word after the cursor using character-class boundaries 321 + /// (matching readline kill-word / Alt+D). 293 322 pub fn deleteWordAfter(self: *TextInput) void { 294 - // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 295 - // moved 296 323 const second_half = self.buf.secondHalf(); 297 324 var i: usize = 0; 298 - while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 299 - const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 300 - self.buf.growGapRight(idx); 325 + // Skip non-word characters 326 + while (i < second_half.len and !isWordChar(second_half[i])) : (i += 1) {} 327 + // Skip word characters 328 + while (i < second_half.len and isWordChar(second_half[i])) : (i += 1) {} 329 + self.buf.growGapRight(i); 301 330 } 302 331 303 332 test "assertion" { ··· 431 460 return self.firstHalf().len + self.secondHalf().len; 432 461 } 433 462 }; 463 + 464 + test "moveBackwardWordwise stops at word boundary" { 465 + var input = TextInput.init(std.testing.allocator); 466 + defer input.deinit(); 467 + try input.insertSliceAtCursor("hello-world"); 468 + // Cursor is at end: "hello-world|" 469 + input.moveBackwardWordwise(); 470 + // Should stop at start of "world": "hello-|world" 471 + try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 472 + try std.testing.expectEqualStrings("world", input.buf.secondHalf()); 473 + input.moveBackwardWordwise(); 474 + // Should skip "-" and stop at start of "hello": "|hello-world" 475 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 476 + try std.testing.expectEqualStrings("hello-world", input.buf.secondHalf()); 477 + } 478 + 479 + test "moveForwardWordwise stops at word boundary" { 480 + var input = TextInput.init(std.testing.allocator); 481 + defer input.deinit(); 482 + try input.insertSliceAtCursor("hello-world"); 483 + // Move cursor to start 484 + input.buf.moveGapLeft(input.buf.firstHalf().len); 485 + // Cursor at start: "|hello-world" 486 + input.moveForwardWordwise(); 487 + // Should skip "hello" then "-": "hello-|world" 488 + try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 489 + try std.testing.expectEqualStrings("world", input.buf.secondHalf()); 490 + input.moveForwardWordwise(); 491 + // Should skip "world" to end: "hello-world|" 492 + try std.testing.expectEqualStrings("hello-world", input.buf.firstHalf()); 493 + try std.testing.expectEqualStrings("", input.buf.secondHalf()); 494 + } 495 + 496 + test "moveBackwardWordwise with path separators" { 497 + var input = TextInput.init(std.testing.allocator); 498 + defer input.deinit(); 499 + try input.insertSliceAtCursor("/usr/local/bin"); 500 + input.moveBackwardWordwise(); 501 + try std.testing.expectEqualStrings("/usr/local/", input.buf.firstHalf()); 502 + input.moveBackwardWordwise(); 503 + try std.testing.expectEqualStrings("/usr/", input.buf.firstHalf()); 504 + input.moveBackwardWordwise(); 505 + try std.testing.expectEqualStrings("/", input.buf.firstHalf()); 506 + input.moveBackwardWordwise(); 507 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 508 + } 509 + 510 + test "moveForwardWordwise with dots" { 511 + var input = TextInput.init(std.testing.allocator); 512 + defer input.deinit(); 513 + try input.insertSliceAtCursor("foo.bar.baz"); 514 + input.buf.moveGapLeft(input.buf.firstHalf().len); 515 + input.moveForwardWordwise(); 516 + try std.testing.expectEqualStrings("foo.", input.buf.firstHalf()); 517 + input.moveForwardWordwise(); 518 + try std.testing.expectEqualStrings("foo.bar.", input.buf.firstHalf()); 519 + input.moveForwardWordwise(); 520 + try std.testing.expectEqualStrings("foo.bar.baz", input.buf.firstHalf()); 521 + } 522 + 523 + test "deleteWordBefore with hyphens" { 524 + var input = TextInput.init(std.testing.allocator); 525 + defer input.deinit(); 526 + try input.insertSliceAtCursor("hello-world"); 527 + input.deleteWordBefore(); 528 + // Should delete "world" only: "hello-|" 529 + try std.testing.expectEqualStrings("hello-", input.buf.firstHalf()); 530 + try std.testing.expectEqualStrings("", input.buf.secondHalf()); 531 + input.deleteWordBefore(); 532 + // Should skip "-" and delete "hello": "|" 533 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 534 + } 535 + 536 + test "deleteWordBeforeWhitespace deletes to whitespace" { 537 + var input = TextInput.init(std.testing.allocator); 538 + defer input.deinit(); 539 + try input.insertSliceAtCursor("hello-world foo.bar"); 540 + input.deleteWordBeforeWhitespace(); 541 + // Should delete "foo.bar" (entire whitespace-delimited word) 542 + try std.testing.expectEqualStrings("hello-world ", input.buf.firstHalf()); 543 + input.deleteWordBeforeWhitespace(); 544 + // Should delete " hello-world" 545 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 546 + } 547 + 548 + test "deleteWordAfter with mixed punctuation" { 549 + var input = TextInput.init(std.testing.allocator); 550 + defer input.deinit(); 551 + try input.insertSliceAtCursor("foo.bar baz"); 552 + input.buf.moveGapLeft(input.buf.firstHalf().len); 553 + // Cursor at start: "|foo.bar baz" 554 + input.deleteWordAfter(); 555 + // Should delete "foo" then ".": ".bar baz" — wait, readline kill-word skips non-word then word 556 + // Actually: skip non-word (none at start), skip word "foo" = delete "foo" 557 + // No: kill-word skips word chars first, then non-word chars? Let me think... 558 + // readline forward-kill-word: delete forward to end of next word 559 + // From "|foo.bar": delete "foo" (word chars), then "." (non-word chars) = "foo." 560 + // Actually no. readline kill-word: skip non-word, then skip word. Same as our deleteWordAfter. 561 + // From "|foo.bar": no non-word to skip, then skip "foo" = delete "foo" leaving ".bar baz" 562 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 563 + try std.testing.expectEqualStrings(".bar baz", input.buf.secondHalf()); 564 + input.deleteWordAfter(); 565 + // From "|.bar baz": skip "." (non-word), then skip "bar" (word) = delete ".bar" 566 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 567 + try std.testing.expectEqualStrings(" baz", input.buf.secondHalf()); 568 + } 569 + 570 + test "word motion with underscores treats them as word chars" { 571 + var input = TextInput.init(std.testing.allocator); 572 + defer input.deinit(); 573 + try input.insertSliceAtCursor("hello_world-test"); 574 + input.moveBackwardWordwise(); 575 + // "test" is a word, should stop before it: "hello_world-|test" 576 + try std.testing.expectEqualStrings("hello_world-", input.buf.firstHalf()); 577 + input.moveBackwardWordwise(); 578 + // "hello_world" is one word (underscore is word char): "|hello_world-test" 579 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 580 + } 581 + 582 + test "word motion with spaces" { 583 + var input = TextInput.init(std.testing.allocator); 584 + defer input.deinit(); 585 + try input.insertSliceAtCursor("hello world"); 586 + input.moveBackwardWordwise(); 587 + try std.testing.expectEqualStrings("hello ", input.buf.firstHalf()); 588 + input.moveBackwardWordwise(); 589 + try std.testing.expectEqualStrings("", input.buf.firstHalf()); 590 + } 434 591 435 592 test "TextInput.zig: Buffer" { 436 593 var gap_buf = Buffer.init(std.testing.allocator);