waveterm/frontend/app/store/keymodel.ts
Evan Simkowitz a2974a3e6d
Fix escape getting eaten by global event handler (#1668)
The terminal keydown handler was set to filter out all key bindings that
have a registered global handler, regardless of whether they actually
propagated or not. This allowed the global handlers to still work
despite the terminal input having precedence, but it also meant that
global key bindings that were invalid for the current context would
still get eaten and not sent to stdin.

Now, the terminal keydown handler will directly call the global handlers
so we can actually see whether or not the global key binding is valid.
If the global handler is valid, it'll be processed immediately and stdin
won't receive the input. If it's not handled, we'll let xterm pass it to
stdin. Because anything xterm doesn't handle gets sent to the
globally-registered version of the handler, we need to make sure we
don't do extra work to process an input we've already checked. We'll
store the last-handled keydown event as a static variable so we can
dedupe later calls for the same event to prevent doing double work.
2025-01-02 08:38:07 -08:00

390 lines
12 KiB
TypeScript

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import {
atoms,
createBlock,
createTab,
getApi,
getBlockComponentModel,
globalStore,
refocusNode,
WOS,
} from "@/app/store/global";
import {
deleteLayoutModelForTab,
getLayoutModelForTab,
getLayoutModelForTabById,
NavigateDirection,
} from "@/layout/index";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
import * as keyutil from "@/util/keyutil";
import { fireAndForget } from "@/util/util";
import * as jotai from "jotai";
const simpleControlShiftAtom = jotai.atom(false);
const globalKeyMap = new Map<string, (waveEvent: WaveKeyboardEvent) => boolean>();
function getFocusedBlockInStaticTab() {
const tabId = globalStore.get(atoms.staticTabId);
const layoutModel = getLayoutModelForTabById(tabId);
const focusedNode = globalStore.get(layoutModel.focusedNode);
return focusedNode.data?.blockId;
}
function getSimpleControlShiftAtom() {
return simpleControlShiftAtom;
}
function setControlShift() {
globalStore.set(simpleControlShiftAtom, true);
setTimeout(() => {
const simpleState = globalStore.get(simpleControlShiftAtom);
if (simpleState) {
globalStore.set(atoms.controlShiftDelayAtom, true);
}
}, 400);
}
function unsetControlShift() {
globalStore.set(simpleControlShiftAtom, false);
globalStore.set(atoms.controlShiftDelayAtom, false);
}
function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
if (globalStore.get(atoms.modalOpen)) {
return false;
}
const activeElem = document.activeElement;
if (activeElem != null && activeElem instanceof HTMLElement) {
if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA" || activeElem.contentEditable == "true") {
if (activeElem.classList.contains("dummy-focus")) {
return true;
}
if (keyutil.isInputEvent(e)) {
return false;
}
return true;
}
}
return true;
}
function genericClose(tabId: string) {
const ws = globalStore.get(atoms.workspace);
const tabORef = WOS.makeORef("tab", tabId);
const tabAtom = WOS.getWaveObjectAtom<Tab>(tabORef);
const tabData = globalStore.get(tabAtom);
if (tabData == null) {
return;
}
if (ws.pinnedtabids?.includes(tabId) && tabData.blockids?.length == 1) {
// don't allow closing the last block in a pinned tab
return;
}
if (tabData.blockids == null || tabData.blockids.length == 0) {
// close tab
getApi().closeTab(ws.oid, tabId);
deleteLayoutModelForTab(tabId);
return;
}
const layoutModel = getLayoutModelForTab(tabAtom);
fireAndForget(layoutModel.closeFocusedNode.bind(layoutModel));
}
function switchBlockByBlockNum(index: number) {
const layoutModel = getLayoutModelForStaticTab();
if (!layoutModel) {
return;
}
layoutModel.switchNodeFocusByBlockNum(index);
}
function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
const layoutModel = getLayoutModelForTabById(tabId);
layoutModel.switchNodeFocusInDirection(direction);
}
function getAllTabs(ws: Workspace): string[] {
return [...(ws.pinnedtabids ?? []), ...(ws.tabids ?? [])];
}
function switchTabAbs(index: number) {
console.log("switchTabAbs", index);
const ws = globalStore.get(atoms.workspace);
const newTabIdx = index - 1;
const tabids = getAllTabs(ws);
if (newTabIdx < 0 || newTabIdx >= tabids.length) {
return;
}
const newActiveTabId = tabids[newTabIdx];
getApi().setActiveTab(newActiveTabId);
}
function switchTab(offset: number) {
console.log("switchTab", offset);
const ws = globalStore.get(atoms.workspace);
const curTabId = globalStore.get(atoms.staticTabId);
let tabIdx = -1;
const tabids = getAllTabs(ws);
for (let i = 0; i < tabids.length; i++) {
if (tabids[i] == curTabId) {
tabIdx = i;
break;
}
}
if (tabIdx == -1) {
return;
}
const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length;
const newActiveTabId = tabids[newTabIdx];
getApi().setActiveTab(newActiveTabId);
}
function handleCmdI() {
globalRefocus();
}
function globalRefocus() {
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode == null) {
// focus a node
layoutModel.focusFirstNode();
return;
}
const blockId = focusedNode?.data?.blockId;
if (blockId == null) {
return;
}
refocusNode(blockId);
}
async function handleCmdN() {
const termBlockDef: BlockDef = {
meta: {
view: "term",
controller: "shell",
},
};
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode != null) {
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
const blockData = globalStore.get(blockAtom);
if (blockData?.meta?.view == "term") {
if (blockData?.meta?.["cmd:cwd"] != null) {
termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"];
}
}
if (blockData?.meta?.connection != null) {
termBlockDef.meta.connection = blockData.meta.connection;
}
}
await createBlock(termBlockDef);
}
let lastHandledEvent: KeyboardEvent | null = null;
function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
const nativeEvent = (waveEvent as any).nativeEvent;
if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) {
return false;
}
lastHandledEvent = nativeEvent;
const handled = handleGlobalWaveKeyboardEvents(waveEvent);
if (handled) {
return true;
}
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
const blockId = focusedNode?.data?.blockId;
if (blockId != null && shouldDispatchToBlock(waveEvent)) {
const bcm = getBlockComponentModel(blockId);
const viewModel = bcm?.viewModel;
if (viewModel?.keyDownHandler) {
const handledByBlock = viewModel.keyDownHandler(waveEvent);
if (handledByBlock) {
return true;
}
}
}
return false;
}
function registerControlShiftStateUpdateHandler() {
getApi().onControlShiftStateUpdate((state: boolean) => {
if (state) {
setControlShift();
} else {
unsetControlShift();
}
});
}
function registerElectronReinjectKeyHandler() {
getApi().onReinjectKey((event: WaveKeyboardEvent) => {
appHandleKeyDown(event);
});
}
function tryReinjectKey(event: WaveKeyboardEvent): boolean {
return appHandleKeyDown(event);
}
function registerGlobalKeys() {
globalKeyMap.set("Cmd:]", () => {
switchTab(1);
return true;
});
globalKeyMap.set("Shift:Cmd:]", () => {
switchTab(1);
return true;
});
globalKeyMap.set("Cmd:[", () => {
switchTab(-1);
return true;
});
globalKeyMap.set("Shift:Cmd:[", () => {
switchTab(-1);
return true;
});
globalKeyMap.set("Cmd:n", () => {
handleCmdN();
return true;
});
globalKeyMap.set("Cmd:i", () => {
handleCmdI();
return true;
});
globalKeyMap.set("Cmd:t", () => {
createTab();
return true;
});
globalKeyMap.set("Cmd:w", () => {
const tabId = globalStore.get(atoms.staticTabId);
genericClose(tabId);
return true;
});
globalKeyMap.set("Cmd:Shift:w", () => {
const tabId = globalStore.get(atoms.staticTabId);
const ws = globalStore.get(atoms.workspace);
if (ws.pinnedtabids?.includes(tabId)) {
// switch to first unpinned tab if it exists (for close spamming)
if (ws.tabids != null && ws.tabids.length > 0) {
getApi().setActiveTab(ws.tabids[0]);
}
return true;
}
getApi().closeTab(ws.oid, tabId);
return true;
});
globalKeyMap.set("Cmd:m", () => {
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode != null) {
layoutModel.magnifyNodeToggle(focusedNode.id);
}
return true;
});
globalKeyMap.set("Ctrl:Shift:ArrowUp", () => {
const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Up);
return true;
});
globalKeyMap.set("Ctrl:Shift:ArrowDown", () => {
const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Down);
return true;
});
globalKeyMap.set("Ctrl:Shift:ArrowLeft", () => {
const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Left);
return true;
});
globalKeyMap.set("Ctrl:Shift:ArrowRight", () => {
const tabId = globalStore.get(atoms.staticTabId);
switchBlockInDirection(tabId, NavigateDirection.Right);
return true;
});
globalKeyMap.set("Cmd:g", () => {
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
if (bcm.openSwitchConnection != null) {
bcm.openSwitchConnection();
return true;
}
});
for (let idx = 1; idx <= 9; idx++) {
globalKeyMap.set(`Cmd:${idx}`, () => {
switchTabAbs(idx);
return true;
});
globalKeyMap.set(`Ctrl:Shift:c{Digit${idx}}`, () => {
switchBlockByBlockNum(idx);
return true;
});
globalKeyMap.set(`Ctrl:Shift:c{Numpad${idx}}`, () => {
switchBlockByBlockNum(idx);
return true;
});
}
function activateSearch(event: WaveKeyboardEvent): boolean {
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
// Ctrl+f is reserved in most shells
if (event.control && bcm.viewModel.viewType == "term") {
return false;
}
if (bcm.viewModel.searchAtoms) {
globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
return true;
}
return false;
}
function deactivateSearch(): boolean {
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);
return true;
}
return false;
}
globalKeyMap.set("Cmd:f", activateSearch);
globalKeyMap.set("Ctrl:f", activateSearch);
globalKeyMap.set("Escape", deactivateSearch);
const allKeys = Array.from(globalKeyMap.keys());
// special case keys, handled by web view
allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft");
getApi().registerGlobalWebviewKeys(allKeys);
}
function getAllGlobalKeyBindings(): string[] {
const allKeys = Array.from(globalKeyMap.keys());
return allKeys;
}
// these keyboard events happen *anywhere*, even if you have focus in an input or somewhere else.
function handleGlobalWaveKeyboardEvents(waveEvent: WaveKeyboardEvent): boolean {
for (const key of globalKeyMap.keys()) {
if (keyutil.checkKeyPressed(waveEvent, key)) {
const handler = globalKeyMap.get(key);
if (handler == null) {
return false;
}
return handler(waveEvent);
}
}
return false;
}
export {
appHandleKeyDown,
getAllGlobalKeyBindings,
getSimpleControlShiftAtom,
globalRefocus,
registerControlShiftStateUpdateHandler,
registerElectronReinjectKeyHandler,
registerGlobalKeys,
tryReinjectKey,
unsetControlShift,
};