shrub/pkg/interface/webterm/Buffer.tsx
fang 064b15e5a0
term: move coordinate origin to top left
Having the origin at the top left instead of the bottom left is more
conventional and ergonomic. The only thing this complicates is
prompt-specific logic, where we care about the coordinates of the
bottom-most line on the screen.

For that reason, the bulk of the changes here are in vere, where we
treat the bottom-most line specially, drawing the spinner onto it.
Webterm is likewise updated to account for the new coordinate system.

Drum now opts to accept clicks anywhere on the screen, and does its best
to move the cursor as close to the clicked location as possible (within
the confines of the prompt).
2022-04-03 21:38:09 +02:00

293 lines
7.7 KiB
TypeScript

import { Terminal, ITerminalOptions } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import bel from './lib/bel';
import api from './api';
import {
Belt, pokeTask, pokeBelt
} from '@urbit/api/term';
import { Session } from './state';
import { useCallback, useEffect, useRef } from 'react';
import useTermState from './state';
import React from 'react';
import { Box, Col } from '@tlon/indigo-react';
import { makeTheme } from './lib/theme';
import { showBlit, csi, hasBell } from './lib/blit';
import { DEFAULT_SESSION } from './constants';
import { retry } from './lib/retry';
const termConfig: ITerminalOptions = {
logLevel: 'warn',
//
convertEol: true,
//
rows: 24,
cols: 80,
scrollback: 10000,
//
fontFamily: '"Source Code Pro", "Roboto mono", "Courier New", monospace',
fontWeight: 400,
// NOTE theme colors configured dynamically
//
bellStyle: 'sound',
bellSound: bel,
//
// allows text selection by holding modifier (option, or shift)
macOptionClickForcesSelection: true
};
const readInput = (term: Terminal, e: string): Belt[] => {
const belts: Belt[] = [];
let strap = '';
while (e.length > 0) {
let c = e.charCodeAt(0);
// text input
//
if (c >= 32 && c !== 127) {
strap += e[0];
e = e.slice(1);
continue;
} else if ('' !== strap) {
belts.push({ txt: strap.split('') });
strap = '';
}
// special keys/characters
//
if (0 === c) {
term.write('\x07'); // bel
} else if (8 === c || 127 === c) {
belts.push({ bac: null });
} else if (13 === c) {
belts.push({ ret: null });
} else if (c <= 26) {
const k = String.fromCharCode(96 + c);
//NOTE prevent remote shut-downs
if ('d' !== k) {
belts.push({ mod: { mod: 'ctl', key: k } });
}
}
// escape sequences
//
if (27 === c) { // ESC
e = e.slice(1);
c = e.charCodeAt(0);
if (91 === c || 79 === c) { // [ or O
e = e.slice(1);
c = e.charCodeAt(0);
/* eslint-disable max-statements-per-line */
switch (c) {
case 65: belts.push({ aro: 'u' }); break;
case 66: belts.push({ aro: 'd' }); break;
case 67: belts.push({ aro: 'r' }); break;
case 68: belts.push({ aro: 'l' }); break;
//
case 77: {
const m = e.charCodeAt(1) - 31;
if (1 === m) {
const c = e.charCodeAt(2) - 32;
const r = e.charCodeAt(3) - 32;
belts.push({ hit: { y: r - 1, x: c - 1 } });
}
e = e.slice(3);
break;
}
//
default: term.write('\x07'); break; // bel
}
} else if (c >= 97 && c <= 122) { // a <= c <= z
belts.push({ mod: { mod: 'met', key: e[0] } });
} else if (c === 46) { // .
belts.push({ mod: { mod: 'met', key: '.' } });
} else if (c === 8 || c === 127) {
belts.push({ mod: { mod: 'met', key: { bac: null } } });
} else {
term.write('\x07'); break; // bel
}
}
e = e.slice(1);
}
if ('' !== strap) {
belts.push({ txt: strap.split('') });
strap = '';
}
return belts;
};
const onResize = (session: Session) => () => {
//TODO debounce, if it ever becomes a problem
//TODO test that we only send this to the selected session,
// and that we *do* send it on-selected-change if necessary.
session?.fit.fit();
};
const onInput = (name: string, session: Session, e: string) => {
if (!session) {
return;
}
const term = session.term;
const belts = readInput(term, e);
belts.map((b) => {
api.poke(pokeBelt(name, b));
});
};
interface BufferProps {
name: string,
selected: boolean,
dark: boolean,
}
export default function Buffer({ name, selected, dark }: BufferProps) {
const container = useRef<HTMLDivElement>(null);
const session: Session = useTermState(s => s.sessions[name]);
const initSession = useCallback(async (name: string, dark: boolean) => {
console.log('setting up', name === DEFAULT_SESSION ? 'default' : name);
// set up xterm terminal
//
const term = new Terminal(termConfig);
term.setOption('theme', makeTheme(dark));
const fit = new FitAddon();
term.loadAddon(fit);
fit.fit();
term.focus();
// start mouse reporting
//
term.write(csi('?9h'));
const ses: Session = { term, fit, hasBell: false, subscriptionId: null };
// set up event handlers
//
term.onData(e => onInput(name, ses, e));
term.onBinary(e => onInput(name, ses, e));
term.onResize((e) => {
api.poke(pokeTask(name, { blew: { w: e.cols, h: e.rows } }));
});
// open subscription
//
const initSubscription = async () => {
const subscriptionId = await api.subscribe({
app: 'herm', path: '/session/' + name + '/view',
event: (e) => {
showBlit(ses.term, e);
//NOTE getting selected from state because selected prop is stale
if (hasBell(e) && (useTermState.getState().selected !== name)) {
useTermState.getState().set((state) => {
state.sessions[name].hasBell = true;
});
}
//TODO should handle %bye on this higher level though, for deletion
},
err: (e, id) => {
console.log(`subscription error, id ${id}:`, e);
},
quit: async () => { // quit
console.error('quit, reconnecting...');
try {
const newSubscriptionId = await retry(initSubscription, () => {
console.log('attempting to reconnect ...');
}, 5);
useTermState.getState().set((state) => {
state.sessions[name].subscriptionId = newSubscriptionId;
});
} catch (error) {
console.log('unable to reconnect', error);
}
}
});
return subscriptionId;
};
ses.subscriptionId = await initSubscription();
useTermState.getState().set((state) => {
state.sessions[name] = ses;
});
}, []);
// init session
useEffect(() => {
if(session) {
return;
}
initSession(name, dark);
}, [name]);
// on selected change, maybe setup the term, or put it into the container
//
const setContainer = useCallback((containerRef: HTMLDivElement | null) => {
const newContainer = containerRef || container.current;
if(session && newContainer) {
container.current = newContainer;
}
}, [session]);
// on-init, open slogstream and fetch existing sessions
//
useEffect(() => {
window.addEventListener('resize', onResize(session));
return () => {
// TODO clean up subs?
window.removeEventListener('resize', onResize(session));
};
}, []);
// on dark mode change, change terminals' theme
//
useEffect(() => {
const theme = makeTheme(dark);
if (session) {
session.term.setOption('theme', theme);
}
if (container.current) {
container.current.style.backgroundColor = theme.background || '';
}
}, [session, dark]);
useEffect(() => {
if (session && selected && !session.term.isOpen) {
session.term.open(container.current);
session.fit.fit();
session.term.focus();
session.term.isOpen = true;
}
}, [selected, session]);
return (
!session && !selected ?
<p>Loading...</p>
:
<Box
width='100%'
height='100%'
bg='white'
fontFamily='mono'
overflow='hidden'
style={selected ? {} : { display: 'none' }}
>
<Col
width='100%'
height='100%'
minHeight='0'
px={['0','2']}
pb={['0','2']}
ref={setContainer}
>
</Col>
</Box>
);
}