Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/glide/browser/actors/GlideHandlerChild.sys.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -701,6 +705,7 @@ export class GlideHandlerChild extends JSWindowActorChild<
case "b":
case "B":
case "$":
case "%":
case "{":
case "}":
case "s":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export const GLIDE_EXCOMMANDS = [
"0",
"^",
"$",
"%",
"{",
"}",
"s",
Expand Down
32 changes: 32 additions & 0 deletions src/glide/browser/base/content/motions.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/glide/browser/base/content/plugins/keymaps.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "(");
});
});
19 changes: 19 additions & 0 deletions src/glide/browser/base/content/utils/dom.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
86 changes: 86 additions & 0 deletions src/glide/browser/base/content/utils/strings.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"(": ")",
"[": "]",
"{": "}",
};
const BRACKET_CLOSE_TO_OPEN: Record<string, string> = {
")": "(",
"]": "[",
"}": "{",
};

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;
}