waveterm/frontend/app/store/keymodel.ts

359 lines
11 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);
}
function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
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;
});
}
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);
}
}
}
export {
appHandleKeyDown,
getAllGlobalKeyBindings,
getSimpleControlShiftAtom,
globalRefocus,
registerControlShiftStateUpdateHandler,
registerElectronReinjectKeyHandler,
registerGlobalKeys,
tryReinjectKey,
unsetControlShift,
};