UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

552 lines 21.5 kB
import { Container } from '../Container.js'; import { Rect, Point, Size } from '../geometry.js'; import { isMouseClicked, toHotKeyDef, hotKeyToString, } from '../events/index.js'; import { Style } from '../Style.js'; import { Palette } from '../Palette.js'; import { define } from '../util.js'; const DRAWER_BORDER = 2; const DRAWER_BTN_SIZE = { vertical: new Size(3, 8), horizontal: new Size(8, 3), }; export class Drawer extends Container { drawerView; contentView; #drawerSize = Size.zero; #isOpen = false; #currentDx = 0; #location = 'left'; #title; #onToggle; #hotKey; constructor({ content, drawer, ...props }) { super(props); if (content) { this.add((this.contentView = content)); } if (drawer) { this.add((this.drawerView = drawer)); } this.#update(props); define(this, 'location', { enumerable: true }); } get location() { return this.#location; } set location(value) { this.#location = value; this.invalidateSize(); } update(props) { this.#update(props); super.update(props); } get title() { return this.#title; } set title(value) { this.#title = value; this.invalidateRender(); } legendItems() { if (!this.#hotKey) { return []; } return [{ key: hotKeyToString(this.#hotKey), label: 'Toggle Drawer' }]; } #update({ isOpen, location, onToggle, hotKey, title }) { this.#title = title; if (isOpen !== undefined) { this.#setIsOpen(isOpen, false); } this.#onToggle = onToggle; this.#hotKey = hotKey; this.#location = location ?? 'left'; } /** * Opens the drawer (does not trigger onToggle) */ open() { this.#setIsOpen(true, false); } /** * Closes the drawer (does not trigger onToggle) */ close() { this.#setIsOpen(false, false); } /** * Toggles the drawer open/closed (does not trigger onToggle) */ toggle() { this.#setIsOpen(!this.#isOpen, false); } #setIsOpen(value, report) { this.#isOpen = value; if (report) { this.#onToggle?.(value); } } add(child, at) { super.add(child, at); this.contentView = this.children[0]; this.drawerView = this.children[1]; } naturalSize(available) { const [drawerSize, contentSize] = this.#saveDrawerSize(available); switch (this.#location) { case 'top': case 'bottom': return new Size(Math.max(drawerSize.width + DRAWER_BORDER, contentSize.width), Math.max(drawerSize.height, contentSize.height) + DRAWER_BTN_SIZE.horizontal.height); case 'left': case 'right': return new Size(Math.max(drawerSize.width, contentSize.width) + DRAWER_BTN_SIZE.vertical.width, Math.max(drawerSize.height + DRAWER_BORDER, contentSize.height)); } } #targetDx() { switch (this.#isOpen ? this.#location : '') { case 'top': case 'bottom': return this.#drawerSize.height; case 'left': case 'right': return this.#drawerSize.width; default: return 0; } } receiveTick(dt) { const targetDx = this.#targetDx(); let delta; switch (this.#location) { case 'top': case 'bottom': delta = (targetDx > this.#currentDx ? 0.05 : -0.05) * dt; break; case 'left': case 'right': delta = (targetDx > this.#currentDx ? 0.2 : -0.2) * dt; break; } let target; switch (this.#location) { case 'top': case 'bottom': target = this.#drawerSize.height; break; case 'left': case 'right': target = this.#drawerSize.width; break; } const nextDx = Math.max(0, Math.min(target, this.#currentDx + delta)); if (nextDx !== this.#currentDx) { this.#currentDx = nextDx; return true; } return false; } receiveKey(_) { this.#setIsOpen(!this.#isOpen, true); } receiveMouse(event, system) { super.receiveMouse(event, system); if (isMouseClicked(event)) { this.#setIsOpen(!this.#isOpen, true); } } #saveDrawerSize(available) { let remainingSize; switch (this.#location) { case 'top': case 'bottom': remainingSize = available.shrink(0, DRAWER_BTN_SIZE.horizontal.height); break; case 'left': case 'right': remainingSize = available.shrink(DRAWER_BTN_SIZE.vertical.width, 0); break; } const drawerSize = this.drawerView?.naturalSize(remainingSize) ?? Size.zero; const contentSize = this.contentView?.naturalSize(remainingSize) ?? Size.zero; this.#drawerSize = drawerSize; return [drawerSize, contentSize]; } childPalette(view) { if (view === this.drawerView) { return this.purpose; } return this.parent?.childPalette(this) ?? Palette.plain; } render(viewport) { if (viewport.isEmpty) { return super.render(viewport); } const [drawerSize] = this.#saveDrawerSize(viewport.contentSize); if (this.#hotKey) { viewport.registerHotKey(toHotKeyDef(this.#hotKey)); } if (this.#currentDx !== this.#targetDx()) { viewport.registerTick(); } const _uiStyle = this.purpose.ui({ isHover: this.isHover, isPressed: this.isPressed, }); const textStyle = this.purpose .text({ isHover: this.isHover, isPressed: this.isPressed, }) .merge({ foreground: _uiStyle.foreground }); const uiStyle = this.isHover || this.isPressed ? _uiStyle : _uiStyle.merge({ background: textStyle.background, }); switch (this.#location) { case 'top': this.#renderTop(viewport, drawerSize, uiStyle, textStyle); break; case 'right': this.#renderRight(viewport, drawerSize, uiStyle, textStyle); break; case 'bottom': this.#renderBottom(viewport, drawerSize, uiStyle, textStyle); break; case 'left': this.#renderLeft(viewport, drawerSize, uiStyle, textStyle); break; } } #renderTop(viewport, drawerSize, uiStyle, textStyle) { const drawerButtonRect = new Rect(new Point(0, ~~this.#currentDx), new Size(viewport.contentSize.width, DRAWER_BTN_SIZE.horizontal.height)); const contentRect = new Rect(new Point(0, DRAWER_BTN_SIZE.horizontal.height - 1), viewport.contentSize.shrink(0, DRAWER_BTN_SIZE.horizontal.height - 1)); const drawerRect = new Rect(new Point(1, ~~this.#currentDx - drawerSize.height), new Size(drawerButtonRect.size.width - DRAWER_BORDER, drawerSize.height)); this.#renderContent(viewport, drawerButtonRect, contentRect, drawerRect); this.#renderDrawerTop(viewport, drawerButtonRect, uiStyle, textStyle); this.#maybeRenderDrawerHeading(viewport, drawerRect); } #renderBottom(viewport, drawerSize, uiStyle, textStyle) { const drawerButtonRect = new Rect(new Point(0, viewport.contentSize.height - ~~this.#currentDx - DRAWER_BTN_SIZE.horizontal.height), new Size(viewport.contentSize.width, DRAWER_BTN_SIZE.horizontal.height)); const contentRect = new Rect(new Point(0, 0), viewport.contentSize.shrink(0, DRAWER_BTN_SIZE.horizontal.height - 1)); const drawerRect = new Rect(new Point(1, viewport.contentSize.height - this.#currentDx), new Size(drawerButtonRect.size.width - DRAWER_BORDER, drawerSize.height)); this.#renderContent(viewport, drawerButtonRect, contentRect, drawerRect); this.#renderDrawerBottom(viewport, drawerButtonRect, uiStyle, textStyle); this.#maybeRenderDrawerHeading(viewport, drawerRect); } #renderRight(viewport, drawerSize, uiStyle, textStyle) { const drawerButtonRect = new Rect(new Point(viewport.contentSize.width - ~~this.#currentDx - DRAWER_BTN_SIZE.vertical.width, 0), new Size(DRAWER_BTN_SIZE.vertical.width, viewport.contentSize.height)); const contentRect = new Rect(new Point(0, 0), viewport.contentSize.shrink(DRAWER_BTN_SIZE.vertical.width - 1, 0)); const drawerRect = new Rect(new Point(viewport.contentSize.width - this.#currentDx, 1), new Size(drawerSize.width, drawerButtonRect.size.height - DRAWER_BORDER)); this.#renderContent(viewport, drawerButtonRect, contentRect, drawerRect); this.#renderDrawerRight(viewport, drawerButtonRect, uiStyle, textStyle); this.#maybeRenderDrawerHeading(viewport, drawerRect); } #renderLeft(viewport, drawerSize, uiStyle, textStyle) { const drawerButtonRect = new Rect(new Point(~~this.#currentDx, 0), new Size(DRAWER_BTN_SIZE.vertical.width, viewport.contentSize.height)); const contentRect = new Rect(new Point(DRAWER_BTN_SIZE.vertical.width - 1, 0), viewport.contentSize.shrink(DRAWER_BTN_SIZE.vertical.width - 1, 0)); const drawerRect = new Rect(new Point(this.#currentDx - drawerSize.width, 1), new Size(drawerSize.width, drawerButtonRect.size.height - DRAWER_BORDER)); this.#renderContent(viewport, drawerButtonRect, contentRect, drawerRect); this.#renderDrawerLeft(viewport, drawerButtonRect, uiStyle, textStyle); this.#maybeRenderDrawerHeading(viewport, drawerRect); } #renderContent(viewport, drawerButtonRect, contentRect, drawerRect) { // contentView renders before registerMouse so the drawer can be "on top" const contentView = this.contentView; const drawerView = this.drawerView; if (!contentView || !drawerView) { return; } viewport.clipped(contentRect, inside => { contentView.render(inside); }); if (this.isHover) { viewport.registerMouse(['mouse.move', 'mouse.button.left'], drawerButtonRect); } else { let inset; switch (this.#location) { case 'top': inset = 'bottom'; break; case 'right': inset = 'left'; break; case 'bottom': inset = 'top'; break; case 'left': inset = 'right'; break; } viewport.registerMouse(['mouse.move', 'mouse.button.left'], drawerButtonRect.inset({ [inset]: 1 })); } if (this.#currentDx > 0) { const resolvedTitle = this.#resolvedDrawerTitle(); const hasHeading = resolvedTitle !== undefined && resolvedTitle.length > 0; const isVertical = this.#location === 'top' || this.#location === 'bottom'; // For top/bottom drawers, shrink drawerRect to make room for a heading row const contentDrawerRect = hasHeading && isVertical ? new Rect(drawerRect.origin.offset(0, 1), drawerRect.size.shrink(0, 1)) : drawerRect; viewport.paint(this.purpose.text(), drawerRect); viewport.clipped(contentDrawerRect, inside => { drawerView.render(inside); }); } } #resolvedDrawerTitle() { if (this.#title !== undefined) return this.#title; return this.drawerView?.heading; } #maybeRenderDrawerHeading(viewport, drawerRect) { const title = this.#resolvedDrawerTitle(); if (!title || this.#currentDx <= 0) { return; } this.#renderDrawerHeading(viewport, title, drawerRect); } #renderDrawerHeading(viewport, heading, drawerRect) { const headingStyle = new Style({ bold: true, background: this.purpose.text().background, }); let headingX; let headingY; let maxWidth; switch (this.#location) { case 'left': // Heading on the ─ top border at y=0 headingX = DRAWER_HEADING_X; headingY = 0; maxWidth = drawerRect.maxX() - DRAWER_HEADING_X - DRAWER_HEADING_PAD; break; case 'right': // Heading on the ─ top border at y=0, in the drawer area headingX = drawerRect.minX() + DRAWER_HEADING_X; headingY = 0; maxWidth = viewport.contentSize.width - headingX - DRAWER_HEADING_PAD; break; case 'top': case 'bottom': // Heading on the reserved first row of the drawer area headingX = drawerRect.minX() + DRAWER_HEADING_X; headingY = drawerRect.minY(); maxWidth = drawerRect.size.width - DRAWER_HEADING_X - DRAWER_HEADING_PAD; break; } if (maxWidth > 0) { const text = heading.slice(0, maxWidth); viewport.write(text, new Point(headingX, headingY), headingStyle); } } #renderDrawerTop(viewport, drawerButtonRect, uiStyle, textStyle) { const drawerY = drawerButtonRect.minY(), minX = drawerButtonRect.minX(), maxX = drawerButtonRect.maxX() - 1, point = new Point(0, 0).mutableCopy(); viewport.usingPen(textStyle, () => { for (; point.y < drawerY; point.y++) { point.x = minX; viewport.write('│', point); point.x = maxX; viewport.write('│', point); } }); viewport.usingPen(uiStyle, () => { point.y = drawerButtonRect.minY(); for (point.x = minX; point.x <= maxX; point.x++) { let drawer; if (point.x === 0) { if (this.isHover) { drawer = ['╮', '│', '│']; } else { drawer = ['╮', '│', '']; } } else if (point.x === maxX) { if (this.isHover) { drawer = ['╭', '│', '│']; } else { drawer = ['╭', '│', '']; } } else { let chevron; if (point.x % 2 === 0) { chevron = ' '; } else if (this.#isOpen) { chevron = '∧'; } else { chevron = '∨'; } let c1, c2, c3; if (this.isHover) { c1 = ' '; c2 = chevron; c3 = '─'; } else { c1 = chevron; c2 = '─'; c3 = ''; } drawer = [c1, c2, c3]; } viewport.write(drawer[0], point.offset(0, 0)); viewport.write(drawer[1], point.offset(0, 1)); if (drawer[2] !== '') { viewport.write(drawer[2], point.offset(0, 2)); } } }); } #renderDrawerBottom(viewport, drawerButtonRect, uiStyle, textStyle) { const drawerY = drawerButtonRect.maxY(), minX = drawerButtonRect.minX(), maxX = drawerButtonRect.maxX() - 1, point = new Point(0, drawerY).mutableCopy(); viewport.usingPen(textStyle, () => { for (; point.y < viewport.contentSize.height; point.y++) { point.x = minX; viewport.write('│', point); point.x = maxX; viewport.write('│', point); } }); viewport.usingPen(uiStyle, () => { point.y = drawerButtonRect.minY(); for (point.x = minX; point.x <= maxX; point.x++) { let drawer; if (point.x === 0) { if (this.isHover) { drawer = ['╭', '│', '│']; } else { drawer = ['', '╭', '│']; } } else if (point.x === maxX) { if (this.isHover) { drawer = ['╮', '│', '│']; } else { drawer = ['', '╮', '│']; } } else { let chevron; if (point.x % 2 === 0) { chevron = ' '; } else if (this.#isOpen) { chevron = '∨'; } else { chevron = '∧'; } let c1, c2, c3; if (this.isHover) { c1 = '─'; c2 = chevron; c3 = ' '; } else { c1 = ''; c2 = '─'; c3 = chevron; } drawer = [c1, c2, c3]; } if (drawer[0] !== '') { viewport.write(drawer[0], point.offset(0, 0)); } viewport.write(drawer[1], point.offset(0, 1)); viewport.write(drawer[2], point.offset(0, 2)); } }); } #renderDrawerRight(viewport, drawerButtonRect, uiStyle, textStyle) { const drawerX = drawerButtonRect.maxX(), minY = drawerButtonRect.minY(), maxY = drawerButtonRect.maxY() - 1, point = new Point(drawerX, 0).mutableCopy(); viewport.usingPen(textStyle, () => { for (; point.x < viewport.contentSize.width; point.x++) { point.y = minY; viewport.write('─', point); point.y = maxY; viewport.write('─', point); } }); viewport.usingPen(uiStyle, () => { for (point.y = minY; point.y <= maxY; point.y++) { point.x = drawerButtonRect.minX(); let drawer; if (point.y === 0) { if (this.isHover) { drawer = '╭──'; } else { drawer = '╭─'; point.x += 1; } } else if (point.y === maxY) { if (this.isHover) { drawer = '╰──'; } else { drawer = '╰─'; point.x += 1; } } else { drawer = ''; if (!this.isHover) { point.x += 1; } drawer += '│'; drawer += this.#isOpen ? '›' : '‹'; } viewport.write(drawer, point); } }); } #renderDrawerLeft(viewport, drawerButtonRect, uiStyle, textStyle) { const drawerX = drawerButtonRect.minX(), minY = drawerButtonRect.minY(), maxY = drawerButtonRect.maxY() - 1, point = new Point(0, 0).mutableCopy(); viewport.usingPen(textStyle, () => { for (; point.x < drawerX; point.x++) { point.y = minY; viewport.write('─', point); point.y = maxY; viewport.write('─', point); } }); viewport.usingPen(uiStyle, () => { point.x = drawerX; for (point.y = minY; point.y <= maxY; point.y++) { let drawer; if (point.y === 0) { drawer = '─' + (this.isHover ? '─' : '') + '╮'; } else if (point.y === maxY) { drawer = '─' + (this.isHover ? '─' : '') + '╯'; } else { drawer = ''; drawer += this.isHover ? ' ' : ''; drawer += this.#isOpen ? '‹' : '›'; drawer += '│'; } viewport.write(drawer, point); } }); } } const DRAWER_HEADING_X = 2; const DRAWER_HEADING_PAD = 1; //# sourceMappingURL=Drawer.js.map