diff --git a/src/glide/browser/actors/GlideHandlerChild.sys.mts b/src/glide/browser/actors/GlideHandlerChild.sys.mts index 16924517..937c089d 100644 --- a/src/glide/browser/actors/GlideHandlerChild.sys.mts +++ b/src/glide/browser/actors/GlideHandlerChild.sys.mts @@ -567,6 +567,10 @@ export class GlideHandlerChild extends JSWindowActorChild< motions.end_of_line(editor, false); break; } + case "%": { + motions.jump_to_matching_bracket(editor); + break; + } case "s": { // caret is on the first line and it's empty if (motions.is_bof(editor) && motions.next_char(editor) === "\n") { @@ -701,6 +705,7 @@ export class GlideHandlerChild extends JSWindowActorChild< case "b": case "B": case "$": + case "%": case "{": case "}": case "s": diff --git a/src/glide/browser/base/content/browser-excmds-registry.mts b/src/glide/browser/base/content/browser-excmds-registry.mts index 88089170..4c55a09e 100644 --- a/src/glide/browser/base/content/browser-excmds-registry.mts +++ b/src/glide/browser/base/content/browser-excmds-registry.mts @@ -444,6 +444,7 @@ export const GLIDE_EXCOMMANDS = [ "0", "^", "$", + "%", "{", "}", "s", diff --git a/src/glide/browser/base/content/motions.mts b/src/glide/browser/base/content/motions.mts index e4ce6fa0..7830b5e5 100644 --- a/src/glide/browser/base/content/motions.mts +++ b/src/glide/browser/base/content/motions.mts @@ -8,6 +8,7 @@ import type { GlideOperator } from "./browser-excmds-registry.mts"; const text_obj = ChromeUtils.importESModule("chrome://glide/content/text-objects.mjs"); const { assert_never } = ChromeUtils.importESModule("chrome://glide/content/utils/guards.mjs"); const strings = ChromeUtils.importESModule("chrome://glide/content/utils/strings.mjs"); +const { is_text_field } = ChromeUtils.importESModule("chrome://glide/content/utils/dom.mjs"); /** * A minimal representation of `nsIEditor` so that we can re-implement editors @@ -529,6 +530,37 @@ export function first_non_whitespace(editor: Editor, extend: boolean = false) { } } +export function jump_to_matching_bracket(editor: nsIEditor): void { + const root = editor.rootElement; + + if (is_text_field(root)) { + const start = root.selectionStart; + if (start == null) { + return; + } + const new_offset = strings.jump_to_matching_bracket_offset(root.value, start); + if (new_offset === null) { + return; + } + root.setSelectionRange(new_offset, new_offset); + return; + } + + const node = editor.selection.focusNode; + if (node?.nodeType !== Node.TEXT_NODE) { + return; + } + const text = node.textContent; + if (text == null) { + return; + } + const new_offset = strings.jump_to_matching_bracket_offset(text, editor.selection.focusOffset); + if (new_offset === null) { + return; + } + editor.selection.collapse(node, new_offset); +} + /** * Delete the current selection range. */ diff --git a/src/glide/browser/base/content/plugins/keymaps.mts b/src/glide/browser/base/content/plugins/keymaps.mts index 92d21ae3..a300ed5f 100644 --- a/src/glide/browser/base/content/plugins/keymaps.mts +++ b/src/glide/browser/base/content/plugins/keymaps.mts @@ -110,6 +110,7 @@ export function init(sandbox: Sandbox) { glide.keymaps.set("normal", "0", "motion 0"); glide.keymaps.set("normal", "^", "motion ^"); glide.keymaps.set("normal", "$", "motion $"); + glide.keymaps.set("normal", "%", "motion %"); glide.keymaps.set("normal", "h", "caret_move left"); glide.keymaps.set("normal", "l", "caret_move right"); glide.keymaps.set("normal", "j", "caret_move down"); diff --git a/src/glide/browser/base/content/test/motions/browser_normal_motions.ts b/src/glide/browser/base/content/test/motions/browser_normal_motions.ts index c756bb8c..bbd30107 100644 --- a/src/glide/browser/base/content/test/motions/browser_normal_motions.ts +++ b/src/glide/browser/base/content/test/motions/browser_normal_motions.ts @@ -501,3 +501,52 @@ add_task(async function test_normal_caret() { await test_motion("^", 2, "h"); }); }); + +add_task(async function test_normal_percent() { + await BrowserTestUtils.withNewTab(INPUT_TEST_FILE, async browser => { + const { set_text, test_motion, set_selection } = GlideTestUtils.make_input_test_helpers(browser, { text_start: 1 }); + + await set_text("a(b)c", "open paren to close"); + await set_selection(1, "("); + await test_motion("%", 3, ")"); + + await set_text("a(b)c", "close paren to open"); + await set_selection(3, ")"); + await test_motion("%", 1, "("); + + await set_text("((x))", "nested parens from outer open"); + await set_selection(0, "("); + await test_motion("%", 4, ")"); + + await set_text("((x))", "nested parens from inner open"); + await set_selection(1, "("); + await test_motion("%", 3, ")"); + + await set_text("((x))", "nested parens from final close"); + await set_selection(4, ")"); + await test_motion("%", 0, "("); + + await set_text("[a]", "square brackets"); + await set_selection(0, "["); + await test_motion("%", 2, "]"); + + await set_text("{a}", "curly brackets"); + await set_selection(0, "{"); + await test_motion("%", 2, "}"); + + await set_text("(\n)", "multiline pair"); + await set_selection(0, "("); + await test_motion("%", 2, ")"); + + await set_text("a (b)", "text before open paren, jump to close"); + await set_selection(0, "a"); + await test_motion("%", 4, ")"); + + await set_text("abc", "no bracket on rest of line"); + await test_motion("%", 0, "a"); + + await set_text("abc(de", "unbalanced bracket on line"); + await set_selection(3, "("); + await test_motion("%", 3, "("); + }); +}); diff --git a/src/glide/browser/base/content/utils/dom.mts b/src/glide/browser/base/content/utils/dom.mts index f267b7fa..8170ece6 100644 --- a/src/glide/browser/base/content/utils/dom.mts +++ b/src/glide/browser/base/content/utils/dom.mts @@ -334,3 +334,22 @@ export function in_frames(window: Window, n: number, func: () => void): void { window.requestAnimationFrame(wait); } + +export function is_text_field(el: Element): el is HTMLTextAreaElement | HTMLInputElement { + if (el instanceof HTMLTextAreaElement) { + return true; + } + if (el instanceof HTMLInputElement) { + const t = el.type; + return ( + t === "text" + || t === "search" + || t === "url" + || t === "tel" + || t === "password" + || t === "email" + || t === "" + ); + } + return false; +} diff --git a/src/glide/browser/base/content/utils/strings.mts b/src/glide/browser/base/content/utils/strings.mts index f234fa5d..4b8fd5a7 100644 --- a/src/glide/browser/base/content/utils/strings.mts +++ b/src/glide/browser/base/content/utils/strings.mts @@ -274,3 +274,89 @@ export function decode_utf8(bytes: Uint8Array) { ?? ((decoder = new (globalThis as any).TextDecoder()), (decode_utf8_ = decoder.decode.bind(decoder))) )(bytes); } + +const BRACKET_OPEN_TO_CLOSE: Record = { + "(": ")", + "[": "]", + "{": "}", +}; +const BRACKET_CLOSE_TO_OPEN: Record = { + ")": "(", + "]": "[", + "}": "{", +}; + +export function is_bracket_char(c: string): boolean { + return c in BRACKET_OPEN_TO_CLOSE || c in BRACKET_CLOSE_TO_OPEN; +} + +export function find_matching_bracket_index(text: string, start_idx: number): number | null { + const ch = text[start_idx]!; + const open = BRACKET_CLOSE_TO_OPEN[ch]; + if (open !== undefined) { + const close = ch; + let depth = 0; + for (let i = start_idx; i >= 0; i--) { + const c = text[i]!; + if (c === close) { + depth++; + } else if (c === open) { + depth--; + if (depth === 0) { + return i; + } + } + } + return null; + } + + const close = BRACKET_OPEN_TO_CLOSE[ch]; + if (close !== undefined) { + const open = ch; + let depth = 0; + for (let i = start_idx; i < text.length; i++) { + const c = text[i]!; + if (c === open) { + depth++; + } else if (c === close) { + depth--; + if (depth === 0) { + return i; + } + } + } + return null; + } + + return null; +} + +export function jump_to_matching_bracket_offset(text: string, focus_offset: number): number | null { + const index_left_of_caret = focus_offset >= 1 ? focus_offset - 1 : -1; + + const next_newline_index = text.indexOf("\n", Math.max(0, index_left_of_caret)); + const line_end_exclusive = next_newline_index === -1 ? text.length : next_newline_index; + + let bracket_index: number | null = null; + if (index_left_of_caret >= 0 && is_bracket_char(text[index_left_of_caret]!)) { + bracket_index = index_left_of_caret; + } else { + const scan_start = index_left_of_caret < 0 ? 0 : index_left_of_caret + 1; + for (let i = scan_start; i < line_end_exclusive; i++) { + if (is_bracket_char(text[i]!)) { + bracket_index = i; + break; + } + } + } + + if (bracket_index === null) { + return null; + } + + const matching_bracket_index = find_matching_bracket_index(text, bracket_index); + if (matching_bracket_index === null) { + return null; + } + return matching_bracket_index + 1; +}