From 8e7721b389924dddb6a9f2a51337f811e95ab283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 4 Apr 2026 00:23:52 -0700 Subject: [PATCH 1/7] Dramatically expanded motion operations i had been waiting for these --- src/glide/browser/base/content/motions.mts | 262 ++++++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/src/glide/browser/base/content/motions.mts b/src/glide/browser/base/content/motions.mts index e4ce6fa0..013d38ac 100644 --- a/src/glide/browser/base/content/motions.mts +++ b/src/glide/browser/base/content/motions.mts @@ -33,7 +33,31 @@ 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 = [ + // text objects + "iw", "aw", + + // quote text objects + 'i"', 'a"', "i'", "a'", "i`", "a`", + + // bracket text objects + "i(", "a(", "ib", "ab", + "i[", "a[", + "i{", "a{", "iB", "aB", + + // basic character motions + "h", "j", "k", "l", + + // word motions + "w", "W", "b", "B", "e", + + // line position motions + "0", "^", "$", + + // line operation + "d", +] as const; + type GlideMotion = (typeof MOTIONS)[number]; export function select_motion( @@ -180,6 +204,137 @@ export function select_motion( }, }; } + case "aw": { + start_of_word(editor); + end_of_word(editor, { extend: true, inclusive: true }); + // extend selection over any trailing whitespace (aw includes a separating space) + while ( + text_obj.cls(next_char(editor)) === text_obj.CLS_WHITESPACE + && !is_eol(editor) + && !is_eof(editor) + ) { + forward_char(editor, true); + } + break; + } + case "w": + case "W": { + const before = editor.selection.focusOffset; + forward_word(editor, motion === "W", undefined); + const after = editor.selection.focusOffset; + + if (after === before) return; + + // exclusive forward + select_absolute_range(editor, before - 1, after - 1); + break; + } + case "b": + case "B": { + const before = editor.selection.focusOffset; + back_word(editor, motion === "B"); + const after = editor.selection.focusOffset; + + if (after === before) return; + + // backward exclusive + select_absolute_range(editor, after - 1, before - 1); + break; + } + case "e": { + const before = editor.selection.focusOffset; + end_word(editor, undefined); + const after = editor.selection.focusOffset; + + if (after === before) return; + + // inclusive forward + select_absolute_range(editor, before - 1, after); + break; + } + case "0": { + const before = editor.selection.focusOffset; + beginning_of_line(editor, false); + const after = editor.selection.focusOffset; + + if (after === before) return; + + // backward exclusive + select_absolute_range(editor, after - 1, before - 1); + break; + } + case "^": { + const before = editor.selection.focusOffset; + first_non_whitespace(editor); + const after = editor.selection.focusOffset; + + if (after === before) return; + + if (after > before) { + // forward exclusive (cursor was on leading whitespace) + select_absolute_range(editor, before - 1, after - 1); + } else { + // backward exclusive + select_absolute_range(editor, after - 1, before - 1); + } + break; + } + case "$": { + const before = editor.selection.focusOffset; + end_of_line(editor, false); + const after = editor.selection.focusOffset; + if (after === before) return; + // inclusive forward: from before current char to after last char on line + select_absolute_range(editor, before - 1, after); + break; + } + // quote text objects + case 'i"': + case 'a"': { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_quote_range(text, editor.selection.focusOffset - 1, '"', motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "i'": + case "a'": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_quote_range(text, editor.selection.focusOffset - 1, "'", motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "i`": + case "a`": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_quote_range(text, editor.selection.focusOffset - 1, "`", motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "i(": + case "a(": + case "ib": + case "ab": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_bracket_range(text, editor.selection.focusOffset - 1, "(", ")", motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "i[": + case "a[": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_bracket_range(text, editor.selection.focusOffset - 1, "[", "]", motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "i{": + case "a{": + case "iB": + case "aB": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_bracket_range(text, editor.selection.focusOffset - 1, "{", "}", motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } default: throw assert_never(motion, `Unknown motion: ${motion}`); } @@ -623,3 +778,108 @@ export function is_eof(editor: Editor): boolean { export function is_eol(editor: Editor): boolean { return current_char(editor) === "\n"; } + +// ── Text-object selection helpers ───────────────────────────────────────────── +// These are intentionally unexported; they are implementation details of +// select_motion and are not part of the public motions API. + +/** + * Move the caret to `start` (collapsing the selection), then extend it to + * `end`. Both values are 0-based DOM cursor positions (i.e. the same index + * space as `focusOffset`). + */ +function select_absolute_range(editor: Editor, start: number, end: number): void { + const steps_to_start = editor.selection.focusOffset - start; + for (let i = 0; i < Math.abs(steps_to_start); i++) { + editor.selectionController.characterMove(steps_to_start > 0 ? false : true, false); + } + const steps_to_end = end - start; + for (let i = 0; i < steps_to_end; i++) { + editor.selectionController.characterMove(true, true); + } +} + +/** + * Find the innermost pair of `quote` characters that encloses `offset` on the + * same line. Returns DOM cursor positions (exclusive end) suitable for + * passing directly to `select_absolute_range`. + * + * `offset` is the 0-based vim cursor position (= `focusOffset - 1`). + */ +function find_quote_range( + text: string, + offset: number, + quote: string, + inner: boolean, +): { start: number; end: number } | null { + const line_start = text.lastIndexOf("\n", offset - 1) + 1; + const line_end_idx = text.indexOf("\n", offset); + const line_end = line_end_idx === -1 ? text.length : line_end_idx; + const line = text.slice(line_start, line_end); + const pos_in_line = offset - line_start; + + const positions: number[] = []; + for (let i = 0; i < line.length; i++) { + if (line[i] === quote) positions.push(i); + } + + // Pair quotes in order: 0-1, 2-3, 4-5, … + for (let i = 0; i < positions.length - 1; i += 2) { + const qs = positions[i]!; + const qe = positions[i + 1]!; + if (qs <= pos_in_line && pos_in_line <= qe) { + return inner + ? { start: line_start + qs + 1, end: line_start + qe } + : { start: line_start + qs, end: line_start + qe + 1 }; + } + } + + return null; +} + +/** + * Find the innermost matching bracket pair (`open`/`close`) that encloses + * `offset`. Returns DOM cursor positions (exclusive end) suitable for passing + * directly to `select_absolute_range`. + * + * `offset` is the 0-based vim cursor position (= `focusOffset - 1`). + */ +function find_bracket_range( + text: string, + offset: number, + open: string, + close: string, + inner: boolean, +): { start: number; end: number } | null { + // Search backward for the matching open bracket + let depth = 0; + let start = -1; + for (let i = offset; i >= 0; i--) { + if (text[i] === close && i !== offset) depth++; + else if (text[i] === open) { + if (depth === 0) { + start = i; + break; + } + depth--; + } + } + if (start === -1) return null; + + // Search forward for the matching close bracket + depth = 0; + let end = -1; + for (let i = start + 1; i < text.length; i++) { + if (text[i] === open) depth++; + else if (text[i] === close) { + if (depth === 0) { + end = i; + break; + } + depth--; + } + } + if (end === -1) return null; + + return inner ? { start: start + 1, end } : { start, end: end + 1 }; +} From 615e3c0cd41d9e8c7d9d0940dba990148cd6f4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 4 Apr 2026 10:33:23 -0700 Subject: [PATCH 2/7] sort of kind of refactor of the main handler to support the more advanced motions, combining with count, etc. --- .../browser/actors/GlideHandlerChild.sys.mts | 268 ++++++++++-------- 1 file changed, 155 insertions(+), 113 deletions(-) diff --git a/src/glide/browser/actors/GlideHandlerChild.sys.mts b/src/glide/browser/actors/GlideHandlerChild.sys.mts index 16924517..4bc93b83 100644 --- a/src/glide/browser/actors/GlideHandlerChild.sys.mts +++ b/src/glide/browser/actors/GlideHandlerChild.sys.mts @@ -325,6 +325,7 @@ export class GlideHandlerChild extends JSWindowActorChild< case "Glide::Move": { const doc_shell = assert_present(this.docShell); + const count = message.data.count ?? 1; const editor = this.#get_editor(this.#get_active_element()); if (editor) { @@ -332,13 +333,17 @@ export class GlideHandlerChild extends JSWindowActorChild< // if we have an editor, sending the following commands should always work switch (message.data.direction) { case "left": - return doc_shell.doCommand("cmd_moveLeft"); + for (let i = 0; i < count; i++) doc_shell.doCommand("cmd_moveLeft"); + return; case "right": - return doc_shell.doCommand("cmd_moveRight"); + for (let i = 0; i < count; i++) doc_shell.doCommand("cmd_moveRight"); + return; case "up": - return doc_shell.doCommand("cmd_moveUp"); + for (let i = 0; i < count; i++) doc_shell.doCommand("cmd_moveUp"); + return; case "down": - return doc_shell.doCommand("cmd_moveDown"); + for (let i = 0; i < count; i++) doc_shell.doCommand("cmd_moveDown"); + return; case "endline": return doc_shell.doCommand("cmd_endLine"); default: @@ -355,13 +360,13 @@ export class GlideHandlerChild extends JSWindowActorChild< switch (message.data.direction) { case "left": - return DOM.scroll(window, { type: "pixel", x: -delta }); + return DOM.scroll(window, { type: "pixel", x: -delta * count }); case "right": - return DOM.scroll(window, { type: "pixel", x: delta }); + return DOM.scroll(window, { type: "pixel", x: delta * count }); case "up": - return DOM.scroll(window, { type: "pixel", y: -delta }); + return DOM.scroll(window, { type: "pixel", y: -delta * count }); case "down": - return DOM.scroll(window, { type: "pixel", y: delta }); + return DOM.scroll(window, { type: "pixel", y: delta * count }); case "endline": return doc_shell.doCommand("cmd_endLine"); default: @@ -369,6 +374,37 @@ export class GlideHandlerChild extends JSWindowActorChild< } } + case "Glide::FindChar": { + const { find_type, character, operator, count } = message.data; + const editor = this.#expect_editor(`find-char:${find_type}${character}`); + + const found = motions.select_find_char(editor, find_type, character, count ?? 1); + if (!found || editor.selection.isCollapsed) { + this.#change_mode("normal"); + break; + } + + switch (operator) { + case "d": { + motions.delete_selection(editor, true); + break; + } + case "c": { + motions.delete_selection(editor, false); + this.#change_mode("insert"); + return; + } + case "r": { + throw new Error("The `r` operator cannot be executed with find-char"); + } + default: + throw assert_never(operator); + } + + this.#change_mode("normal"); + break; + } + case "Glide::SelectionCollapse": { const editor = this.#expect_editor("selection_collapse"); editor.selection.collapseToEnd(); @@ -469,19 +505,22 @@ export class GlideHandlerChild extends JSWindowActorChild< const sequence = props.sequence.join(""); const editor = this.#expect_editor(`${operator}${sequence}`); + const count = props.count ?? 1; switch (operator) { case "d": { - const result = motions.select_motion(editor, sequence as any, this.state?.mode ?? "normal", operator); + for (let i = 0; i < count; i++) { + const result = motions.select_motion(editor, sequence as any, this.state?.mode ?? "normal", operator); - // if the motion didn't actually select anything, then there's - // nothing for us to delete - if (!editor.selection.isCollapsed) { - motions.delete_selection(editor, true); - } + // if the motion didn't actually select anything, then there's + // nothing for us to delete + if (!editor.selection.isCollapsed) { + motions.delete_selection(editor, true); + } - if (result?.fixup_deletion) { - result.fixup_deletion(); + if (result?.fixup_deletion) { + result.fixup_deletion(); + } } this.#record_repeatable_command({ ...props, operator }); @@ -489,8 +528,10 @@ export class GlideHandlerChild extends JSWindowActorChild< break; } case "c": { - motions.select_motion(editor, sequence as any, this.state?.mode ?? "normal", operator); - motions.delete_selection(editor, false); + for (let i = 0; i < count; i++) { + motions.select_motion(editor, sequence as any, this.state?.mode ?? "normal", operator); + motions.delete_selection(editor, false); + } this.#record_repeatable_command({ ...props, operator }); this.#change_mode("insert"); @@ -520,137 +561,138 @@ export class GlideHandlerChild extends JSWindowActorChild< } const editor = this.#expect_editor(keyseq); + const count = props.count ?? 1; + + // Mode-changing and one-shot operations run once regardless of count. switch (keyseq) { - case "w": { - motions.forward_word(editor, /* bigword */ false, this.state?.mode); - break; - } - case "W": { - motions.forward_word(editor, /* bigword */ true, this.state?.mode); - break; - } - case "e": { - motions.end_word(editor, this.state?.mode); - break; - } - case "b": { - motions.back_word(editor, false); - break; - } - case "B": { - motions.back_word(editor, true); - break; - } - case "{": { - motions.back_para(editor); - break; - } - case "}": { - motions.next_para(editor); - break; - } case "I": { motions.first_non_whitespace(editor, false); motions.back_char(editor, false); this.#change_mode("insert"); break; } - case "0": { - motions.beginning_of_line(editor, false); - break; - } - case "^": { - motions.first_non_whitespace(editor, false); - break; - } - case "$": { - motions.end_of_line(editor, false); - break; - } case "s": { // caret is on the first line and it's empty - if (motions.is_bof(editor) && motions.next_char(editor) === "\n") { - return; - } + if (motions.is_bof(editor) && motions.next_char(editor) === "\n") return; // `foo █ar baz` -> `foo█ar baz` editor.deleteSelection(/* action */ editor.ePrevious!, /* stripWrappers */ editor.eStrip!); this.#change_mode("insert"); break; } - case "vh": { - if (editor.selection.isCollapsed) { - motions.back_char(editor, true); - } - motions.back_char(editor, true); - break; - } - case "vl": { - if (editor.selection.isCollapsed) { - editor.selectionController.characterMove(false, false); - editor.selectionController.characterMove(true, true); - } - - motions.forward_char(editor, true); - break; - } case "vd": { // `foo ██r` -> `foo |r` editor.deleteSelection(editor.ePrevious!, editor.eStrip!); // `foo |r` -> `foo r|` motions.forward_char(editor, false); - this.#change_mode("normal"); break; } case "vc": { // `foo ██r` -> `foo |r` editor.deleteSelection(editor.ePrevious!, editor.eStrip!); - this.#change_mode("insert"); break; } - case "x": { - if ( - // caret is on the first line and it's empty - (motions.is_bof(editor) && motions.next_char(editor) === "\n") - // we don't want to delete newlines - || motions.current_char(editor) === "\n" - ) { - return; - } - - // `foo █ar baz` -> `foo█ar baz` - editor.deleteSelection(/* action */ editor.ePrevious!, /* stripWrappers */ editor.eStrip!); - - if (motions.next_char(editor) !== "\n") { - // `foo█ar baz` -> `foo █r baz` - editor.selectionController.characterMove(/* forward */ true, /* extend */ false); - } - break; - } - case "X": { - if ( - // caret is on the first line and it's empty - (motions.is_bof(editor) && motions.next_char(editor) === "\n") - // we don't want to delete newlines - || motions.current_char(editor) === "\n" - ) { - return; - } - - // `foo █ar baz` -> `foo█ar baz` - editor.deleteSelection(/* action */ editor.ePrevious!, /* stripWrappers */ editor.eStrip!); - break; - } case "o": { editor.selectionController.intraLineMove(/* forward */ true, /* extend */ false); editor.insertLineBreak(); - this.#change_mode("insert"); break; } + // Position-only motions: repeat count times + case "w": + case "W": + case "e": + case "b": + case "B": + case "{": + case "}": + case "0": + case "^": + case "$": + case "vh": + case "vl": + case "x": + case "X": { + for (let i = 0; i < count; i++) { + switch (keyseq) { + case "w": + motions.forward_word(editor, /* bigword */ false, this.state?.mode); + break; + case "W": + motions.forward_word(editor, /* bigword */ true, this.state?.mode); + break; + case "e": + motions.end_word(editor, this.state?.mode); + break; + case "b": + motions.back_word(editor, false); + break; + case "B": + motions.back_word(editor, true); + break; + case "{": + motions.back_para(editor); + break; + case "}": + motions.next_para(editor); + break; + case "0": + motions.beginning_of_line(editor, false); + break; + case "^": + motions.first_non_whitespace(editor, false); + break; + case "$": + motions.end_of_line(editor, false); + break; + case "vh": { + if (editor.selection.isCollapsed) motions.back_char(editor, true); + motions.back_char(editor, true); + break; + } + case "vl": { + if (editor.selection.isCollapsed) { + editor.selectionController.characterMove(false, false); + editor.selectionController.characterMove(true, true); + } + motions.forward_char(editor, true); + break; + } + case "x": { + if ( + // caret is on the first line and it's empty + (motions.is_bof(editor) && motions.next_char(editor) === "\n") + // we don't want to delete newlines + || motions.current_char(editor) === "\n" + ) break; + + // `foo █ar baz` -> `foo█ar baz` + editor.deleteSelection(/* action */ editor.ePrevious!, /* stripWrappers */ editor.eStrip!); + if (motions.next_char(editor) !== "\n") { + // `foo█ar baz` -> `foo █r baz` + editor.selectionController.characterMove(/* forward */ true, /* extend */ false); + } + break; + } + case "X": { + if ( + // caret is on the first line and it's empty + (motions.is_bof(editor) && motions.next_char(editor) === "\n") + // we don't want to delete newlines + || motions.current_char(editor) === "\n" + ) break; + + // `foo █ar baz` -> `foo█ar baz` + editor.deleteSelection(/* action */ editor.ePrevious!, /* stripWrappers */ editor.eStrip!); + break; + } + } + } + break; + } default: throw assert_never(keyseq); } From 657a9d38c5767ba75eade41f1aec90218d9df5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 4 Apr 2026 10:33:31 -0700 Subject: [PATCH 3/7] linking for find and till --- .../browser/actors/GlideHandlerParent.sys.mts | 7 +++ .../base/content/browser-excmds-registry.mts | 13 ++++ .../browser/base/content/browser-excmds.mts | 61 ++++++++++++++----- .../browser/base/content/plugins/keymaps.mts | 6 ++ 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/glide/browser/actors/GlideHandlerParent.sys.mts b/src/glide/browser/actors/GlideHandlerParent.sys.mts index 56bacd37..76e7a7b3 100644 --- a/src/glide/browser/actors/GlideHandlerParent.sys.mts +++ b/src/glide/browser/actors/GlideHandlerParent.sys.mts @@ -30,6 +30,13 @@ export interface ParentMessages { args: string; operator: GlideOperator | null; sequence: string[]; + count: number; + }; + "Glide::FindChar": { + find_type: "f" | "F" | "t" | "T"; + character: string; + operator: GlideOperator; + count: number; }; "Glide::KeyMappingExecution": { sequence: string[]; diff --git a/src/glide/browser/base/content/browser-excmds-registry.mts b/src/glide/browser/base/content/browser-excmds-registry.mts index 88089170..cf43c433 100644 --- a/src/glide/browser/base/content/browser-excmds-registry.mts +++ b/src/glide/browser/base/content/browser-excmds-registry.mts @@ -425,6 +425,19 @@ export const GLIDE_EXCOMMANDS = [ // can be repeated and register it in the history. repeatable: false, }, + { + name: "execute_find", + description: "Await the next key press and execute a find-char motion (f/F/t/T) with the pending operator.", + content: false, + repeatable: false, + args_schema: { + find_type: { + type: { enum: ["f", "F", "t", "T"] }, + required: true, + position: 0, + }, + } as const satisfies ArgumentsSchema, + }, { name: "motion", description: "Execute a given motion (internal)", diff --git a/src/glide/browser/base/content/browser-excmds.mts b/src/glide/browser/base/content/browser-excmds.mts index 1a4166f3..0fe8d1fe 100644 --- a/src/glide/browser/base/content/browser-excmds.mts +++ b/src/glide/browser/base/content/browser-excmds.mts @@ -599,27 +599,30 @@ class GlideExcmdsClass { args: { direction }, } = this.#parse_command_args(command_meta, command); + const count = GlideBrowser.state.count; + if (GlideBrowser.api.options.get("scroll_implementation") === "legacy") { - GlideBrowser.get_focused_actor().send_async_message("Glide::Move", { direction }); + GlideBrowser.get_focused_actor().send_async_message("Glide::Move", { direction, count }); return; } GlideBrowser.notify_scroll_breaking_change?.(); - switch (direction) { - case "up": - return await GlideBrowser.api.keys.send("", { skip_mappings: true }); - case "left": - return await GlideBrowser.api.keys.send("", { skip_mappings: true }); - case "right": - return await GlideBrowser.api.keys.send("", { skip_mappings: true }); - case "down": - return await GlideBrowser.api.keys.send("", { skip_mappings: true }); - case "endline": - return GlideBrowser.get_focused_actor().send_async_message("Glide::Move", { direction: "endline" }); - default: - throw assert_never(direction); + const key_for_dir = direction === "up" ? "" + : direction === "left" ? "" + : direction === "right" ? "" + : direction === "down" ? "" + : null; + + if (key_for_dir) { + for (let i = 0; i < count; i++) { + await GlideBrowser.api.keys.send(key_for_dir, { skip_mappings: true }); + } + return; } + + // endline has no count semantics + return GlideBrowser.get_focused_actor().send_async_message("Glide::Move", { direction: "endline", count: 1 }); } case "echo": { @@ -970,6 +973,35 @@ class GlideExcmdsClass { break; } + case "execute_find": { + const { + args: { find_type }, + } = this.#parse_command_args(command_meta, command); + + const operator = GlideBrowser.state.operator; + if (!operator) { + throw new Error("execute_find requires a pending operator"); + } + + const event = await GlideBrowser.api.keys.next(); + if (!Keys.is_printable(event.glide_key)) { + // cancelled (e.g. Escape) — reset count and bail + GlideBrowser.state.count = 1; + return; + } + + const count = GlideBrowser.state.count; + GlideBrowser.state.count = 1; + + GlideBrowser.get_focused_actor().send_async_message("Glide::FindChar", { + find_type: find_type as "f" | "F" | "t" | "T", + character: event.key, + operator, + count, + }); + break; + } + default: throw assert_never(command_meta, `Unhandled excmd: \`${(command_meta as any).name}\``); } @@ -987,6 +1019,7 @@ class GlideExcmdsClass { args: props.args, operator: props.operator ?? GlideBrowser.state.operator, sequence: props.sequence, + count: GlideBrowser.state.count, }; actor.send_async_message("Glide::ExecuteContentCommand", opts); console.log("sent execute command with", opts); diff --git a/src/glide/browser/base/content/plugins/keymaps.mts b/src/glide/browser/base/content/plugins/keymaps.mts index 92d21ae3..d298023d 100644 --- a/src/glide/browser/base/content/plugins/keymaps.mts +++ b/src/glide/browser/base/content/plugins/keymaps.mts @@ -92,6 +92,12 @@ export function init(sandbox: Sandbox) { glide.keymaps.set("op-pending", motion, "execute_motion"); } + // find-char motions — only available in op-pending (df, dF, dt, dT, cf, cF, ct, cT) + glide.keymaps.set("op-pending", "f", "execute_find f"); + glide.keymaps.set("op-pending", "F", "execute_find F"); + glide.keymaps.set("op-pending", "t", "execute_find t"); + glide.keymaps.set("op-pending", "T", "execute_find T"); + glide.keymaps.set(["normal", "visual"], "w", "motion w"); glide.keymaps.set(["normal", "visual"], "W", "motion W"); glide.keymaps.set("normal", "e", "motion e"); From f08276f9a133488b5cd51f90d70414a1aeda0c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 4 Apr 2026 10:33:50 -0700 Subject: [PATCH 4/7] digit prefixing --- src/glide/browser/base/content/browser.mts | 29 +++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/glide/browser/base/content/browser.mts b/src/glide/browser/base/content/browser.mts index 5ceadfb2..5366cb82 100644 --- a/src/glide/browser/base/content/browser.mts +++ b/src/glide/browser/base/content/browser.mts @@ -45,6 +45,7 @@ declare var document: Document & { documentElement: HTMLElement }; export interface State { mode: GlideMode; operator: GlideOperator | null; + count: number; } export interface StateChangeMeta { /* By default, when exiting visual mode we collapse the selection but for certain cases, e.g. @@ -55,7 +56,7 @@ type ResolvedAddonCache = { addons: Record; }; -const _defaultState: State = { mode: "normal", operator: null }; +const _defaultState: State = { mode: "normal", operator: null, count: 1 }; export type StateChangeListener = ( new_state: State, @@ -64,6 +65,7 @@ export type StateChangeListener = ( ) => void; const DEBOUNCE_MODE_ANIMATION_FRAMES = 3; +const MAX_VIM_COUNT = 999; class GlideBrowserClass { state_listeners = new Set(); @@ -1413,6 +1415,11 @@ class GlideBrowserClass { this.state.mode = new_mode; this.state.operator = props?.operator ?? null; + // Reset count whenever we leave op-pending (i.e. a full command completed). + if (new_mode !== "op-pending") { + this.state.count = 1; + } + Services.prefs.setIntPref("glide.caret.style", this.#mode_to_style_enum(new_mode)); for (const listener of this.state_listeners) { @@ -1628,6 +1635,20 @@ class GlideBrowserClass { const mode = this.state.mode; const has_partial = this.key_manager.has_partial_mapping; + + // THE COUNT PREFIX intercept bare digit keys in normal/op-pending mode to + // accumulate... like (`3`, `3d`, `3dw`). + const is_digit_prefix = !event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey + && ((event.key >= "1" && event.key <= "9") + || (event.key === "0" && this.state.count > 1)); + if ((mode === "normal" || mode === "op-pending") && is_digit_prefix) { + this.state.count = Math.min( + this.state.count * 10 + parseInt(event.key, 10), + MAX_VIM_COUNT, + ); + this.#prevent_keydown(keyn, event); + return; + } const current_sequence = this.key_manager.current_sequence; const mapping = this.key_manager.handle_key_event(event, mode); if (mapping?.has_children || mapping?.value?.retain_key_display) { @@ -1727,6 +1748,12 @@ class GlideBrowserClass { mode, }); await GlideExcmds.execute(mapping.value.command, { mapping }); + + // Reset count after any command that didn't enter op-pending. When + // entering op-pending the count must survive. + if (this.state.mode !== "op-pending") { + this.state.count = 1; + } } return; From 941047adbcf6489cdbf3687bda521a96d1db6fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 4 Apr 2026 10:33:59 -0700 Subject: [PATCH 5/7] core logic for the new motions! --- src/glide/browser/base/content/motions.mts | 164 +++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/glide/browser/base/content/motions.mts b/src/glide/browser/base/content/motions.mts index 013d38ac..f4f5f096 100644 --- a/src/glide/browser/base/content/motions.mts +++ b/src/glide/browser/base/content/motions.mts @@ -45,6 +45,12 @@ export const MOTIONS = [ "i[", "a[", "i{", "a{", "iB", "aB", + // angle bracket text objects + "i<", "a<", + + // html tag text objects + "it", "at", + // basic character motions "h", "j", "k", "l", @@ -54,6 +60,9 @@ export const MOTIONS = [ // line position motions "0", "^", "$", + // whole-buffer motions (for operators: dgg, dG, cgg, cG) + "gg", "G", + // line operation "d", ] as const; @@ -335,6 +344,34 @@ export function select_motion( if (range) select_absolute_range(editor, range.start, range.end); break; } + case "i<": + case "a<": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_bracket_range(text, editor.selection.focusOffset - 1, "<", ">", motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "it": + case "at": { + const text = editor.selection.focusNode?.textContent ?? ""; + const range = find_tag_range(text, editor.selection.focusOffset - 1, motion[0] === "i"); + if (range) select_absolute_range(editor, range.start, range.end); + break; + } + case "gg": { + // Extend selection backward to beginning of buffer. + while (!is_bof(editor, "current")) { + editor.selectionController.characterMove(false, true); + } + break; + } + case "G": { + // Extend selection forward to end of buffer. + while (!is_eof(editor)) { + editor.selectionController.characterMove(true, true); + } + break; + } default: throw assert_never(motion, `Unknown motion: ${motion}`); } @@ -883,3 +920,130 @@ function find_bracket_range( return inner ? { start: start + 1, end } : { start, end: end + 1 }; } + +/** + * Find the innermost HTML tag pair that encloses `offset`. + * + * `offset` is the 0-based vim cursor position (`focusOffset - 1`). + * + * Returns DOM cursor positions (exclusive end) for use with + * `select_absolute_range`. `inner` selects the content between the tags; + * `outer` (`!inner`) selects the entire `` span. + */ +function find_tag_range( + text: string, + offset: number, + inner: boolean, +): { start: number; end: number } | null { + // Search backward for an opening tag `= 0; i--) { + if (text[i] === "<" && text[i + 1] !== "/") { + open_start = i; + break; + } + } + + if (open_start === -1) return null; + + // Find the end of the opening tag (the `>`). + const open_end = text.indexOf(">", open_start); + if (open_end === -1) return null; + + // Extract the tag name (ASCII alphanumeric / hyphen / underscore only). + let name_end = open_start + 1; + while (name_end < text.length && /[\w-]/.test(text[name_end]!)) name_end++; + const tag_name = text.slice(open_start + 1, name_end); + + if (!tag_name) return null; + + // Find the matching closing tag. + const close_tag = ``; + const close_start = text.indexOf(close_tag, open_end); + + if (close_start === -1) return null; + + // Make sure the cursor is actually inside this element. + if (offset < open_start || offset > close_start + close_tag.length - 1) return null; + + return inner + ? { start: open_end + 1, end: close_start } + : { start: open_start, end: close_start + close_tag.length }; +} + +/** + * Select text between the current cursor position and the nth occurrence of + * `character` in the given direction, in preparation for a pending operator. + * + * - `f` — forward to char (inclusive) + * - `F` — backward to char (inclusive) + * - `t` — forward until char (exclusive) + * - `T` — backward until char (exclusive) + * + * The search is restricted to the current line (no newline crossing). + * + * Returns `true` when the character was found and a selection was made, + * `false` when the search failed (no change to the editor). + */ +export function select_find_char( + editor: Editor, + find_type: "f" | "F" | "t" | "T", + character: string, + count: number, +): boolean { + const text = editor.selection.focusNode?.textContent ?? ""; + + // `focus` is the DOM offset (1-based). The vim cursor is ON text[focus-1]. + const focus = editor.selection.focusOffset; + + const forward = find_type === "f" || find_type === "t"; + + // Restrict search to the current line. + const line_start = text.lastIndexOf("\n", focus - 2) + 1; // first char of line (0-based) + const line_end_raw = text.indexOf("\n", focus); + const line_end = line_end_raw === -1 ? text.length : line_end_raw; + + let found_index = -1; + let occurrences = 0; + + if (forward) { + // Start searching from the character *after* the vim cursor . + for (let i = focus; i < line_end; i++) { + if (text[i] === character) { + occurrences++; + if (occurrences === count) { + found_index = i; + break; + } + } + } + } else { + // Start searching from the character *before* the vim cursor . + for (let i = focus - 2; i >= line_start; i--) { + if (text[i] === character) { + occurrences++; + if (occurrences === count) { + found_index = i; + break; + } + } + } + } + + if (found_index === -1) return false; + + // Collapse selection to the vim cursor position (DOM offset: focus-1). + back_char(editor, false); + + if (forward) { + const target_end = find_type === "f" ? found_index + 1 : found_index; + const steps = target_end - (focus - 1); + for (let i = 0; i < steps; i++) forward_char(editor, true); + } else { + const target_start = find_type === "F" ? found_index : found_index + 1; + const steps = (focus - 1) - target_start; + for (let i = 0; i < steps; i++) back_char(editor, true); + } + + return true; +} From a5bac2f09a85411f9fab23357a9beb803454bb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 5 Apr 2026 12:32:00 -0400 Subject: [PATCH 6/7] Fixes for lints why do i never see these --- src/glide/browser/actors/GlideHandlerChild.sys.mts | 8 ++++---- src/glide/browser/actors/GlideHandlerParent.sys.mts | 2 +- src/glide/browser/base/content/browser.mts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/glide/browser/actors/GlideHandlerChild.sys.mts b/src/glide/browser/actors/GlideHandlerChild.sys.mts index 4bc93b83..2380ac50 100644 --- a/src/glide/browser/actors/GlideHandlerChild.sys.mts +++ b/src/glide/browser/actors/GlideHandlerChild.sys.mts @@ -871,12 +871,12 @@ export class GlideHandlerChild extends JSWindowActorChild< ) => Promise = this.sendQuery; #change_mode(mode: GlideMode, force: boolean = true): void { - this.state ??= { mode, operator: null }; - this.state.mode = mode; - this.state.operator = null; + this.state ??= { mode, operator: null, count: 0 }; + this.state!.mode = mode; + this.state!.operator = null; this.send_async_message("Glide::ChangeMode", { mode, force }); this._log.debug("new mode", this.state?.mode ?? "unset"); - } +} #record_repeatable_command( props: ChildMessages["Glide::RecordRepeatableCommand"], diff --git a/src/glide/browser/actors/GlideHandlerParent.sys.mts b/src/glide/browser/actors/GlideHandlerParent.sys.mts index 76e7a7b3..1bd52d9e 100644 --- a/src/glide/browser/actors/GlideHandlerParent.sys.mts +++ b/src/glide/browser/actors/GlideHandlerParent.sys.mts @@ -66,7 +66,7 @@ export interface ParentMessages { | GlideFunctionIPC<(target: HTMLElement) => Promise> | null; }; - "Glide::Move": { direction: "left" | "right" | "up" | "down" | "endline" }; + "Glide::Move": { direction: "left" | "right" | "up" | "down" | "endline"; count?: number }; "Glide::Scroll": { to: "half_page_up" | "half_page_down" | "page_up" | "page_down" | "top" | "bottom" }; "Glide::Debug": null; } diff --git a/src/glide/browser/base/content/browser.mts b/src/glide/browser/base/content/browser.mts index 5366cb82..8d932738 100644 --- a/src/glide/browser/base/content/browser.mts +++ b/src/glide/browser/base/content/browser.mts @@ -257,7 +257,7 @@ class GlideBrowserClass { this.on_startup(async () => { await extension_startup; - await this.#state_change_autocmd(this.state, { mode: null, operator: null }); + await this.#state_change_autocmd(this.state, { mode: null, operator: null, count: 0 }); // Set count to zero here, it shoudl just be ignored }); this.on_startup(async () => { From a5f33e574bdb6b9822f1f296f2ce91719f1c7708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 5 Apr 2026 13:46:10 -0400 Subject: [PATCH 7/7] formatting --- .../browser/actors/GlideHandlerChild.sys.mts | 2 +- .../browser/base/content/browser-excmds.mts | 12 +++-- src/glide/browser/base/content/browser.mts | 7 +-- src/glide/browser/base/content/motions.mts | 53 ++++++++++++++----- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/glide/browser/actors/GlideHandlerChild.sys.mts b/src/glide/browser/actors/GlideHandlerChild.sys.mts index 2380ac50..61259d5b 100644 --- a/src/glide/browser/actors/GlideHandlerChild.sys.mts +++ b/src/glide/browser/actors/GlideHandlerChild.sys.mts @@ -876,7 +876,7 @@ export class GlideHandlerChild extends JSWindowActorChild< this.state!.operator = null; this.send_async_message("Glide::ChangeMode", { mode, force }); this._log.debug("new mode", this.state?.mode ?? "unset"); -} + } #record_repeatable_command( props: ChildMessages["Glide::RecordRepeatableCommand"], diff --git a/src/glide/browser/base/content/browser-excmds.mts b/src/glide/browser/base/content/browser-excmds.mts index 0fe8d1fe..6c515b7f 100644 --- a/src/glide/browser/base/content/browser-excmds.mts +++ b/src/glide/browser/base/content/browser-excmds.mts @@ -608,10 +608,14 @@ class GlideExcmdsClass { GlideBrowser.notify_scroll_breaking_change?.(); - const key_for_dir = direction === "up" ? "" - : direction === "left" ? "" - : direction === "right" ? "" - : direction === "down" ? "" + const key_for_dir = direction === "up" + ? "" + : direction === "left" + ? "" + : direction === "right" + ? "" + : direction === "down" + ? "" : null; if (key_for_dir) { diff --git a/src/glide/browser/base/content/browser.mts b/src/glide/browser/base/content/browser.mts index 8d932738..4241e211 100644 --- a/src/glide/browser/base/content/browser.mts +++ b/src/glide/browser/base/content/browser.mts @@ -1637,15 +1637,12 @@ class GlideBrowserClass { const has_partial = this.key_manager.has_partial_mapping; // THE COUNT PREFIX intercept bare digit keys in normal/op-pending mode to - // accumulate... like (`3`, `3d`, `3dw`). + // accumulate... like (`3`, `3d`, `3dw`). const is_digit_prefix = !event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && ((event.key >= "1" && event.key <= "9") || (event.key === "0" && this.state.count > 1)); if ((mode === "normal" || mode === "op-pending") && is_digit_prefix) { - this.state.count = Math.min( - this.state.count * 10 + parseInt(event.key, 10), - MAX_VIM_COUNT, - ); + this.state.count = Math.min(this.state.count * 10 + parseInt(event.key, 10), MAX_VIM_COUNT); this.#prevent_keydown(keyn, event); return; } diff --git a/src/glide/browser/base/content/motions.mts b/src/glide/browser/base/content/motions.mts index f4f5f096..1a10263c 100644 --- a/src/glide/browser/base/content/motions.mts +++ b/src/glide/browser/base/content/motions.mts @@ -35,33 +35,58 @@ export interface Editor { */ export const MOTIONS = [ // text objects - "iw", "aw", + "iw", + "aw", // quote text objects - 'i"', 'a"', "i'", "a'", "i`", "a`", + "i\"", + "a\"", + "i'", + "a'", + "i`", + "a`", // bracket text objects - "i(", "a(", "ib", "ab", - "i[", "a[", - "i{", "a{", "iB", "aB", + "i(", + "a(", + "ib", + "ab", + "i[", + "a[", + "i{", + "a{", + "iB", + "aB", // angle bracket text objects - "i<", "a<", + "i<", + "a<", // html tag text objects - "it", "at", + "it", + "at", // basic character motions - "h", "j", "k", "l", + "h", + "j", + "k", + "l", // word motions - "w", "W", "b", "B", "e", + "w", + "W", + "b", + "B", + "e", // line position motions - "0", "^", "$", + "0", + "^", + "$", // whole-buffer motions (for operators: dgg, dG, cgg, cG) - "gg", "G", + "gg", + "G", // line operation "d", @@ -298,10 +323,10 @@ export function select_motion( break; } // quote text objects - case 'i"': - case 'a"': { + case "i\"": + case "a\"": { const text = editor.selection.focusNode?.textContent ?? ""; - const range = find_quote_range(text, editor.selection.focusOffset - 1, '"', motion[0] === "i"); + const range = find_quote_range(text, editor.selection.focusOffset - 1, "\"", motion[0] === "i"); if (range) select_absolute_range(editor, range.start, range.end); break; }