diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go index 9efac0ff87..e28f5df177 100644 --- a/cmd/wsh/cmd/wshcmd-debug.go +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -31,12 +31,33 @@ var debugSendTelemetryCmd = &cobra.Command{ Hidden: true, } +var debugGetTabCmd = &cobra.Command{ + Use: "gettab", + Short: "get tab", + RunE: debugGetTabRun, + Hidden: true, +} + func init() { debugCmd.AddCommand(debugBlockIdsCmd) debugCmd.AddCommand(debugSendTelemetryCmd) + debugCmd.AddCommand(debugGetTabCmd) rootCmd.AddCommand(debugCmd) } +func debugGetTabRun(cmd *cobra.Command, args []string) error { + tab, err := wshclient.GetTabCommand(RpcClient, RpcContext.TabId, nil) + if err != nil { + return err + } + barr, err := json.MarshalIndent(tab, "", " ") + if err != nil { + return err + } + WriteStdout("%s\n", string(barr)) + return nil +} + func debugSendTelemetryRun(cmd *cobra.Command, args []string) error { err := wshclient.SendTelemetryCommand(RpcClient, nil) return err diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index ee3fac1075..4f2125ebed 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -4,7 +4,7 @@ id: "keybindings" title: "Key Bindings" --- -import { Kbd } from "@site/src/components/kbd.tsx"; +import { Kbd, KbdChord } from "@site/src/components/kbd.tsx"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext.tsx"; @@ -15,32 +15,39 @@ Some keybindings are always active. Others are only active for certain types of Note that these are the MacOS keybindings (they use "Cmd"). For Windows and Linux, replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and Linux). +Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd chord key after typing the first key. Hitting Escape after an initial chord key will always be a no-op. + ## Global Keybindings
-| Key | Function | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| | Open a new tab | -| | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | -| | Split horizontally, open a new block to the right | -| | Split vertically, open a new block below | -| | Open a new window | -| | Close the current block | -| | Close the current tab | -| | Magnify / Un-Magnify the current block | -| | Open the "connection" switcher | -| | Refocus the current block (useful if the block has lost input focus) | -| | Show block numbers | -| | Switch to block number | -| | Move left, right, up, down between blocks | -| | Switch to tab number | -| | Switch tab left | -| | Switch tab right | -| | Switch to workspace number | -| | Refresh the UI | -| | Toggle terminal multi-input mode | +| Key | Function | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| | Open a new tab | +| | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | +| | Split horizontally, open a new block to the right | +| | Split vertically, open a new block below | +| | Split vertically, open a new block above | +| | Split vertically, open a new block below | +| | Split horizontally, open a new block to the left | +| | Split horizontally, open a new block to the right | +| | Open a new window | +| | Close the current block | +| | Close the current tab | +| | Magnify / Un-Magnify the current block | +| | Open the "connection" switcher | +| | Refocus the current block (useful if the block has lost input focus) | +| | Show block numbers | +| | Switch to block number | +| | Move left, right, up, down between blocks | +| | Replace the current block with a launcher block | +| | Switch to tab number | +| | Switch tab left | +| | Switch tab right | +| | Switch to workspace number | +| | Refresh the UI | +| | Toggle terminal multi-input mode | ## File Preview Keybindings diff --git a/docs/src/components/kbd.tsx b/docs/src/components/kbd.tsx index d8550521af..21eb0b3c68 100644 --- a/docs/src/components/kbd.tsx +++ b/docs/src/components/kbd.tsx @@ -61,3 +61,15 @@ const KbdInternal = ({ k }: { k: string }) => { export const Kbd = ({ k }: { k: string }) => { return {k}}>{() => }; }; + +export const KbdChord = ({ karr }: { karr: string[] }) => { + const elems: React.ReactNode[] = []; + for (let i = 0; i < karr.length; i++) { + if (i > 0) { + elems.push(+); + } + elems.push(); + } + const fullElem = {elems}; + return {() => fullElem}; +}; diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 7a93269881..c174cbf630 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -3,6 +3,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { adaptFromElectronKeyEvent } from "@/util/keyutil"; +import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { Rectangle, shell, WebContentsView } from "electron"; import { getWaveWindowById } from "emain/emain-window"; import path from "path"; @@ -45,6 +46,8 @@ export class WaveTabView extends WebContentsView { isInitialized: boolean = false; isWaveReady: boolean = false; isDestroyed: boolean = false; + keyboardChordMode: boolean = false; + resetChordModeTimeout: NodeJS.Timeout = null; constructor(fullConfig: FullConfigType) { console.log("createBareTabView"); @@ -91,6 +94,23 @@ export class WaveTabView extends WebContentsView { this._waveTabId = waveTabId; } + setKeyboardChordMode(mode: boolean) { + this.keyboardChordMode = mode; + if (mode) { + if (this.resetChordModeTimeout) { + clearTimeout(this.resetChordModeTimeout); + } + this.resetChordModeTimeout = setTimeout(() => { + this.keyboardChordMode = false; + }, CHORD_TIMEOUT); + } else { + if (this.resetChordModeTimeout) { + clearTimeout(this.resetChordModeTimeout); + this.resetChordModeTimeout = null; + } + } + } + positionTabOnScreen(winBounds: Rectangle) { const curBounds = this.getBounds(); if ( @@ -220,6 +240,11 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); handleCtrlShiftState(tabView.webContents, waveEvent); setWasActive(true); + if (input.type == "keyDown" && tabView.keyboardChordMode) { + e.preventDefault(); + tabView.setKeyboardChordMode(false); + tabView.webContents.send("reinject-key", waveEvent); + } }); tabView.webContents.on("zoom-changed", (e) => { tabView.webContents.send("zoom-changed"); diff --git a/emain/emain.ts b/emain/emain.ts index c2037b70c2..80f6e1703f 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -259,6 +259,16 @@ electron.ipcMain.on("get-cursor-point", (event) => { event.returnValue = retVal; }); +electron.ipcMain.handle("capture-screenshot", async (event, rect) => { + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + if (!tabView) { + throw new Error("No tab view found for the given webContents id"); + } + const image = await tabView.webContents.capturePage(rect); + const base64String = image.toPNG().toString("base64"); + return `data:image/png;base64,${base64String}`; +}); + electron.ipcMain.on("get-env", (event, varName) => { event.returnValue = process.env[varName] ?? null; }); @@ -312,6 +322,12 @@ electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { webviewKeys = keys ?? []; }); +electron.ipcMain.on("set-keyboard-chord-mode", (event) => { + event.returnValue = null; + const tabView = getWaveTabViewByWebContentsId(event.sender.id); + tabView?.setKeyboardChordMode(true); +}); + if (unamePlatform !== "darwin") { const fac = new FastAverageColor(); diff --git a/emain/preload.ts b/emain/preload.ts index ff5b25851c..0c0633fdfe 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { contextBridge, ipcRenderer, WebviewTag } from "electron"; +import { contextBridge, ipcRenderer, Rectangle, WebviewTag } from "electron"; contextBridge.exposeInMainWorld("api", { getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), @@ -51,6 +51,8 @@ contextBridge.exposeInMainWorld("api", { sendLog: (log) => ipcRenderer.send("fe-log", log), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), + captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), + setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), }); // Custom event for "new-window" diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 7d359993fc..8a0b822a20 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -643,13 +643,11 @@ const BlockFrame = React.memo((props: BlockFrameProps) => { const blockId = props.nodeModel.blockId; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const tabData = jotai.useAtomValue(atoms.tabAtom); - if (!blockId || !blockData) { return null; } - const FrameElem = BlockFrame_Default; const numBlocks = tabData?.blockids?.length ?? 0; - return ; + return ; }); export { BlockFrame, NumActiveConnColors }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 0ee5ce87e8..4b4b6afee4 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -456,6 +456,9 @@ async function replaceBlock(blockId: string, blockDef: BlockDef): Promise { + await ObjectService.DeleteBlock(blockId); + }, 300); const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id; if (targetNodeId == null) { throw new Error(`targetNodeId not found for blockId: ${blockId}`); @@ -763,6 +766,7 @@ export { getBlockComponentModel, getBlockMetaKeyAtom, getConnStatusAtom, + getFocusedBlockId, getHostName, getObjectId, getOverrideConfigAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 6aa6401474..b673925584 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -10,9 +10,11 @@ import { getAllBlockComponentModels, getApi, getBlockComponentModel, + getFocusedBlockId, getSettingsKeyAtom, globalStore, refocusNode, + replaceBlock, WOS, } from "@/app/store/global"; import { @@ -23,12 +25,37 @@ import { } from "@/layout/index"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import * as keyutil from "@/util/keyutil"; +import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { fireAndForget } from "@/util/util"; import * as jotai from "jotai"; import { modalsModel } from "./modalmodel"; +type KeyHandler = (event: WaveKeyboardEvent) => boolean; + const simpleControlShiftAtom = jotai.atom(false); const globalKeyMap = new Map boolean>(); +const globalChordMap = new Map>(); + +// track current chord state and timeout (for resetting) +let activeChord: string | null = null; +let chordTimeout: NodeJS.Timeout = null; + +function resetChord() { + activeChord = null; + if (chordTimeout) { + clearTimeout(chordTimeout); + chordTimeout = null; + } +} + +function setActiveChord(activeChordArg: string) { + getApi().setKeyboardChordMode(); + if (chordTimeout) { + clearTimeout(chordTimeout); + } + activeChord = activeChordArg; + chordTimeout = setTimeout(() => resetChord(), CHORD_TIMEOUT); +} export function keyboardMouseDownHandler(e: MouseEvent) { if (!e.ctrlKey || !e.shiftKey) { @@ -69,7 +96,7 @@ function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean { 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")) { + if (activeElem.classList.contains("dummy-focus") || activeElem.classList.contains("dummy")) { return true; } if (keyutil.isInputEvent(e)) { @@ -216,38 +243,73 @@ async function handleCmdN() { await createBlock(blockDef); } -async function handleSplitHorizontal() { +async function handleSplitHorizontal(position: "before" | "after") { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } const blockDef = getDefaultNewBlockDef(); - await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, "after"); + await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position); } -async function handleSplitVertical() { +async function handleSplitVertical(position: "before" | "after") { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } const blockDef = getDefaultNewBlockDef(); - await createBlockSplitVertically(blockDef, focusedNode.data.blockId, "after"); + await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position); } let lastHandledEvent: KeyboardEvent | null = null; +// returns [keymatch, T] +function checkKeyMap(waveEvent: WaveKeyboardEvent, keyMap: Map): [string, T] { + for (const key of keyMap.keys()) { + if (keyutil.checkKeyPressed(waveEvent, key)) { + const val = keyMap.get(key); + return [key, val]; + } + } + return [null, null]; +} + function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { const nativeEvent = (waveEvent as any).nativeEvent; if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) { + console.log("lastHandledEvent return false"); return false; } lastHandledEvent = nativeEvent; - const handled = handleGlobalWaveKeyboardEvents(waveEvent); - if (handled) { + if (activeChord) { + console.log("handle activeChord", activeChord); + // If we're in chord mode, look for the second key. + const chordBindings = globalChordMap.get(activeChord); + const [, handler] = checkKeyMap(waveEvent, chordBindings); + if (handler) { + resetChord(); + return handler(waveEvent); + } else { + // invalid chord; reset state and consume key + resetChord(); + return true; + } + } + const [chordKeyMatch] = checkKeyMap(waveEvent, globalChordMap); + if (chordKeyMatch) { + setActiveChord(chordKeyMatch); return true; } + + const [, globalHandler] = checkKeyMap(waveEvent, globalKeyMap); + if (globalHandler) { + const handled = globalHandler(waveEvent); + if (handled) { + return true; + } + } const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); const blockId = focusedNode?.data?.blockId; @@ -319,11 +381,11 @@ function registerGlobalKeys() { return true; }); globalKeyMap.set("Cmd:d", () => { - handleSplitHorizontal(); + handleSplitHorizontal("after"); return true; }); globalKeyMap.set("Shift:Cmd:d", () => { - handleSplitVertical(); + handleSplitVertical("after"); return true; }); globalKeyMap.set("Cmd:i", () => { @@ -380,6 +442,18 @@ function registerGlobalKeys() { switchBlockInDirection(tabId, NavigateDirection.Right); return true; }); + globalKeyMap.set("Ctrl:Shift:k", () => { + const blockId = getFocusedBlockId(); + if (blockId == null) { + return true; + } + replaceBlock(blockId, { + meta: { + view: "launcher", + }, + }); + return true; + }); globalKeyMap.set("Cmd:g", () => { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); if (bcm.openSwitchConnection != null) { @@ -445,6 +519,25 @@ function registerGlobalKeys() { // special case keys, handled by web view allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); getApi().registerGlobalWebviewKeys(allKeys); + + const splitBlockKeys = new Map(); + splitBlockKeys.set("ArrowUp", () => { + handleSplitVertical("before"); + return true; + }); + splitBlockKeys.set("ArrowDown", () => { + handleSplitVertical("after"); + return true; + }); + splitBlockKeys.set("ArrowLeft", () => { + handleSplitHorizontal("before"); + return true; + }); + splitBlockKeys.set("ArrowRight", () => { + handleSplitHorizontal("after"); + return true; + }); + globalChordMap.set("Ctrl:Shift:s", splitBlockKeys); } function getAllGlobalKeyBindings(): string[] { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 1c6903104e..f27f4d0ee9 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -232,6 +232,11 @@ class RpcApiType { return client.wshRpcCall("getmeta", data, opts); } + // command "gettab" [call] + GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("gettab", data, opts); + } + // command "getupdatechannel" [call] GetUpdateChannelCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("getupdatechannel", null, opts); diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index b0c311f949..ceb87b32c1 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -212,7 +212,7 @@ const LauncherView: React.FC> = ({ blockId value={searchTerm} onKeyDown={keydownWrapper(model.keyDownHandler.bind(model))} onChange={(e) => setSearchTerm(e.target.value)} - className="sr-only" + className="sr-only dummy" aria-label="Search widgets" /> diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 70a7777618..4f2960b3c5 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -101,6 +101,8 @@ declare global { sendLog: (log: string) => void; onQuicklook: (filePath: string) => void; openNativePath(filePath: string): void; + captureScreenshot(rect: Electron.Rectangle): Promise; + setKeyboardChordMode: () => void; }; type ElectronContextMenuItem = { diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index f6558e3cca..867dfcb4e2 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -31,6 +31,35 @@ function keydownWrapper( }; } +function waveEventToKeyDesc(waveEvent: WaveKeyboardEvent): string { + let keyDesc: string[] = []; + if (waveEvent.cmd) { + keyDesc.push("Cmd"); + } + if (waveEvent.option) { + keyDesc.push("Option"); + } + if (waveEvent.meta) { + keyDesc.push("Meta"); + } + if (waveEvent.control) { + keyDesc.push("Ctrl"); + } + if (waveEvent.shift) { + keyDesc.push("Shift"); + } + if (waveEvent.key != null && waveEvent.key != "") { + if (waveEvent.key == " ") { + keyDesc.push("Space"); + } else { + keyDesc.push(waveEvent.key); + } + } else { + keyDesc.push("c{" + waveEvent.code + "}"); + } + return keyDesc.join(":"); +} + function parseKey(key: string): { key: string; type: string } { let regexMatch = key.match(KeyTypeCodeRegex); if (regexMatch != null && regexMatch.length > 1) { @@ -183,7 +212,7 @@ function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): bool } if (keyPress.keyType == KeyTypeKey) { eventKey = event.key; - if (eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) { + if (eventKey != null && eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) { // key is upper case A-Z, this means shift is applied, we want to allow // "Shift:e" as well as "Shift:E" or "E" eventKey = eventKey.toLocaleLowerCase(); @@ -303,4 +332,5 @@ export { keydownWrapper, parseKeyDescription, setKeyUtilPlatform, + waveEventToKeyDesc, }; diff --git a/frontend/util/sharedconst.ts b/frontend/util/sharedconst.ts new file mode 100644 index 0000000000..cac939d8c7 --- /dev/null +++ b/frontend/util/sharedconst.ts @@ -0,0 +1,4 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export const CHORD_TIMEOUT = 2000; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 6fdbaf7473..034365eecb 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -284,6 +284,12 @@ func GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wsh return resp, err } +// command "gettab", wshserver.GetTabCommand +func GetTabCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*waveobj.Tab, error) { + resp, err := sendRpcRequestCallHelper[*waveobj.Tab](w, "gettab", data, opts) + return resp, err +} + // command "getupdatechannel", wshserver.GetUpdateChannelCommand func GetUpdateChannelCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "getupdatechannel", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index bca78a4ad5..8dc06a894b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -194,6 +194,7 @@ type WshRpcInterface interface { PathCommand(ctx context.Context, data PathCommandData) (string, error) SendTelemetryCommand(ctx context.Context) error FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error) + GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) // connection functions ConnStatusCommand(ctx context.Context) ([]ConnStatus, error) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 9db25050a7..84af61fd2b 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -914,3 +914,11 @@ func (ws *WshServer) PathCommand(ctx context.Context, data wshrpc.PathCommandDat func (ws *WshServer) FetchSuggestionsCommand(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { return suggestion.FetchSuggestions(ctx, data) } + +func (ws *WshServer) GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error) { + tab, err := wstore.DBGet[*waveobj.Tab](ctx, tabId) + if err != nil { + return nil, fmt.Errorf("error getting tab: %w", err) + } + return tab, nil +}