@teaui/core
Version:
A high-level terminal UI library for Node
390 lines • 12.1 kB
JavaScript
import { Terminal as TermTerminal, cursorTo, isKeyEvent, isMouseEvent, isPasteEvent, isFocusEvent, } from '@teaui/term';
import { Size } from './geometry.js';
import { View } from './View.js';
import { Viewport } from './Viewport.js';
import { Buffer } from './Buffer.js';
import { translateTermKeyEvent, translateTermMouseEvent, } from './events/translate.js';
import { FocusManager } from './managers/FocusManager.js';
import { ModalManager } from './managers/ModalManager.js';
import { MouseManager } from './managers/MouseManager.js';
import { TickManager } from './managers/TickManager.js';
import { Window } from './components/Window.js';
import { UnboundSystem } from './System.js';
// --- TerminalProgram: adapter wrapping @teaui/term's Terminal ---
/**
* Wraps @teaui/term's Terminal for use by Screen and the public API.
* Translates low-level terminal input into SystemEvents that Screen can consume.
*/
export class TerminalProgram {
#terminal;
constructor() {
this.#terminal = new TermTerminal();
this.#terminal.enableWriteBuffer();
}
get terminal() {
return this.#terminal;
}
// --- SGRTerminal interface ---
get cols() {
return this.#terminal.cols;
}
get rows() {
return this.#terminal.rows;
}
move(x, y) {
this.#terminal.write(cursorTo(x, y));
}
write(str) {
this.#terminal.write(str);
}
flush() {
this.#terminal.flushWrites();
}
// --- Lifecycle ---
setup() {
this.#terminal.enterFullscreen({
mouse: true,
hideCursor: true,
focusEvents: true,
});
this.#terminal.clear();
}
teardown() {
this.#terminal.clear();
this.#terminal.exitFullscreen();
}
// --- Events ---
/**
* Subscribe to translated system events from terminal input.
* Returns an unsubscribe function.
*/
onEvents(listener) {
return this.#terminal.onInput((event) => {
if (isFocusEvent(event)) {
listener({ type: event.focused ? 'focus' : 'blur' });
return;
}
if (isKeyEvent(event)) {
listener(translateTermKeyEvent(event));
return;
}
if (isPasteEvent(event)) {
listener({ type: 'paste', text: event.text });
return;
}
if (isMouseEvent(event)) {
const mouseEvent = translateTermMouseEvent(event);
if (mouseEvent) {
listener(mouseEvent);
}
return;
}
});
}
/**
* Subscribe to terminal resize events.
* Returns an unsubscribe function.
*/
onResize(listener) {
return this.#terminal.onResize(() => listener());
}
/**
* Listen for raw data once (for iTerm2 proprietary escape sequences, etc.)
*/
onceRawData(fn) {
this.#terminal.onceRawData(fn);
}
}
export class Screen {
#program;
#onExit;
#keyListeners = [];
#cleanupEvents;
#cleanupResize;
#isFocused;
rootView;
#buffer;
#focusManager = new FocusManager();
#modalManager = new ModalManager();
#mouseManager = new MouseManager();
#tickManager = new TickManager(() => this.render());
#eventListeners = {
focusChange: new Set(),
};
/**
* Start the TeaUI application. Expects a root node (I recommend Window, it
* consumes all the available screen space) *or* an async function that creates the
* root node, and accepts a small amount of options.
*
* @return the Screen, the TerminalProgram that controls the terminal, and the root node
* instance.
*/
static async start(viewConstructor = new Window(), opts) {
opts ??= {};
opts = {
quitChar: 'C-c',
...opts,
};
const program = new TerminalProgram();
program.setup();
const rootView = viewConstructor instanceof View
? viewConstructor
: await viewConstructor(program);
if (opts.emoji !== undefined) {
rootView.purpose = rootView.purpose.merge({ emoji: opts.emoji });
}
const screen = new Screen(program, rootView);
screen.onExit(() => {
program.teardown();
});
if (opts.quitChar) {
screen.key(opts.quitChar, () => {
screen.exit();
});
}
screen.start();
return [screen, program, rootView];
}
constructor(program, rootView, { isFocused = true } = {}) {
this.#program = program;
this.#buffer = new Buffer();
this.rootView = rootView;
this.#isFocused = isFocused;
}
onExit(callback) {
if (this.#onExit) {
const prev = this.#onExit;
this.#onExit = () => {
prev();
callback();
};
}
else {
this.#onExit = callback;
}
}
/**
* Register a key binding on the screen.
* Pattern: 'escape', 'C-c', 'C-q', 'return', etc.
*/
key(pattern, fn) {
const patterns = Array.isArray(pattern) ? pattern : [pattern];
for (const p of patterns) {
this.#keyListeners.push({ pattern: p, fn });
}
}
/**
* Called from Screen.start(). Don't call this yourself unless you wanted
* to construct your own 'program'. I recommend starting with a
* copy of the implementation of Screen.start.
*/
start() {
this.rootView.moveToScreen(this);
this.#cleanupEvents = this.#program.onEvents(event => {
if (event.type === 'key') {
for (const { pattern, fn } of this.#keyListeners) {
if (matchKeyPattern(pattern, event)) {
fn(event.char, event);
}
}
}
this.trigger(event);
});
this.#cleanupResize = this.#program.onResize(() => {
this.trigger({ type: 'resize' });
});
this.render();
}
/**
* Puts the screen back in normal terminal mode, restores the normal buffer
*/
stop() {
this.#tickManager.stop();
this.rootView.moveToScreen(undefined);
this.#cleanupEvents?.();
this.#cleanupResize?.();
this.#cleanupEvents = undefined;
this.#cleanupResize = undefined;
this.#onExit?.();
}
/**
* Stops (putting the screen back in normal mode and buffer) and exits by emitting
* process.exit(0)
*/
exit() {
this.stop();
setTimeout(() => {
process.exit(0);
}, 0);
}
trigger(event) {
switch (event.type) {
case 'resize':
case 'focus':
case 'blur':
break;
case 'key':
this.triggerKeyboard(event);
break;
case 'paste':
this.triggerPaste(event.text);
break;
case 'mouse': {
this.triggerMouse(event);
break;
}
}
this.render();
}
/**
* Requests a modal to be presented. The modal is pushed onto a stack and
* rendered after the main view tree. Multiple modals can be stacked.
*/
requestModal(modal, rect) {
return this.#modalManager.requestModal(modal, rect);
}
/**
* @return boolean Whether the current view has focus
*/
registerFocus(view, isDefault) {
return this.#focusManager.registerFocus(view, isDefault);
}
registerHotKey(view, key) {
return this.#focusManager.registerHotKey(view, key);
}
registerKeyboard(view) {
return this.#focusManager.registerKeyboard(view);
}
requestFocus(view) {
return this.#focusManager.requestFocus(view);
}
get currentFocusView() {
return this.#focusManager.currentFocusView;
}
get hotKeyViews() {
return this.#focusManager.hotKeyViews;
}
/**
* Subscribe to a screen event. Returns an unsubscribe function.
*/
on(event, listener) {
this.#eventListeners[event].add(listener);
return () => {
this.#eventListeners[event].delete(listener);
};
}
#emit(event, ...args) {
for (const listener of this.#eventListeners[event]) {
;
listener(...args);
}
}
triggerKeyboard(event) {
event = translateKeyEvent(event);
this.#focusManager.trigger(event);
}
triggerPaste(text) {
this.#focusManager.triggerPaste(text);
}
/**
* @see MouseManager.registerMouse
*/
registerMouse(view, offset, point, eventNames) {
this.#mouseManager.registerMouse(view, offset, point, eventNames);
}
checkMouse(view, x, y) {
this.#mouseManager.checkMouse(view, x, y);
}
triggerMouse(systemEvent) {
const system = new UnboundSystem(this.#focusManager);
this.#mouseManager.trigger(systemEvent, system);
}
registerTick(view) {
this.#tickManager.registerTick(view);
}
/**
* Manually advance tick animations by `dt` milliseconds.
* Useful for testing animations without real timers.
*/
tick(dt) {
this.#tickManager.triggerTick(dt);
}
preRender(view) {
if (this.#focusManager.determineFocus()) {
this.#emit('focusChange', this.#focusManager.currentFocusView);
}
this.#modalManager.reset();
this.#tickManager.reset();
this.#mouseManager.reset();
this.#focusManager.reset(view === this.rootView);
if (!this.#isFocused) {
this.#focusManager.unfocus();
}
}
/**
* @return boolean Whether or not to rerender the view due to focus or mouse change
*/
commit() {
const system = new UnboundSystem(this.#focusManager);
const focusNeedsRender = this.#focusManager.commit();
const mouseNeedsRender = this.#mouseManager.commit(system);
if (focusNeedsRender) {
this.#emit('focusChange', this.#focusManager.currentFocusView);
}
return focusNeedsRender || mouseNeedsRender;
}
needsRender() {
this.#tickManager.needsRender();
}
render() {
const screenSize = new Size(this.#program.cols, this.#program.rows);
this.#buffer.resize(screenSize);
// this may be called again by renderModals, before the last modal renders
this.preRender(this.rootView);
const viewport = new Viewport(this, this.#buffer, screenSize);
this.rootView.render(viewport);
const rerenderView = this.#modalManager.renderModals(this, viewport);
const needsRerender = this.commit();
// one -and only one- re-render if a change is detected to focus or mouse-hover
if (needsRerender) {
rerenderView.render(viewport);
}
this.#tickManager.endRender();
this.#buffer.flush(this.#program);
}
}
function matchKeyPattern(pattern, event) {
return event.full === pattern;
}
/**
* These are mostly due to my own terminal keybindings; would be better to have
* these configured in some .rc file.
*/
function translateKeyEvent(event) {
if (event.full === 'A-b') {
return {
type: 'key',
full: 'A-left',
name: 'left',
ctrl: false,
alt: true,
gui: false,
shift: false,
char: '1;9D',
};
}
if (event.full === 'A-f') {
return {
type: 'key',
full: 'A-right',
name: 'right',
ctrl: false,
alt: true,
gui: false,
shift: false,
char: '1;9C',
};
}
return event;
}
//# sourceMappingURL=Screen.js.map