diff --git a/@types/gecko.d.mts b/@types/gecko.d.mts index b945e70c..fd33c234 100644 --- a/@types/gecko.d.mts +++ b/@types/gecko.d.mts @@ -205,6 +205,8 @@ declare namespace MockedExports { typeof import("../src/glide/browser/base/content/plugins/keymaps.mts"); "chrome://glide/content/plugins/jumplist.mjs": typeof import("../src/glide/browser/base/content/plugins/jumplist.mts"); + "chrome://glide/content/plugins/which-key.mjs": + typeof import("../src/glide/browser/base/content/plugins/which-key.mts"); "chrome://glide/content/docs.mjs": typeof import("../src/glide/browser/base/content/docs.mts"); diff --git a/src/glide/browser/base/content/browser.mts b/src/glide/browser/base/content/browser.mts index 4133d567..57213345 100644 --- a/src/glide/browser/base/content/browser.mts +++ b/src/glide/browser/base/content/browser.mts @@ -17,6 +17,9 @@ const Keys = ChromeUtils.importESModule("chrome://glide/content/utils/keys.mjs", const JumplistPlugin = ChromeUtils.importESModule("chrome://glide/content/plugins/jumplist.mjs"); const ShimsPlugin = ChromeUtils.importESModule("chrome://glide/content/plugins/shims.mjs"); const HintsPlugin = ChromeUtils.importESModule("chrome://glide/content/plugins/hints.mjs"); +const WhichKeyPlugin = ChromeUtils.importESModule("chrome://glide/content/plugins/which-key.mjs", { + global: "current", +}); const Promises = ChromeUtils.importESModule("chrome://glide/content/utils/promises.mjs"); const DOM = ChromeUtils.importESModule("chrome://glide/content/utils/dom.mjs", { global: "current" }); const IPC = ChromeUtils.importESModule("chrome://glide/content/utils/ipc.mjs"); @@ -331,6 +334,7 @@ class GlideBrowserClass { ShimsPlugin.init(sandbox); HintsPlugin.init(sandbox); DefaultKeymaps.init(sandbox); + WhichKeyPlugin.init(sandbox); this.jumplist = new JumplistPlugin.Jumplist(sandbox); if (this.#startup_finished) { @@ -1170,6 +1174,40 @@ class GlideBrowserClass { */ #partial_mapping_waiter_id: number | null = null; + async #invoke_keystatechanged_autocmd(props: glide.AutocmdArgs["KeyStateChanged"]) { + const cmds = GlideBrowser.autocmds.KeyStateChanged ?? []; + if (!cmds.length) { + return; + } + + const results = await Promise.allSettled(cmds.map(cmd => + (async () => { + const cleanup = await cmd.callback({ + ...props, + sequence: props.sequence.map(element => element === GlideBrowser.api.g.mapleader ? "" : element), + }); + if (cleanup) { + throw new Error("ModeChanged autocmds cannot define cleanup functions"); + } + })() + )); + + for (const result of results) { + if (result.status === "fulfilled") { + continue; + } + + GlideBrowser._log.error(result.reason); + + const loc = GlideBrowser.#clean_stack(result.reason, "#invoke_keystatechanged_autocmd") ?? ""; + GlideBrowser.add_notification("glide-autocmd-error", { + label: `Error occurred in KeyStateChanged autocmd \`${loc}\` - ${result.reason}`, + priority: MozElements.NotificationBox.prototype.PRIORITY_CRITICAL_HIGH, + buttons: [GlideBrowser.remove_all_notifications_button], + }); + } + } + async #on_keydown(event: KeyboardEvent) { if (this.#partial_mapping_waiter_id) { clearTimeout(this.#partial_mapping_waiter_id); @@ -1237,7 +1275,6 @@ class GlideBrowserClass { const has_partial = this.key_manager.has_partial_mapping; 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) { this.#display_keyseq([...this.#current_display_keyseq, keyn]); } else { @@ -1294,6 +1331,11 @@ class GlideBrowserClass { if (!mapping) { this.key_manager.reset_sequence(); + // This event only makes sense to fire if the previous state was not of length 0. + if (current_sequence.length !== 0) { + this.#invoke_keystatechanged_autocmd({ mode, sequence: [], partial: false }); + } + if (this.state.mode === "op-pending") { this._change_mode("normal"); return; @@ -1303,6 +1345,12 @@ class GlideBrowserClass { return; } + this.#invoke_keystatechanged_autocmd({ + mode, + sequence: [...this.key_manager.current_sequence], + partial: mapping.has_children, + }); + this.#prevent_keydown(keyn, event); if (mapping.has_children) { @@ -1504,7 +1552,16 @@ class GlideGlobals implements GlideG { function make_glide_api(): typeof glide { return { g: new GlideGlobals(), - o: { mapping_timeout: 200, yank_highlight: "#edc73b", yank_highlight_time: 150, jumplist_max_entries: 100 }, + o: { + mapping_timeout: 200, + + yank_highlight: "#edc73b", + yank_highlight_time: 150, + + jumplist_max_entries: 100, + + which_key_delay: 300, + }, bo: {}, options: { get(name: Name): glide.Options[Name] { diff --git a/src/glide/browser/base/content/glide.d.ts b/src/glide/browser/base/content/glide.d.ts index 4cedb1ef..089ebf2f 100644 --- a/src/glide/browser/base/content/glide.d.ts +++ b/src/glide/browser/base/content/glide.d.ts @@ -88,6 +88,32 @@ declare global { callback: (args: glide.AutocmdArgs[Event]) => void, ): void; + /** + * Create an autocmd that will be invoked whenever the key sequence changes. + * + * This will be fired under three circumstances: + * + * 1. A key is pressed that matches a key mapping. + * 2. A key is pressed that is part of a key mapping. + * 3. A key is pressed that cancels a previous partial key mapping sequence. + * + * For example, with + * ```typescript + * glide.keymaps.set('normal', 'gt', '...'); + * ``` + * + * Pressing `g` will fire with `{ sequence: ["g"], partial: true }`, then either: + * - Pressing `t` would fire `{ sequence: ["g", "t"], partial: false }` + * - Pressing any other key would fire `{ sequence: [], partial: false }` + * + * Note that this is not fired for consecutive key presses for keys that don't correspond to mappings, + * as the key state has not changed. + */ + create( + event: Event, + callback: (args: glide.AutocmdArgs[Event]) => void, + ): void; + /** * Create an autocmd that will be invoked whenever the config is loaded. * @@ -654,6 +680,13 @@ declare global { */ yank_highlight_time: number; + /** + * The delay, in milliseconds, before showing the which key UI. + * + * @default 300 + */ + which_key_delay: number; + /** * The maximum number of entries to include in the jumplist, i.e. * how far back in history will the jumplist store. @@ -787,12 +820,14 @@ declare global { | "UrlEnter" | "ModeChanged" | "ConfigLoaded" - | "WindowLoaded"; + | "WindowLoaded" + | "KeyStateChanged"; type AutocmdPatterns = { UrlEnter: RegExp | { hostname?: string }; ModeChanged: "*" | `${GlideMode | "*"}:${GlideMode | "*"}`; ConfigLoaded: null; WindowLoaded: null; + KeyStateChanged: null; }; type AutocmdArgs = { UrlEnter: { readonly url: string; readonly tab_id: number }; @@ -805,6 +840,11 @@ declare global { }; ConfigLoaded: {}; WindowLoaded: {}; + KeyStateChanged: { + readonly mode: GlideMode; + readonly sequence: string[]; + readonly partial: boolean; + }; }; /// doesn't render properly right now diff --git a/src/glide/browser/base/content/plugins/which-key.mts b/src/glide/browser/base/content/plugins/which-key.mts new file mode 100644 index 00000000..ea2877e4 --- /dev/null +++ b/src/glide/browser/base/content/plugins/which-key.mts @@ -0,0 +1,181 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import type { Sandbox } from "../sandbox.mts"; + +const DOM = ChromeUtils.importESModule("chrome://glide/content/utils/dom.mjs", { global: "current" }); + +export function init(sandbox: Sandbox) { + const { glide } = sandbox; + + const modal = new Modal(sandbox); + + glide.autocmds.create("KeyStateChanged", async ({ mode, partial, sequence }) => { + const maps = glide.keymaps.list(mode); + + if (partial) { + // if the modal is visible, show immediately to avoid lag + queue_timeout(modal.is_visible() ? 0 : glide.o.which_key_delay, () => { + modal.show(maps, sequence); + }); + } else { + queue_timeout(0, () => { + modal.hide(); + }); + } + }); + + let timeout_id: number | null = null; + + function queue_timeout(ms: number, fn: () => void) { + if (timeout_id) { + clearTimeout(timeout_id); + } + timeout_id = setTimeout(fn, ms); + } +} + +class Modal { + #element: HTMLDivElement; + #glide: Glide; + + constructor(sandbox: Sandbox) { + this.#glide = sandbox.glide; + this.#element = DOM.create_element("div", { + id: "which-key", + style: { + display: "none", + position: "fixed", + bottom: "2rem", + right: "2rem", + background: "var(--glide-cmdl-bg)", + fontFamily: "var(--glide-cmdl-font-family)", + fontSize: "var(--glide-cmdl-font-size)", + lineHeight: "var(--glide-cmdl-line-height)", + color: "var(--glide-cmdl-fg)", + zIndex: "2147483646", + boxShadow: "0 -8px 32px hsla(0, 0%, 0%, 0.4)", + minWidth: "300px", + maxWidth: "600px", + maxHeight: "60vh", + overflow: "hidden", + borderRadius: "4px", + border: "1px solid hsla(0, 0%, 100%, 0.1)", + }, + }); + + sandbox.document.children[0]!.appendChild(this.#element); + } + + is_visible(): boolean { + return this.#element.style.display === "block"; + } + + show( + keymaps: glide.Keymap[], + sequence: string[], + ) { + const relevant_maps = keymaps.filter(map => map.lhs && map.lhs.startsWith(sequence.join(""))); + + const rows = relevant_maps.map(map => { + const remaining = map.sequence.slice(sequence.length); + + return DOM.create_element("tr", { + style: { + height: "var(--glide-cmplt-option-height)", + }, + children: [ + DOM.create_element("td", { + textContent: pretty_print_keyseq(this.#glide, remaining), + style: { + paddingLeft: "1rem", + paddingRight: "2rem", + color: "var(--glide-cmplt-fg)", + fontWeight: "600", + minWidth: "3rem", + }, + }), + DOM.create_element("td", { + textContent: get_map_description(map), + style: { + paddingRight: "1rem", + color: "var(--glide-cmplt-fg)", + opacity: "0.8", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + }), + ], + }); + }); + + this.#element.replaceChildren( + // header + DOM.create_element("div", { + style: { + padding: "0.5rem 1rem", + borderBottom: "var(--glide-cmplt-border-top)", + background: "var(--glide-header-first-bg)", + }, + children: [ + DOM.create_element("span", { + textContent: pretty_print_keyseq(this.#glide, sequence), + style: { + color: "var(--glide-cmplt-fg)", + fontWeight: "var(--glide-header-font-weight)", + fontSize: "var(--glide-header-font-size)", + }, + }), + ], + }), + // content + DOM.create_element("div", { + style: { + maxHeight: "calc(60vh - 3rem)", + overflowY: "auto", + overflowX: "hidden", + }, + children: [ + DOM.create_element("table", { + style: { + width: "100%", + borderSpacing: "0", + fontSize: "var(--glide-cmplt-font-size)", + }, + children: [ + DOM.create_element("tbody", { children: rows }), + ], + }), + ], + }), + ); + + this.#element.style.display = "block"; + } + + hide() { + this.#element.style.display = "none"; + } +} + +function pretty_print_keyseq(glide: Glide, sequence: string[]): string { + return sequence.map((keyn: string) => keyn === "" ? glide.g.mapleader : keyn).join(""); +} + +function get_map_description(map: glide.Keymap): string { + if (map.description) { + return map.description; + } + + if (typeof map.rhs === "string") { + return map.rhs; + } + + if (typeof map.rhs === "function" && map.rhs.name) { + return `${map.rhs.name}()`; + } + + return map.rhs.toString(); +} diff --git a/src/glide/browser/base/content/sandbox.mts b/src/glide/browser/base/content/sandbox.mts index c78efaa6..0ff35f1a 100644 --- a/src/glide/browser/base/content/sandbox.mts +++ b/src/glide/browser/base/content/sandbox.mts @@ -10,7 +10,7 @@ const DOMUtils = ChromeUtils.importESModule("chrome://glide/content/utils/dom.mj /** * Represents an object returned by {@link create_sandbox}. */ -export type Sandbox = { glide: Glide; browser: Browser.Browser } & { +export type Sandbox = { glide: Glide; browser: Browser.Browser; document: Document } & { readonly __brand: unique symbol; }; diff --git a/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts b/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts index 4f9fcc81..9a8a681e 100644 --- a/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts +++ b/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts @@ -16,6 +16,8 @@ declare global { /** Collects the order of multiple autocmd callbacks. */ calls?: string[]; + + autocmds?: any[]; } } @@ -500,6 +502,39 @@ add_task(async function test_window_loaded_not_called_on_reload() { isjson(GlideBrowser.api.g.calls, [], "WindowLoaded autocmd should not be triggered on config reload"); }); +add_task(async function test_key_state_changed_autocmd() { + await GlideTestUtils.reload_config(function _() { + glide.g.autocmds = []; + + glide.autocmds.create("KeyStateChanged", args => { + glide.g.autocmds!.push({ ...args }); + }); + + glide.keymaps.set("normal", "gt", () => {}); + }); + + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + await sleep_frames(5); + + await GlideTestUtils.synthesize_keyseq("g"); + await GlideTestUtils.synthesize_keyseq("t"); + await GlideTestUtils.synthesize_keyseq("j"); + + is( + JSON.stringify(GlideBrowser.api.g.autocmds), + JSON.stringify([ + { mode: "normal", sequence: ["g"], partial: true }, + { + mode: "normal", + sequence: ["g", "t"], + partial: false, + }, + { mode: "normal", sequence: ["j"], partial: false }, + ]), + ); + }); +}); + function num_calls() { return (GlideBrowser.api.g.calls ?? []).length; } diff --git a/src/glide/browser/base/jar.mn b/src/glide/browser/base/jar.mn index 9b841887..84f49825 100644 --- a/src/glide/browser/base/jar.mn +++ b/src/glide/browser/base/jar.mn @@ -45,6 +45,7 @@ glide.jar: content/plugins/hints.mjs (content/plugins/dist/hints.mjs) content/plugins/keymaps.mjs (content/plugins/dist/keymaps.mjs) content/plugins/jumplist.mjs (content/plugins/dist/jumplist.mjs) + content/plugins/which-key.mjs (content/plugins/dist/which-key.mjs) # types content/glide.d.ts (content/dist/bundled.compiled.d.ts) diff --git a/src/glide/docs/api.md b/src/glide/docs/api.md index 89f8904a..5ef95e24 100644 --- a/src/glide/docs/api.md +++ b/src/glide/docs/api.md @@ -23,6 +23,7 @@ text-decoration: none; [`glide.o.mapping_timeout`](#glide.o.mapping_timeout)\ [`glide.o.yank_highlight`](#glide.o.yank_highlight)\ [`glide.o.yank_highlight_time`](#glide.o.yank_highlight_time)\ +[`glide.o.which_key_delay`](#glide.o.which_key_delay)\ [`glide.o.jumplist_max_entries`](#glide.o.jumplist_max_entries)\ [`glide.bo`](#glide.bo)\ [`glide.options`](#glide.options)\ @@ -125,6 +126,12 @@ How long, in milliseconds, to highlight the selection for when it's yanked. `ts:@default 150` +### `glide.o.which_key_delay: number` {% id="glide.o.which_key_delay" %} + +The delay, in milliseconds, before showing the which key UI. + +`ts:@default 300` + ### `glide.o.jumplist_max_entries: number` {% id="glide.o.jumplist_max_entries" %} The maximum number of entries to include in the jumplist, i.e.