UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

367 lines 14.6 kB
import * as unicode from '@teaui/term'; import { RESET, BG_DRAW } from './ansi.js'; import { Style } from './Style.js'; import { Rect, Point, Size } from './geometry.js'; import { define } from './util.js'; /** * Defines a region (contentSize) in which to draw, and a subset (visibleRect) that * is on-screen. Anything not in the visibleRect is considered invisible (and any * drawing outside the rect will be clipped) */ export class Viewport { #terminal; #currentRender = null; #contentSize; #availableRect; #visibleRect; #offset; #screen; #style; /** * For modals, this is the Rect of the view that presented the modal, in absolute * coordinates */ parentRect; constructor(screen, terminal, contentSize) { const rect = new Rect(Point.zero, contentSize); this.#terminal = terminal; this.#screen = screen; this.#contentSize = contentSize; this.#availableRect = rect; this.parentRect = rect; this.#visibleRect = rect; this.#offset = Point.zero; this.#style = Style.NONE; // control visibility of props for inspect(viewport) define(this, 'contentSize', { enumerable: true }); define(this, 'contentRect', { enumerable: true }); define(this, 'visibleRect', { enumerable: true }); } /** * during render, `contentSize` is what you should use for laying out your * rectangles. in most cases this is synonymous with "visible" area, but not * always. */ get contentSize() { return this.#contentSize; } /** * In most cases, `availableRect` is synonymous with `contentRect`, but in some * rare cases (e.g. drawing on top of box borders) the `availableRect` is larger. * The available Rect will always be positioned outside or overlapping the * `contentRect`, i.e. * availableRect.origin.x <= 0 * availableRect.origin.y <= 0 * availableRect.size.width >= contentRect.size.width * availableRect.size.height >= contentRect.size.height */ get availableRect() { return this.#availableRect; } /* * `visibleRect` can be used to optimize drawing. `visibleRect.origin` * represents the first visible point, taking clipping into account. */ get visibleRect() { return this.#visibleRect; } /* * `contentRect` is a convenience property, useful for creating clipped inner * regions. origin is always [0, 0] and size is contentSize. */ get contentRect() { return new Rect(Point.zero, this.#contentSize); } get isEmpty() { return this.#contentSize.isEmpty; } /** * Request that a modal be presented above the current view tree. * The modal receives `presentedRect` (this view's absolute rect) and * `windowSize` (the full screen size) before rendering. * * @return boolean Whether the modal creation was successful */ requestModal(modal) { if (!this.#currentRender) { return false; } return this.#screen.requestModal(modal, new Rect(this.#offset, this.#contentSize)); } registerHotKey(key) { if (!this.#currentRender) { return; } this.#screen.registerHotKey(this.#currentRender, key); } /** * Registers the current view as a fallback keyboard listener. Key events that * aren't consumed by hotkeys or a focused view are sent to the innermost * (last registered) keyboard listener. */ registerKeyboard() { if (!this.#currentRender) { return; } this.#screen.registerKeyboard(this.#currentRender); } /** * @return boolean Whether the current render target is the focus view */ registerFocus(opts) { if (!this.#currentRender) { return false; } return this.#screen.registerFocus(this.#currentRender, opts?.isDefault ?? true); } /** * @see MouseManager.registerMouse */ registerMouse(eventNames, rect) { if (!this.#currentRender || this.#currentRender.screen !== this.#screen) { return; } if (rect) { rect = this.#visibleRect.intersection(rect); } else { rect = this.#visibleRect; } const maxX = rect.maxX(); const maxY = rect.maxY(); const events = typeof eventNames === 'string' ? [eventNames] : eventNames; for (let y = rect.minY(); y < maxY; ++y) for (let x = rect.minX(); x < maxX; ++x) { this.#screen.registerMouse(this.#currentRender, this.#offset, new Point(x, y), events); } } registerTick() { if (!this.#currentRender) { return; } this.#screen.registerTick(this.#currentRender); } /** * Clears out, and optionally "paints" default foreground/background colors. If no * region is provided, the entire visibleRect is painted. */ paint(defaultStyle, region) { if (region instanceof Point) { this.write(BG_DRAW, region, defaultStyle); } else { region ??= this.visibleRect; const minX = Math.max(region.minX(), this.#visibleRect.minX()); const maxX = Math.min(region.maxX(), this.#visibleRect.maxX()); const minY = Math.max(region.minY(), this.#visibleRect.minY()); const maxY = Math.min(region.maxY(), this.#visibleRect.maxY()); this.#terminal.paintRect(defaultStyle, minX + this.#offset.x, minY + this.#offset.y, maxX + this.#offset.x, maxY + this.#offset.y); } } /** * Replaces the style of existing cells without changing their text content. * Useful for dimming background content (e.g. modal overlays). * If no region is provided, the entire visibleRect is restyled. */ restyle(style, region) { region ??= this.visibleRect; const minX = Math.max(region.minX(), this.#visibleRect.minX()); const maxX = Math.min(region.maxX(), this.#visibleRect.maxX()); const minY = Math.max(region.minY(), this.#visibleRect.minY()); const maxY = Math.min(region.maxY(), this.#visibleRect.maxY()); for (let y = minY; y < maxY; y++) { for (let x = minX; x < maxX; x++) { this.#terminal.restyleChar(x + this.#offset.x, y + this.#offset.y, style); } } } /** * Does not support newlines (no default wrapping behavior), * always prints left-to-right. */ write(input, to, style) { const minX = this.#visibleRect.minX(), maxX = this.#visibleRect.maxX(), minY = this.#visibleRect.minY(), maxY = this.#visibleRect.maxY(); if (to.x >= maxX || to.y < minY || to.y >= maxY) { return; } style ??= this.#style; const startingStyle = style; let x = to.x, y = to.y; for (const char of unicode.printableChars(input)) { if (char === '\n') { break; } if (x >= maxX) { continue; } const width = unicode.charWidth(char); if (width === 0) { style = char === RESET ? startingStyle : startingStyle.merge(Style.fromSGR(char, startingStyle)); } else if (x >= minX && x + width - 1 < maxX) { this.#terminal.writeChar(char, this.#offset.x + x, this.#offset.y + y, style); if (this.#currentRender && // if the currentRender wasn't added as a child to the screen's tree, // we shouldn't perform this check this.#currentRender.screen === this.#screen) { this.#screen.checkMouse(this.#currentRender, this.#offset.x + x, this.#offset.y + y); } } x += width; } } /** * Forwards 'meta' ANSI sequences (see ITerm) to the terminal */ writeMeta(str) { this.#terminal.writeMeta(str); } usingPen(...args) { const prevStyle = this.#style; const pen = new Pen(prevStyle, (style) => { this.#style = style ?? prevStyle; }); if (args.length === 2) { // usingPen(style: Style | undefined, draw: (pen: Pen) => void): void if (args[0] && args[0] !== Style.NONE) { pen.replacePen(args[0]); } args[1](pen); } else { // usingPen(draw: (pen: Pen) => void): void args[0](pen); } this.#style = prevStyle; } _render(view, clip, draw) { const prevRender = this.#currentRender; this.#currentRender = view; this.clipped(clip, draw); this.#currentRender = prevRender; } clipped(...args) { let clip; let style; let draw; if (args.length === 3) { ; [clip, style, draw] = args; } else { ; [clip, draw] = args; style = this.#style; } const offsetX = this.#offset.x + clip.origin.x; const offsetY = this.#offset.y + clip.origin.y; const contentWidth = Math.max(0, clip.size.width); const contentHeight = Math.max(0, clip.size.height); // visibleRect.origin doesn't go negative - Math.max(0) prevents that. // The subtraction of clip.origin.x only has an effect when the clipped // origin is *negative*. In that case, the effect is that (0, 0) is outside the // visible space, and so visibleRect.origin represents the first visiblePoint // (in local coordinates). Basically - trust this math, it looks wrong, but I // double checked it. const visibleMinX = Math.max(0, this.#visibleRect.origin.x - clip.origin.x); const visibleMinY = Math.max(0, this.#visibleRect.origin.y - clip.origin.y); const visibleMaxX = Math.min(clip.size.width, this.#visibleRect.origin.x + this.#visibleRect.size.width - clip.origin.x); const visibleMaxY = Math.min(clip.size.height, this.#visibleRect.origin.y + this.#visibleRect.size.height - clip.origin.y); const contentSize = new Size(contentWidth, contentHeight); const availableRect = new Rect(new Point(this.#availableRect.origin.x - clip.origin.x, this.#availableRect.origin.y - clip.origin.y), this.#availableRect.size); const visibleRect = new Rect(new Point(visibleMinX, visibleMinY), new Size(visibleMaxX - visibleMinX, visibleMaxY - visibleMinY)); const offset = new Point(offsetX, offsetY); const prevContentSize = this.#contentSize; const prevAvailableRect = this.#availableRect; const prevVisibleRect = this.#visibleRect; const prevOffset = this.#offset; const prevStyle = this.#style; this.#contentSize = contentSize; this.#availableRect = availableRect; this.#visibleRect = visibleRect; this.#offset = offset; this.#style = style; draw(this); this.#contentSize = prevContentSize; this.#availableRect = prevAvailableRect; this.#visibleRect = prevVisibleRect; this.#offset = prevOffset; this.#style = prevStyle; } /** * Shifts the viewport origin inward (to `rect.origin`) and sets contentSize to * `rect.size`, without changing the clip boundary. This makes `availableRect` * extend beyond `contentRect` so children can draw "on top of" the surrounding * area (e.g. box borders). * * Must be called inside a `clipped()` callback — the enclosing `clipped` will * restore the previous state. * * viewport.clipped(outerRect, inside => { * inside.inset(innerRect) * super.render(inside) * }) */ inset(rect, draw) { const prevAvailableRect = this.#availableRect; const prevContentSize = this.#contentSize; const prevOffset = this.#offset; const prevVisibleRect = this.#visibleRect; // The enclosing clip area, expressed in the inset coordinate system this.#availableRect = new Rect(new Point(-rect.origin.x, -rect.origin.y), this.#contentSize); this.#contentSize = rect.size; // Shift the offset so writes at (0,0) map to the inset origin this.#offset = new Point(this.#offset.x + rect.origin.x, this.#offset.y + rect.origin.y); // Adjust visibleRect to the new coordinate system this.#visibleRect = new Rect(new Point(this.#visibleRect.origin.x - rect.origin.x, this.#visibleRect.origin.y - rect.origin.y), this.#visibleRect.size); draw(this); this.#availableRect = prevAvailableRect; this.#contentSize = prevContentSize; this.#offset = prevOffset; this.#visibleRect = prevVisibleRect; } } class Pen { #setter; #initial; #stack; constructor(initialStyle, setter) { this.#setter = setter; this.#initial = initialStyle; this.#stack = []; } /** * Used in Text drawing components - the component usually defines a starting * style (`viewport.usingPen(style, pen => {})`), and as it prints characters * (`char of unicode.printableChars(line)`) it will detect 0-width SGR codes * (`unicode.charWidth(char) === 0`). These codes can be used to create a `Style` * object (`Style.fromSGR(char)`). * * SGR codes do support turn-on/turn-off, but this doesn't work well when, say, the * default style already has certain features turned on. For instance, if the * string specifies one region to be bold, but the entire Text component is meant * to be bold, the behaviour of "turn-off-bold" is actually incorrect here. * * This is why the `fromSGR` method accepts the default style - it can be compared * with the SGR state to determine what to do. */ mergePen(style) { const current = this.#stack[0] ?? this.#initial; style = current.merge(style); this.replacePen(style); } /** * replacePen is better when you need to control the drawing style, but you will * assign the entire desired style. */ replacePen(style) { this.#stack[0] = style; this.#setter(style); } } //# sourceMappingURL=Viewport.js.map