singularity-forge/packages/tui/src/overlay-layout.ts

458 lines
14 KiB
TypeScript

/**
* Overlay layout resolution, compositing, and rendering utilities.
*
* Extracted from tui.ts — these are pure functions that compute overlay
* positions and composite overlay content onto base terminal lines.
*/
import type {
OverlayAnchor,
OverlayOptions,
SizeValue,
} from "./overlay-types.js";
import { tryRender } from "./render-guard.js";
import { isImageLine } from "./terminal-image.js";
import { CURSOR_MARKER } from "./tui.js";
import {
applyBackgroundToLine,
extractSegments,
sliceByColumn,
sliceWithWidth,
visibleWidth,
} from "./utils.js";
// ─── Size parsing ───────────────────────────────────────────────────────────
/** Parse a SizeValue into absolute value given a reference size */
export function parseSizeValue(
value: SizeValue | undefined,
referenceSize: number,
): number | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") return value;
// Parse percentage string like "50%"
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
if (match) {
return Math.floor((referenceSize * parseFloat(match[1])) / 100);
}
return undefined;
}
// ─── Anchor resolution ──────────────────────────────────────────────────────
export function resolveAnchorRow(
anchor: OverlayAnchor,
height: number,
availHeight: number,
marginTop: number,
): number {
switch (anchor) {
case "top-left":
case "top-center":
case "top-right":
return marginTop;
case "bottom-left":
case "bottom-center":
case "bottom-right":
return marginTop + availHeight - height;
case "left-center":
case "center":
case "right-center":
return marginTop + Math.floor((availHeight - height) / 2);
}
}
export function resolveAnchorCol(
anchor: OverlayAnchor,
width: number,
availWidth: number,
marginLeft: number,
): number {
switch (anchor) {
case "top-left":
case "left-center":
case "bottom-left":
return marginLeft;
case "top-right":
case "right-center":
case "bottom-right":
return marginLeft + availWidth - width;
case "top-center":
case "center":
case "bottom-center":
return marginLeft + Math.floor((availWidth - width) / 2);
}
}
// ─── Overlay layout resolution ──────────────────────────────────────────────
export interface OverlayLayout {
width: number;
row: number;
col: number;
maxHeight: number | undefined;
}
/**
* Resolve overlay layout from options.
* Returns { width, row, col, maxHeight } for rendering.
*/
export function resolveOverlayLayout(
options: OverlayOptions | undefined,
overlayHeight: number,
termWidth: number,
termHeight: number,
): OverlayLayout {
const opt = options ?? {};
// Parse margin (clamp to non-negative)
const margin =
typeof opt.margin === "number"
? {
top: opt.margin,
right: opt.margin,
bottom: opt.margin,
left: opt.margin,
}
: (opt.margin ?? {});
const marginTop = Math.max(0, margin.top ?? 0);
const marginRight = Math.max(0, margin.right ?? 0);
const marginBottom = Math.max(0, margin.bottom ?? 0);
const marginLeft = Math.max(0, margin.left ?? 0);
// Available space after margins
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
// === Resolve width ===
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
// Apply minWidth
if (opt.minWidth !== undefined) {
width = Math.max(width, opt.minWidth);
}
// Clamp to available space
width = Math.max(1, Math.min(width, availWidth));
// === Resolve maxHeight ===
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
// Clamp to available space
if (maxHeight !== undefined) {
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
}
// Effective overlay height (may be clamped by maxHeight)
const effectiveHeight =
maxHeight !== undefined
? Math.min(overlayHeight, maxHeight)
: overlayHeight;
// === Resolve position ===
let row: number;
let col: number;
if (opt.row !== undefined) {
if (typeof opt.row === "string") {
// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
if (match) {
const maxRow = Math.max(0, availHeight - effectiveHeight);
const percent = parseFloat(match[1]) / 100;
row = marginTop + Math.floor(maxRow * percent);
} else {
// Invalid format, fall back to center
row = resolveAnchorRow(
"center",
effectiveHeight,
availHeight,
marginTop,
);
}
} else {
// Absolute row position
row = opt.row;
}
} else {
// Anchor-based (default: center)
const anchor = opt.anchor ?? "center";
row = resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
}
if (opt.col !== undefined) {
if (typeof opt.col === "string") {
// Percentage: 0% = left, 100% = right (overlay stays within bounds)
const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
if (match) {
const maxCol = Math.max(0, availWidth - width);
const percent = parseFloat(match[1]) / 100;
col = marginLeft + Math.floor(maxCol * percent);
} else {
// Invalid format, fall back to center
col = resolveAnchorCol("center", width, availWidth, marginLeft);
}
} else {
// Absolute column position
col = opt.col;
}
} else {
// Anchor-based (default: center)
const anchor = opt.anchor ?? "center";
col = resolveAnchorCol(anchor, width, availWidth, marginLeft);
}
// Apply offsets
if (opt.offsetY !== undefined) row += opt.offsetY;
if (opt.offsetX !== undefined) col += opt.offsetX;
// Clamp to terminal bounds (respecting margins)
row = Math.max(
marginTop,
Math.min(row, termHeight - marginBottom - effectiveHeight),
);
col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
return { width, row, col, maxHeight };
}
// ─── Line compositing ───────────────────────────────────────────────────────
const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
/** Append reset sequences to each non-image line. */
export function applyLineResets(lines: string[]): string[] {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!isImageLine(line)) {
lines[i] = line + SEGMENT_RESET;
}
}
return lines;
}
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
export function compositeLineAt(
baseLine: string,
overlayLine: string,
startCol: number,
overlayWidth: number,
totalWidth: number,
): string {
if (isImageLine(baseLine)) return baseLine;
// Single pass through baseLine extracts both before and after segments
const afterStart = startCol + overlayWidth;
const base = extractSegments(
baseLine,
startCol,
afterStart,
totalWidth - afterStart,
true,
);
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
// Pad segments to target widths
const beforePad = Math.max(0, startCol - base.beforeWidth);
const overlayPad = Math.max(0, overlayWidth - overlay.width);
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
const afterTarget = Math.max(
0,
totalWidth - actualBeforeWidth - actualOverlayWidth,
);
const afterPad = Math.max(0, afterTarget - base.afterWidth);
// Compose result
const r = SEGMENT_RESET;
const result =
base.before +
" ".repeat(beforePad) +
r +
overlay.text +
" ".repeat(overlayPad) +
r +
base.after +
" ".repeat(afterPad);
// CRITICAL: Always verify and truncate to terminal width.
// This is the final safeguard against width overflow which would crash the TUI.
// Width tracking can drift from actual visible width due to:
// - Complex ANSI/OSC sequences (hyperlinks, colors)
// - Wide characters at segment boundaries
// - Edge cases in segment extraction
const resultWidth = visibleWidth(result);
if (resultWidth <= totalWidth) {
return result;
}
// Truncate with strict=true to ensure we don't exceed totalWidth
return sliceByColumn(result, 0, totalWidth, true);
}
// ─── Overlay compositing ────────────────────────────────────────────────────
export interface OverlayEntry {
component: { render(width: number): string[]; invalidate?(): void };
options?: OverlayOptions;
hidden: boolean;
focusOrder: number;
}
/** Check if an overlay entry is currently visible */
export function isOverlayVisible(
entry: OverlayEntry,
termWidth: number,
termHeight: number,
): boolean {
if (entry.hidden) return false;
if (entry.options?.visible) {
return entry.options.visible(termWidth, termHeight);
}
return true;
}
/**
* Composite all visible overlays into content lines.
* Sorted by focusOrder (higher = on top).
*/
export function compositeOverlays(
lines: string[],
overlayStack: OverlayEntry[],
termWidth: number,
termHeight: number,
maxLinesRendered: number,
): string[] {
if (overlayStack.length === 0) return lines;
const result = [...lines];
// Pre-render all visible overlays and calculate positions
const rendered: {
overlayLines: string[];
row: number;
col: number;
w: number;
}[] = [];
let minLinesNeeded = result.length;
const visibleEntries = overlayStack.filter((e) =>
isOverlayVisible(e, termWidth, termHeight),
);
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
for (const entry of visibleEntries) {
const { component, options } = entry;
// Get layout with height=0 first to determine width and maxHeight
// (width and maxHeight don't depend on overlay height)
const { width, maxHeight } = resolveOverlayLayout(
options,
0,
termWidth,
termHeight,
);
// Render component at calculated width (isolated from base frame errors)
let overlayLines = tryRender(
component,
width,
`overlay[focusOrder=${entry.focusOrder}]`,
);
// Apply maxHeight if specified
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
overlayLines = overlayLines.slice(0, maxHeight);
}
// Get final row/col with actual overlay height
const { row, col } = resolveOverlayLayout(
options,
overlayLines.length,
termWidth,
termHeight,
);
rendered.push({ overlayLines, row, col, w: width });
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
}
// Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
// maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
const workingHeight = Math.max(maxLinesRendered, minLinesNeeded);
// Extend result with empty lines if content is too short for overlay placement or working area
while (result.length < workingHeight) {
result.push("");
}
const viewportStart = Math.max(0, workingHeight - termHeight);
// Apply backdrop dimming if any visible overlay requests it.
// Uses dim + gray foreground so text fades without painting empty lines.
const hasBackdrop = visibleEntries.some((e) => e.options?.backdrop);
if (hasBackdrop) {
const dimFn = (text: string) =>
`\x1b[2m\x1b[38;5;240m${text}\x1b[39m\x1b[22m`;
for (let i = viewportStart; i < result.length; i++) {
if (!isImageLine(result[i]) && result[i].length > 0) {
result[i] = applyBackgroundToLine(result[i], termWidth, dimFn);
}
}
}
// Composite each overlay
for (const { overlayLines, row, col, w } of rendered) {
for (let i = 0; i < overlayLines.length; i++) {
const idx = viewportStart + row + i;
if (idx >= 0 && idx < result.length) {
// Defensive: truncate overlay line to declared width before compositing
// (components should already respect width, but this ensures it)
const truncatedOverlayLine =
visibleWidth(overlayLines[i]) > w
? sliceByColumn(overlayLines[i], 0, w, true)
: overlayLines[i];
result[idx] = compositeLineAt(
result[idx],
truncatedOverlayLine,
col,
w,
termWidth,
);
}
}
}
return result;
}
// ─── Cursor extraction ──────────────────────────────────────────────────────
/**
* Find and extract cursor position from rendered lines.
* Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
* Only scans the bottom terminal height lines (visible viewport).
* @param lines - Rendered lines to search (mutated to strip marker)
* @param height - Terminal height (visible viewport size)
* @returns Cursor position { row, col } or null if no marker found
*/
export function extractCursorPosition(
lines: string[],
height: number,
): { row: number; col: number } | null {
// Only scan the bottom `height` lines (visible viewport)
const viewportTop = Math.max(0, lines.length - height);
for (let row = lines.length - 1; row >= viewportTop; row--) {
const line = lines[row];
const markerIndex = line.indexOf(CURSOR_MARKER);
if (markerIndex !== -1) {
// Calculate visual column (width of text before marker)
const beforeMarker = line.slice(0, markerIndex);
const col = visibleWidth(beforeMarker);
// Strip marker from the line
lines[row] =
line.slice(0, markerIndex) +
line.slice(markerIndex + CURSOR_MARKER.length);
return { row, col };
}
}
return null;
}