diff --git a/src/glide/browser/actors/GlideHandlerChild.sys.mts b/src/glide/browser/actors/GlideHandlerChild.sys.mts index 16924517..61259d5b 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); } @@ -829,9 +871,9 @@ 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"); } diff --git a/src/glide/browser/actors/GlideHandlerParent.sys.mts b/src/glide/browser/actors/GlideHandlerParent.sys.mts index 56bacd37..1bd52d9e 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[]; @@ -59,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-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..6c515b7f 100644 --- a/src/glide/browser/base/content/browser-excmds.mts +++ b/src/glide/browser/base/content/browser-excmds.mts @@ -599,27 +599,34 @@ 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 +977,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 +1023,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/browser.mts b/src/glide/browser/base/content/browser.mts index 5ceadfb2..4241e211 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(); @@ -255,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 () => { @@ -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,17 @@ 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 +1745,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; diff --git a/src/glide/browser/base/content/motions.mts b/src/glide/browser/base/content/motions.mts index e4ce6fa0..1a10263c 100644 --- a/src/glide/browser/base/content/motions.mts +++ b/src/glide/browser/base/content/motions.mts @@ -33,7 +33,65 @@ 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", + + // angle bracket text objects + "i<", + "a<", + + // html tag text objects + "it", + "at", + + // basic character motions + "h", + "j", + "k", + "l", + + // word motions + "w", + "W", + "b", + "B", + "e", + + // line position motions + "0", + "^", + "$", + + // whole-buffer motions (for operators: dgg, dG, cgg, cG) + "gg", + "G", + + // line operation + "d", +] as const; + type GlideMotion = (typeof MOTIONS)[number]; export function select_motion( @@ -180,6 +238,165 @@ 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; + } + 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}`); } @@ -623,3 +840,235 @@ 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 }; +} + +/** + * 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; +} 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");