Skip to content

Commit 57291e7

Browse files
committed
feat(motion): add % motion
1 parent 01bab04 commit 57291e7

7 files changed

Lines changed: 193 additions & 0 deletions

File tree

src/glide/browser/actors/GlideHandlerChild.sys.mts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,10 @@ export class GlideHandlerChild extends JSWindowActorChild<
567567
motions.end_of_line(editor, false);
568568
break;
569569
}
570+
case "%": {
571+
motions.jump_to_matching_bracket(editor);
572+
break;
573+
}
570574
case "s": {
571575
// caret is on the first line and it's empty
572576
if (motions.is_bof(editor) && motions.next_char(editor) === "\n") {
@@ -701,6 +705,7 @@ export class GlideHandlerChild extends JSWindowActorChild<
701705
case "b":
702706
case "B":
703707
case "$":
708+
case "%":
704709
case "{":
705710
case "}":
706711
case "s":

src/glide/browser/base/content/browser-excmds-registry.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ export const GLIDE_EXCOMMANDS = [
444444
"0",
445445
"^",
446446
"$",
447+
"%",
447448
"{",
448449
"}",
449450
"s",

src/glide/browser/base/content/motions.mts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { GlideOperator } from "./browser-excmds-registry.mts";
88
const text_obj = ChromeUtils.importESModule("chrome://glide/content/text-objects.mjs");
99
const { assert_never } = ChromeUtils.importESModule("chrome://glide/content/utils/guards.mjs");
1010
const strings = ChromeUtils.importESModule("chrome://glide/content/utils/strings.mjs");
11+
const { is_text_field } = ChromeUtils.importESModule("chrome://glide/content/utils/dom.mjs");
1112

1213
/**
1314
* 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) {
529530
}
530531
}
531532

533+
export function jump_to_matching_bracket(editor: nsIEditor): void {
534+
const root = editor.rootElement;
535+
536+
if (is_text_field(root)) {
537+
const start = root.selectionStart;
538+
if (start == null) {
539+
return;
540+
}
541+
const new_offset = strings.jump_to_matching_bracket_offset(root.value, start);
542+
if (new_offset === null) {
543+
return;
544+
}
545+
root.setSelectionRange(new_offset, new_offset);
546+
return;
547+
}
548+
549+
const node = editor.selection.focusNode;
550+
if (node?.nodeType !== Node.TEXT_NODE) {
551+
return;
552+
}
553+
const text = node.textContent;
554+
if (text == null) {
555+
return;
556+
}
557+
const new_offset = strings.jump_to_matching_bracket_offset(text, editor.selection.focusOffset);
558+
if (new_offset === null) {
559+
return;
560+
}
561+
editor.selection.collapse(node, new_offset);
562+
}
563+
532564
/**
533565
* Delete the current selection range.
534566
*/

src/glide/browser/base/content/plugins/keymaps.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function init(sandbox: Sandbox) {
110110
glide.keymaps.set("normal", "0", "motion 0");
111111
glide.keymaps.set("normal", "^", "motion ^");
112112
glide.keymaps.set("normal", "$", "motion $");
113+
glide.keymaps.set("normal", "%", "motion %");
113114
glide.keymaps.set("normal", "h", "caret_move left");
114115
glide.keymaps.set("normal", "l", "caret_move right");
115116
glide.keymaps.set("normal", "j", "caret_move down");

src/glide/browser/base/content/test/motions/browser_normal_motions.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,3 +501,52 @@ add_task(async function test_normal_caret() {
501501
await test_motion("^", 2, "h");
502502
});
503503
});
504+
505+
add_task(async function test_normal_percent() {
506+
await BrowserTestUtils.withNewTab(INPUT_TEST_FILE, async browser => {
507+
const { set_text, test_motion, set_selection } = GlideTestUtils.make_input_test_helpers(browser, { text_start: 1 });
508+
509+
await set_text("a(b)c", "open paren to close");
510+
await set_selection(1, "(");
511+
await test_motion("%", 3, ")");
512+
513+
await set_text("a(b)c", "close paren to open");
514+
await set_selection(3, ")");
515+
await test_motion("%", 1, "(");
516+
517+
await set_text("((x))", "nested parens from outer open");
518+
await set_selection(0, "(");
519+
await test_motion("%", 4, ")");
520+
521+
await set_text("((x))", "nested parens from inner open");
522+
await set_selection(1, "(");
523+
await test_motion("%", 3, ")");
524+
525+
await set_text("((x))", "nested parens from final close");
526+
await set_selection(4, ")");
527+
await test_motion("%", 0, "(");
528+
529+
await set_text("[a]", "square brackets");
530+
await set_selection(0, "[");
531+
await test_motion("%", 2, "]");
532+
533+
await set_text("{a}", "curly brackets");
534+
await set_selection(0, "{");
535+
await test_motion("%", 2, "}");
536+
537+
await set_text("(\n)", "multiline pair");
538+
await set_selection(0, "(");
539+
await test_motion("%", 2, ")");
540+
541+
await set_text("a (b)", "text before open paren, jump to close");
542+
await set_selection(0, "a");
543+
await test_motion("%", 4, ")");
544+
545+
await set_text("abc", "no bracket on rest of line");
546+
await test_motion("%", 0, "a");
547+
548+
await set_text("abc(de", "unbalanced bracket on line");
549+
await set_selection(3, "(");
550+
await test_motion("%", 3, "(");
551+
});
552+
});

src/glide/browser/base/content/utils/dom.mts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,22 @@ export function in_frames(window: Window, n: number, func: () => void): void {
334334

335335
window.requestAnimationFrame(wait);
336336
}
337+
338+
export function is_text_field(el: Element): el is HTMLTextAreaElement | HTMLInputElement {
339+
if (el instanceof HTMLTextAreaElement) {
340+
return true;
341+
}
342+
if (el instanceof HTMLInputElement) {
343+
const t = el.type;
344+
return (
345+
t === "text"
346+
|| t === "search"
347+
|| t === "url"
348+
|| t === "tel"
349+
|| t === "password"
350+
|| t === "email"
351+
|| t === ""
352+
);
353+
}
354+
return false;
355+
}

src/glide/browser/base/content/utils/strings.mts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,89 @@ export function decode_utf8(bytes: Uint8Array) {
274274
?? ((decoder = new (globalThis as any).TextDecoder()), (decode_utf8_ = decoder.decode.bind(decoder)))
275275
)(bytes);
276276
}
277+
278+
const BRACKET_OPEN_TO_CLOSE: Record<string, string> = {
279+
"(": ")",
280+
"[": "]",
281+
"{": "}",
282+
};
283+
const BRACKET_CLOSE_TO_OPEN: Record<string, string> = {
284+
")": "(",
285+
"]": "[",
286+
"}": "{",
287+
};
288+
289+
export function is_bracket_char(c: string): boolean {
290+
return c in BRACKET_OPEN_TO_CLOSE || c in BRACKET_CLOSE_TO_OPEN;
291+
}
292+
293+
export function find_matching_bracket_index(text: string, start_idx: number): number | null {
294+
const ch = text[start_idx]!;
295+
const open = BRACKET_CLOSE_TO_OPEN[ch];
296+
if (open !== undefined) {
297+
const close = ch;
298+
let depth = 0;
299+
for (let i = start_idx; i >= 0; i--) {
300+
const c = text[i]!;
301+
if (c === close) {
302+
depth++;
303+
} else if (c === open) {
304+
depth--;
305+
if (depth === 0) {
306+
return i;
307+
}
308+
}
309+
}
310+
return null;
311+
}
312+
313+
const close = BRACKET_OPEN_TO_CLOSE[ch];
314+
if (close !== undefined) {
315+
const open = ch;
316+
let depth = 0;
317+
for (let i = start_idx; i < text.length; i++) {
318+
const c = text[i]!;
319+
if (c === open) {
320+
depth++;
321+
} else if (c === close) {
322+
depth--;
323+
if (depth === 0) {
324+
return i;
325+
}
326+
}
327+
}
328+
return null;
329+
}
330+
331+
return null;
332+
}
333+
334+
export function jump_to_matching_bracket_offset(text: string, focus_offset: number): number | null {
335+
const index_left_of_caret = focus_offset >= 1 ? focus_offset - 1 : -1;
336+
337+
const next_newline_index = text.indexOf("\n", Math.max(0, index_left_of_caret));
338+
const line_end_exclusive = next_newline_index === -1 ? text.length : next_newline_index;
339+
340+
let bracket_index: number | null = null;
341+
if (index_left_of_caret >= 0 && is_bracket_char(text[index_left_of_caret]!)) {
342+
bracket_index = index_left_of_caret;
343+
} else {
344+
const scan_start = index_left_of_caret < 0 ? 0 : index_left_of_caret + 1;
345+
for (let i = scan_start; i < line_end_exclusive; i++) {
346+
if (is_bracket_char(text[i]!)) {
347+
bracket_index = i;
348+
break;
349+
}
350+
}
351+
}
352+
353+
if (bracket_index === null) {
354+
return null;
355+
}
356+
357+
const matching_bracket_index = find_matching_bracket_index(text, bracket_index);
358+
if (matching_bracket_index === null) {
359+
return null;
360+
}
361+
return matching_bracket_index + 1;
362+
}

0 commit comments

Comments
 (0)