Skip to content
Merged
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
2 changes: 2 additions & 0 deletions @types/gecko.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
61 changes: 59 additions & 2 deletions src/glide/browser/base/content/browser.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ? "<leader>" : 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") ?? "<unknown>";
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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 extends keyof glide.Options>(name: Name): glide.Options[Name] {
Expand Down
42 changes: 41 additions & 1 deletion src/glide/browser/base/content/glide.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<const Event extends "KeyStateChanged">(
event: Event,
callback: (args: glide.AutocmdArgs[Event]) => void,
): void;

/**
* Create an autocmd that will be invoked whenever the config is loaded.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
Expand All @@ -805,6 +840,11 @@ declare global {
};
ConfigLoaded: {};
WindowLoaded: {};
KeyStateChanged: {
readonly mode: GlideMode;
readonly sequence: string[];
readonly partial: boolean;
};
};

/// doesn't render properly right now
Expand Down
181 changes: 181 additions & 0 deletions src/glide/browser/base/content/plugins/which-key.mts
Original file line number Diff line number Diff line change
@@ -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 === "<leader>" ? 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();
}
2 changes: 1 addition & 1 deletion src/glide/browser/base/content/sandbox.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
Loading