UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

324 lines 12.9 kB
import { Container } from '../Container.js'; import { Rect, Point, Size } from '../geometry.js'; import { isMousePressStart, isMousePressEnd, } from '../events/index.js'; import { Style } from '../Style.js'; /** Minimum width when collapsible — below this threshold the pane will collapse on release. */ const COLLAPSE_THRESHOLD = 3; /** Minimum width when NOT collapsible. */ const MIN_PANE_WIDTH_NOT_COLLAPSIBLE = 4; /** Absolute minimum width during drag when collapsible (allows dragging into the collapse zone). */ const MIN_PANE_WIDTH_DRAG = 1; /** Default minimum width (non-drag). */ const MIN_PANE_WIDTH = 5; const SEPARATOR_WIDTH = 1; /** * A split-pane container for index/detail layouts. The first N-1 children are * collapsible "browser" panes with draggable separators. The last child is the * "detail" pane that takes remaining space. */ export class Pane extends Container { #border = false; #collapsible = true; #browserPanes = []; // Drag state #dragSeparator = -1; #dragStartX = 0; #dragStartWidth = 0; // Hover state #hoverSeparator = -1; // Cached layout info for mouse hit-testing #separatorPositions = []; #borderInset = 0; #lastAvailableWidth = 80; constructor(props = {}) { super(props); this.#update(props); } update(props) { this.#update(props); super.update(props); } get border() { return this.#border; } set border(value) { if (value === this.#border) { return; } this.#border = value; this.invalidateSize(); } #update({ border, collapsible }) { this.#border = border ?? false; this.#collapsible = collapsible ?? true; } /** * Returns the browser panes (all children except the last). */ get browserViews() { const children = this.children; if (children.length <= 1) { return []; } return children.slice(0, -1); } /** * Returns the detail pane (the last child). */ get detailView() { const children = this.children; return children.length > 0 ? children[children.length - 1] : undefined; } #ensurePaneState(available) { const browserCount = Math.max(0, this.children.length - 1); while (this.#browserPanes.length < browserCount) { this.#browserPanes.push({ width: -1, collapsed: false }); } if (this.#browserPanes.length > browserCount) { this.#browserPanes.length = browserCount; } const borderInset = this.#border ? 2 : 0; const innerAvailable = available.shrink(borderInset, borderInset); const browsers = this.browserViews; let usedWidth = 0; for (let i = 0; i < browsers.length; i++) { const ns = browsers[i].naturalSize(innerAvailable); // Initialize widths from natural size on first layout if (this.#browserPanes[i].width === -1) { this.#browserPanes[i].width = Math.max(MIN_PANE_WIDTH, Math.min(ns.width, Math.floor(innerAvailable.width / 3))); } usedWidth += (this.#browserPanes[i].collapsed ? 0 : this.#browserPanes[i].width) + SEPARATOR_WIDTH; } return [usedWidth, innerAvailable, borderInset]; } naturalSize(available) { const [usedWidth, innerAvailable, borderInset] = this.#ensurePaneState(available); const detailNs = this.detailView?.naturalSize(innerAvailable.shrink(usedWidth, 0)); const detailWidth = detailNs?.width ?? 0; const totalWidth = usedWidth + detailWidth + borderInset; return new Size(Math.min(available.width, Math.max(totalWidth, MIN_PANE_WIDTH)), available.height); } /** * Hit-test: given an absolute x position in viewport coordinates, * find which separator index (if any) was hit. */ #hitTestSeparator(x) { for (let i = 0; i < this.#separatorPositions.length; i++) { if (x === this.#separatorPositions[i]) { return i; } } return -1; } /** * Whether a given browser pane is in the "will collapse" zone during a drag. */ #isInCollapseZone(index) { if (!this.#collapsible) return false; const pane = this.#browserPanes[index]; return !pane.collapsed && pane.width <= COLLAPSE_THRESHOLD; } /** * Whether a given mouse event is part of an ongoing button-drag gesture. * This includes dragInside, dragOutside, exit (leaving the region while * dragging), and the initial down event itself. */ #isDragGesture(event) { if (!event.name.startsWith('mouse.button.')) return false; // up and cancel end the gesture if (event.name.endsWith('.up') || event.name.endsWith('.cancel')) return false; return true; } receiveMouse(event, system) { super.receiveMouse(event, system); const x = event.position.x; // During a drag, always track the dragged separator if (this.#dragSeparator >= 0) { if (this.#isDragGesture(event)) { const dx = x - this.#dragStartX; const maxWidth = Math.floor(this.#lastAvailableWidth / 2); const pane = this.#browserPanes[this.#dragSeparator]; if (!pane.collapsed) { const minDrag = this.#collapsible ? MIN_PANE_WIDTH_DRAG : MIN_PANE_WIDTH_NOT_COLLAPSIBLE; pane.width = Math.max(minDrag, Math.min(maxWidth, this.#dragStartWidth + dx)); } return; } if (isMousePressEnd(event)) { const pane = this.#browserPanes[this.#dragSeparator]; const dx = Math.abs(x - this.#dragStartX); if (this.#collapsible && !pane.collapsed && this.#isInCollapseZone(this.#dragSeparator)) { // Released in collapse zone — collapse and restore width pane.collapsed = true; pane.width = this.#dragStartWidth; } else if (dx <= 1) { // Click without drag — toggle collapse (only if collapsible) if (this.#collapsible) { pane.collapsed = !pane.collapsed; } } this.#dragSeparator = -1; return; } } const separatorIndex = this.#hitTestSeparator(x); // Handle hover if (event.name === 'mouse.move.in' || event.name === 'mouse.move.enter') { this.#hoverSeparator = separatorIndex; } else if (event.name === 'mouse.move.exit') { this.#hoverSeparator = -1; } // Handle drag start on a separator if (isMousePressStart(event) && separatorIndex >= 0) { this.#dragSeparator = separatorIndex; this.#dragStartX = x; this.#dragStartWidth = this.#browserPanes[separatorIndex].width; } } render(viewport) { if (viewport.isEmpty) { return super.render(viewport); } this.#ensurePaneState(viewport.availableRect.size); this.#borderInset = this.#border ? 1 : 0; const innerSize = viewport.contentSize.shrink(this.#borderInset * 2, this.#borderInset * 2); this.#lastAvailableWidth = innerSize.width; // Draw border if enabled if (this.#border) { this.#renderBorder(viewport); } // Panes get the full width minus left/right border, but full height // so that #renderSeparator can draw border junctions on the top/bottom rows. const panesRect = this.#border ? new Rect(new Point(this.#borderInset, 0), viewport.contentSize.shrink(this.#borderInset * 2, 0)) : viewport.contentRect; viewport.clipped(panesRect, inner => { this.#renderPanes(inner); }); } #renderPanes(viewport) { const browsers = this.browserViews; const detail = this.detailView; const fullHeight = viewport.contentSize.height; // Child content is inset from top/bottom border rows const contentTop = this.#borderInset; const contentHeight = fullHeight - this.#borderInset * 2; let x = 0; this.#separatorPositions = []; for (let i = 0; i < browsers.length; i++) { const pane = this.#browserPanes[i]; // Render browser pane content (if not collapsed) if (!pane.collapsed) { const paneWidth = pane.width; const paneRect = new Rect(new Point(x, contentTop), new Size(paneWidth, contentHeight)); viewport.clipped(paneRect, inner => { browsers[i].render(inner); }); x += paneWidth; } // Render separator (full height, including border rows) this.#separatorPositions.push(x); this.#renderSeparator(viewport, i, x, fullHeight, pane.collapsed); x += SEPARATOR_WIDTH; } // Render detail pane (fills remaining space) if (detail) { const detailWidth = Math.max(0, viewport.contentSize.width - x); if (detailWidth > 0) { const detailRect = new Rect(new Point(x, contentTop), new Size(detailWidth, contentHeight)); viewport.clipped(detailRect, inner => { detail.render(inner); }); } } } #renderSeparator(viewport, index, x, fullHeight, collapsed) { const isHover = this.#hoverSeparator === index; const isDragging = this.#dragSeparator === index; const willCollapse = isDragging && this.#isInCollapseZone(index); const style = isHover || isDragging ? this.purpose.ui({ isHover: true }) : Style.NONE; const separatorRect = new Rect(new Point(x, 0), new Size(SEPARATOR_WIDTH, fullHeight)); viewport.registerMouse(['mouse.move', 'mouse.button.left'], separatorRect); const topRow = this.#border ? 0 : -1; const bottomRow = this.#border ? fullHeight - 1 : -1; const point = new Point(x, 0).mutableCopy(); viewport.usingPen(style, () => { for (point.y = 0; point.y < fullHeight; point.y++) { let char; if (point.y === topRow) { char = collapsed ? BORDER_TOP_COLLAPSED : BORDER_TOP_SEP; } else if (point.y === bottomRow) { char = collapsed ? BORDER_BOTTOM_COLLAPSED : BORDER_BOTTOM_SEP; } else if (collapsed) { char = SEP_COLLAPSED; } else if (willCollapse) { char = point.y % 2 === 1 ? SEP_WILL_COLLAPSE : SEP_DEFAULT; } else { char = SEP_DEFAULT; } viewport.write(char, point); } }); } #renderBorder(viewport) { const w = viewport.contentSize.width; const h = viewport.contentSize.height; const point = new Point(0, 0).mutableCopy(); // Top edge point.y = 0; for (point.x = 1; point.x < w - 1; point.x++) { viewport.write(BORDER_HORIZONTAL, point); } // Bottom edge point.y = h - 1; for (point.x = 1; point.x < w - 1; point.x++) { viewport.write(BORDER_HORIZONTAL, point); } // Left & right edges for (point.y = 1; point.y < h - 1; point.y++) { point.x = 0; viewport.write(BORDER_VERTICAL, point); point.x = w - 1; viewport.write(BORDER_VERTICAL, point); } // Corners viewport.write(BORDER_TOP_LEFT, new Point(0, 0)); viewport.write(BORDER_TOP_RIGHT, new Point(w - 1, 0)); viewport.write(BORDER_BOTTOM_LEFT, new Point(0, h - 1)); viewport.write(BORDER_BOTTOM_RIGHT, new Point(w - 1, h - 1)); } } //// /// Drawing constants // // Separator characters const SEP_DEFAULT = '┃'; const SEP_COLLAPSED = '║'; const SEP_WILL_COLLAPSE = '←'; // Border characters const BORDER_HORIZONTAL = '─'; const BORDER_VERTICAL = '│'; const BORDER_TOP_LEFT = '╭'; const BORDER_TOP_RIGHT = '╮'; const BORDER_BOTTOM_LEFT = '╰'; const BORDER_BOTTOM_RIGHT = '╯'; const BORDER_TOP_SEP = '┰'; const BORDER_BOTTOM_SEP = '┸'; const BORDER_TOP_COLLAPSED = '╥'; const BORDER_BOTTOM_COLLAPSED = '╨'; //# sourceMappingURL=Pane.js.map