Skip to content

Commit

Permalink
Features/keybind (#19)
Browse files Browse the repository at this point in the history
* temp keybind implementation

* react to keybinds
  • Loading branch information
atinylittleshell committed Oct 27, 2023
1 parent 033e93a commit d746ff8
Show file tree
Hide file tree
Showing 11 changed files with 256 additions and 18 deletions.
4 changes: 3 additions & 1 deletion apps/app/src/nativeBridge/modules/common/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,23 @@ export class Logger extends EventEmitter {

this.logPath = path.join(getAppDirs().userData, 'logs.log');
this.winstonLogger = winston.createLogger({
level: 'info',
format: logFormat,
transports: app.isPackaged
? [
new winston.transports.File({
level: 'info',
filename: this.logPath,
format: winston.format.combine(logFormat, winston.format.json()),
}),
]
: [
new winston.transports.File({
level: 'info',
filename: this.logPath,
format: winston.format.combine(logFormat, winston.format.json()),
}),
new winston.transports.Console({
level: 'debug',
format: winston.format.combine(
winston.format.colorize(),
logFormat,
Expand Down
46 changes: 33 additions & 13 deletions apps/terminal/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
import { DEFAULT_CONFIG } from '@terminalone/types';
import _ from 'lodash';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { FiMenu, FiPlus, FiX } from 'react-icons/fi';

import SettingsPage from '../components/SettingsPage';
import ShellSelector from '../components/ShellSelector';
import { useConfigContext } from '../hooks/ConfigContext';
import { useKeybindContext } from '../hooks/KeybindContext';

const TitleBar = dynamic(() => import('../components/TitleBar'), {
ssr: false,
Expand All @@ -25,10 +26,39 @@ type UserTab = {

const Page = () => {
const { config, loading } = useConfigContext();
const { commands } = useKeybindContext();

const [tabId, setTabId] = useState<number>(0);
const [userTabs, setUserTabs] = useState<UserTab[]>([]);

const createTab = useCallback(() => {
const newTabId = (_.max(userTabs.map((t) => t.tabId)) || 0) + 1;
setUserTabs([
...userTabs,
{
tabId: newTabId,
shellName: config.shells.length === 1 ? config.defaultShellName : null,
},
]);
setTabId(newTabId);
}, [userTabs, config]);

const closeTab = useCallback(() => {
const newTabs = userTabs.filter((t) => t.tabId !== tabId);
setUserTabs(newTabs);
setTabId(_.max(newTabs.map((t) => t.tabId)) || 0);
}, [userTabs, tabId]);

useEffect(() => {
commands.on('createTab', createTab);
commands.on('closeTab', closeTab);

return () => {
commands.off('createTab', createTab);
commands.off('closeTab', closeTab);
};
}, [commands, createTab, closeTab]);

useEffect(() => {
if (!loading && userTabs.length === 0) {
setTabId(1);
Expand Down Expand Up @@ -84,9 +114,7 @@ const Page = () => {
<button
className="btn btn-ghost btn-square btn-xs opacity-50 hover:bg-transparent hover:opacity-100 ml-2"
onClick={() => {
const newTabs = userTabs.filter((t) => t.tabId !== userTab.tabId);
setUserTabs(newTabs);
setTabId(_.max(newTabs.map((t) => t.tabId)) || 0);
closeTab();
}}
>
<FiX />
Expand All @@ -97,15 +125,7 @@ const Page = () => {
<a
className="tab tab-lifted"
onClick={() => {
const newTabId = (_.max(userTabs.map((t) => t.tabId)) || 0) + 1;
setUserTabs([
...userTabs,
{
tabId: newTabId,
shellName: config.shells.length === 1 ? config.defaultShellName : null,
},
]);
setTabId(newTabId);
createTab();
}}
>
<FiPlus />
Expand Down
7 changes: 6 additions & 1 deletion apps/terminal/components/ClientSideProviders/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
import { PropsWithChildren } from 'react';

import { ConfigContextProvider } from '../../hooks/ConfigContext';
import { KeybindContextProvider } from '../../hooks/KeybindContext';

const ClientSideProviders = (props: PropsWithChildren) => {
return <ConfigContextProvider>{props.children}</ConfigContextProvider>;
return (
<ConfigContextProvider>
<KeybindContextProvider>{props.children}</KeybindContextProvider>
</ConfigContextProvider>
);
};

export default ClientSideProviders;
8 changes: 7 additions & 1 deletion apps/terminal/components/Terminal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { WebLinksAddon } from 'xterm-addon-web-links';
import { WebglAddon } from 'xterm-addon-webgl';

import { useConfigContext } from '../../hooks/ConfigContext';
import { useKeybindContext } from '../../hooks/KeybindContext';

let nextId = 0;

const Terminal = ({ active, shellName }: { active: boolean; shellName: string }) => {
const { config, loading } = useConfigContext();
const { handleKey } = useKeybindContext();
const terminalRef = useRef<HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -117,6 +119,10 @@ const Terminal = ({ active, shellName }: { active: boolean; shellName: string })
};
xtermDiv.addEventListener('contextmenu', contextMenuListener);

xterm.attachCustomKeyEventHandler((event) => {
return handleKey(event);
});

return () => {
window.removeEventListener('resize', resizeListener);
xtermDiv.removeEventListener('focus', focusListener);
Expand All @@ -126,7 +132,7 @@ const Terminal = ({ active, shellName }: { active: boolean; shellName: string })
window.TerminalOne?.terminal?.killTerminal(terminalId);
xterm.dispose();
};
}, [terminalRef, shellName, config, loading]);
}, [terminalRef, shellName, config, loading, handleKey]);

useEffect(() => {
if (!terminalRef.current) {
Expand Down
2 changes: 1 addition & 1 deletion apps/terminal/hooks/ConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const DEFAULT_CONFIG_CONTEXT_DATA: IConfigContextData = {

const ConfigContext = createContext<IConfigContextData>(DEFAULT_CONFIG_CONTEXT_DATA);

export const ConfigContextProvider = (props: React.PropsWithChildren<{}>) => {
export const ConfigContextProvider = (props: React.PropsWithChildren) => {
const [data, setData] = useState<IConfigContextData>(DEFAULT_CONFIG_CONTEXT_DATA);

useEffect(() => {
Expand Down
178 changes: 178 additions & 0 deletions apps/terminal/hooks/KeybindContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
'use client';

import { KeybindCommand } from '@terminalone/types';
import EventEmitter from 'eventemitter3';
import _ from 'lodash';
import React, { createContext, useCallback, useContext, useMemo } from 'react';

import { useConfigContext } from './ConfigContext';

type KeybindContextData = {
commands: EventEmitter<KeybindCommand>;
handleKey: (event: KeyboardEvent) => boolean;
};

type KeybindConfig = {
keybindLeader: string;
keybindLookup: Record<string, KeybindCommand>;
};

const KEY_REPLACEMENTS: Record<string, string> = {
Control: 'ctrl',
};
const MODIFIER_KEYS = new Set(['alt', 'ctrl', 'meta', 'shift']);

const keybindCommandEmitter = new EventEmitter<KeybindCommand>();
const keyState: Record<string, boolean> = {};
let lastLeaderKeyEvent: KeyboardEvent | null = null;

const isLeaderActive = (event: KeyboardEvent) => {
return lastLeaderKeyEvent && lastLeaderKeyEvent.timeStamp + 1000 > event.timeStamp;
};

const describeKey = (event: KeyboardEvent) => {
const { altKey, ctrlKey, metaKey, shiftKey, key } = event;
const modifiers = [altKey && 'alt', ctrlKey && 'ctrl', metaKey && 'meta', shiftKey && 'shift'].filter(Boolean);

return normalizeKeyDescriptor(modifiers.length > 0 ? `${modifiers.join('+')}+${key}` : key);
};

// return value means whether the key event should be propagated to the terminal
const keyHandler = (event: KeyboardEvent, config: KeybindConfig) => {
const rawKey = event.key;
const type = event.type;
const key = normalizeSingleKey(rawKey);
const keyDescriptor = describeKey(event);

const logDebug = (dest: string, reason: string) => {
window.TerminalOne?.app.log('debug', `${type}:${keyDescriptor}:${dest} ${reason}`);
};

logDebug('RAW', 'user input');

// any events that happened during IME composition should not trigger TerminalOne keybinds.
// so we just should pass them through to the actual terminal.
if (event.isComposing) {
return true;
}

if (MODIFIER_KEYS.has(key)) {
// modifier keys should always be passed through to the terminal
logDebug('TERM', 'modifier key');
return true;
}

if (type === 'keydown') {
if (keyDescriptor === config.keybindLeader) {
if (isLeaderActive(event)) {
// double leader key press means we should "escape" and cancel the active leader key,
// and pass this latest leader key as a normal key stroke to the terminal.
lastLeaderKeyEvent = null;
keyState[key] = true;
logDebug('TERM', 'double leader key');
return true;
} else {
// first leader key press simply activates other keybinds. it should not be passed to the terminal.
lastLeaderKeyEvent = event;
logDebug('HOST', 'leader key');
return false;
}
}

if (isLeaderActive(event)) {
const command = config.keybindLookup[keyDescriptor];

if (command) {
// the key event maps to a known keybind. we should emit the command and not pass the key to the terminal.
keybindCommandEmitter.emit(command);
logDebug('HOST', 'keybind');
return false;
} else {
// we should cancel the active leader key and pass this unbound key to the terminal.
lastLeaderKeyEvent = null;
keyState[key] = true;
logDebug('TERM', 'unbound key');
return true;
}
} else {
// leader key was not active, which means we should not trigger any other keybinds yet.
// we should pass the key to the terminal.
keyState[key] = true;
logDebug('TERM', 'leader inactive');
return true;
}
}

const isSameKeyDown = Boolean(keyState[key]);
if (type === 'keyup') {
keyState[key] = false;
}

// if the key was considered pressed down, it means we had passed the keydown event to the terminal.
// in that case, we should let keypress and keyup events on the same key pass through to the terminal as well.
// when this is false, it means TerminalOne had "eaten" the keydown event as part of a keybind.
logDebug(isSameKeyDown ? 'TERM' : 'HOST', 'follow keydown event');
return isSameKeyDown;
};

const normalizeSingleKey = (rawKey: string) => {
const key = KEY_REPLACEMENTS[rawKey] || rawKey;
if (key.length > 1 || key < 'A' || key > 'Z') {
return key.toLowerCase();
}
return key;
};

const normalizeKeyDescriptor = (keyDescriptor: string) => {
const items = keyDescriptor.split('+').map(normalizeSingleKey);
const modifiers = _.uniq(items.filter((item) => MODIFIER_KEYS.has(item)).sort());
const keys = items.filter((item) => !MODIFIER_KEYS.has(item));
return [...modifiers, ...keys].join('+');
};

const KeybindContext = createContext<KeybindContextData>({
commands: keybindCommandEmitter,
handleKey: () => true,
});

export const KeybindContextProvider = (props: React.PropsWithChildren) => {
const { config } = useConfigContext();

const keybindConfig = useMemo<KeybindConfig>(() => {
const result: KeybindConfig = {
keybindLeader: normalizeKeyDescriptor(config.keybindLeader || 'ctrl+b'),
keybindLookup: {},
};

Object.keys(config.keybinds).forEach((commandValue) => {
const command = commandValue as KeybindCommand;
const key = config.keybinds[command];
if (!key) return;

result.keybindLookup[normalizeKeyDescriptor(key)] = command;
});

return result;
}, [config]);

const handleKey = useCallback(
(event: KeyboardEvent) => {
return keyHandler(event, keybindConfig);
},
[keybindConfig],
);

const value = useMemo(
() => ({
commands: keybindCommandEmitter,
handleKey,
}),
[handleKey],
);

return <KeybindContext.Provider value={value}>{props.children}</KeybindContext.Provider>;
};

export const useKeybindContext = () => {
return useContext(KeybindContext);
};
1 change: 1 addition & 0 deletions apps/terminal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"autoprefixer": "^10.4.14",
"daisyui": "^2.51.5",
"dayjs": "^1.11.7",
"eventemitter3": "^5.0.1",
"next": "^13.3.0",
"postcss": "^8.4.22",
"react": "^18.2.0",
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@terminalone/monorepo",
"productName": "Terminal One",
"version": "0.7.2",
"version": "0.8.0",
"description": "A fast, elegant and intelligent cross-platform terminal.",
"author": "atinylittleshell <[email protected]>",
"license": "MIT",
Expand Down
Loading

0 comments on commit d746ff8

Please sign in to comment.