Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/keybind #19

Merged
merged 2 commits into from
Oct 27, 2023
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
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