diff --git a/src/glide/browser/base/content/motions.mts b/src/glide/browser/base/content/motions.mts index e956a956..c04f3133 100644 --- a/src/glide/browser/base/content/motions.mts +++ b/src/glide/browser/base/content/motions.mts @@ -33,7 +33,7 @@ export interface Editor { /** * An exhaustive list of all currently supported motion operations. */ -export const MOTIONS = ["iw", "h", "j", "k", "l", "d"] as const; +export const MOTIONS = ["iw", "h", "j", "k", "l", "d", "w"] as const; type GlideMotion = (typeof MOTIONS)[number]; export function select_motion( @@ -180,11 +180,29 @@ export function select_motion( }, }; } + case "w": { + if (is_bof(editor)) { + editor.selectionController.characterMove(true, true); + } else { + editor.selectionController.characterMove(false, false); + editor.selectionController.characterMove(true, true); + } + + const starting_cls = text_obj.cls(current_char(editor)); + + forward_word(editor, false, "visual"); + if (selection_has_cls_white_space(editor) || text_obj.cls(current_char(editor)) !== starting_cls) { + editor.selectionController.characterMove(false, true); + } + if (!is_bof(editor, "left") && is_eol(editor)) { + editor.selectionController.characterMove(false, true); + } + break; + } default: throw assert_never(motion, `Unknown motion: ${motion}`); } } - /** * Returns the offset of the caret in the current line. * @@ -538,6 +556,28 @@ function selection_direction( return "backwards"; } +/** + * Returns the currently selected text (single-node selections). + * Falls back to empty string if collapsed or no text node. + */ +export function selection_text(editor: Editor): string { + if (editor.selection.isCollapsed) { + return ""; + } + const content = editor.selection.focusNode?.textContent ?? ""; + const start = Math.min(editor.selection.anchorOffset, editor.selection.focusOffset); + const end = Math.max(editor.selection.anchorOffset, editor.selection.focusOffset); + return content.slice(start, end); +} + +/** + * Whether the current selection contains a space character. + */ +export function selection_has_cls_white_space(editor: Editor): boolean { + const text = selection_text(editor); + return text.includes(" ") || text.includes("\t") || text.includes("\n") || text.includes("\r"); +} + export function preceding_char(editor: Editor): string | null { const content = editor.selection.focusNode?.textContent; if (content == null) { diff --git a/src/glide/browser/base/content/test/motions/browser_words.ts b/src/glide/browser/base/content/test/motions/browser_words.ts index 457cf7fc..025f595d 100644 --- a/src/glide/browser/base/content/test/motions/browser_words.ts +++ b/src/glide/browser/base/content/test/motions/browser_words.ts @@ -100,6 +100,46 @@ add_task(async function test_normal_diw() { }); }); +add_task(async function test_normal_dw() { + await BrowserTestUtils.withNewTab(INPUT_TEST_FILE, async browser => { + const { set_text, test_edit, set_selection } = GlideTestUtils.make_input_test_helpers(browser, { + text_start: 1, + }); + + await set_text("hello world", "dw at start of word deletes word + following space"); + await set_selection(0, "h"); + await test_edit("dw", "world", 0, "w"); + + await set_text("hello world", "dw from inside a word deletes to next word boundary (keeps preceding chars)"); + await set_selection(2, "l"); + await test_edit("dw", "heworld", 2, "w"); + + await set_text("hello world", "dw at start deletes word + all following spaces"); + await set_selection(0, "h"); + await test_edit("dw", "world", 0, "w"); + + await set_text("hello, world", "dw stops at punctuation (keeps punctuation and following space)"); + await set_selection(0, "h"); + await test_edit("dw", ", world", 0, ","); + + await set_text("hello\nworld", "dw treats newline as whitespace and deletes it with the word"); + await set_selection(0, "h"); + await test_edit("dw", "\nworld", -1, ""); + + await set_text("hello?.world", "dw on punctuation run deletes punctuation up to next word"); + await set_selection(5, "?"); + await test_edit("dw", "helloworld", 5, "w"); + + await set_text("h j k", "dw deletes a single-letter word + following space"); + await set_selection(2, "j"); + await test_edit("dw", "h k", 2, "k"); + + await set_text("\nworld", "dw at start deletes leading newline (treats newline as whitespace)"); + await set_selection(0, "\n"); + await test_edit("dw", "world", 0, "w"); + }); +}); + add_task(async function test_normal_w() { await BrowserTestUtils.withNewTab(INPUT_TEST_FILE, async browser => { const { set_text, test_motion, set_selection } = GlideTestUtils.make_input_test_helpers(browser, { text_start: 1 });