UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

390 lines 12.1 kB
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