import chalk from 'chalk'; import cliBoxes, { type Boxes, type BoxStyle } from 'cli-boxes'; import { applyColor } from './colorize.js'; import type { DOMNode } from './dom.js'; import type Output from './output.js'; import { stringWidth } from './stringWidth.js'; import type { Color } from './styles.js'; export type BorderTextOptions = { /** Pre-rendered string, may include SGR sequences. */ content: string; position: 'top' | 'bottom'; align: 'start' | 'end' | 'center'; /** Offset from the chosen edge, in cells. Only used with start/end align. */ offset?: number; }; /** Border presets that aren't in cli-boxes. */ export const CUSTOM_BORDER_STYLES = { dashed: { top: '╌', left: '╎', right: '╎', bottom: '╌', topLeft: ' ', topRight: ' ', bottomLeft: ' ', bottomRight: ' ', }, } as const; export type BorderStyle = | keyof Boxes | keyof typeof CUSTOM_BORDER_STYLES | BoxStyle; function embedTextInBorder( borderLine: string, text: string, align: 'start' | 'end' | 'center', offset: number = 0, borderChar: string, ): [before: string, text: string, after: string] { const textLength = stringWidth(text); const borderLength = borderLine.length; if (textLength >= borderLength - 2) { return ['', text.substring(0, borderLength), '']; } let position: number; if (align === 'center') { position = Math.floor((borderLength - textLength) / 2); } else if (align === 'start') { // +1 leaves room for the corner glyph. position = offset + 1; } else { // align === 'end' position = borderLength - textLength - offset - 1; } // Clamp so the text never collides with a corner. position = Math.max(1, Math.min(position, borderLength - textLength - 1)); const before = borderLine.substring(0, 1) + borderChar.repeat(position - 1); const after = borderChar.repeat(borderLength - position - textLength - 1) + borderLine.substring(borderLength - 1); return [before, text, after]; } function styleBorderLine( line: string, color: Color | undefined, dim: boolean | undefined, ): string { let styled = applyColor(line, color); if (dim) { styled = chalk.dim(styled); } return styled; } const renderBorder = ( x: number, y: number, node: DOMNode, output: Output, ): void => { if (!node.style.borderStyle) { return; } const width = Math.floor(node.yogaNode!.getComputedWidth()); const height = Math.floor(node.yogaNode!.getComputedHeight()); const box = typeof node.style.borderStyle === 'string' ? (CUSTOM_BORDER_STYLES[ node.style.borderStyle as keyof typeof CUSTOM_BORDER_STYLES ] ?? cliBoxes[node.style.borderStyle as keyof Boxes]) : node.style.borderStyle; // Per-side colour falls back to the catch-all borderColor; same for the // dim flag. This mirrors how every CSS-shaped border API behaves. const topBorderColor = node.style.borderTopColor ?? node.style.borderColor; const bottomBorderColor = node.style.borderBottomColor ?? node.style.borderColor; const leftBorderColor = node.style.borderLeftColor ?? node.style.borderColor; const rightBorderColor = node.style.borderRightColor ?? node.style.borderColor; const dimTopBorderColor = node.style.borderTopDimColor ?? node.style.borderDimColor; const dimBottomBorderColor = node.style.borderBottomDimColor ?? node.style.borderDimColor; const dimLeftBorderColor = node.style.borderLeftDimColor ?? node.style.borderDimColor; const dimRightBorderColor = node.style.borderRightDimColor ?? node.style.borderDimColor; const showTopBorder = node.style.borderTop !== false; const showBottomBorder = node.style.borderBottom !== false; const showLeftBorder = node.style.borderLeft !== false; const showRightBorder = node.style.borderRight !== false; const contentWidth = Math.max( 0, width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0), ); const topBorderLine = showTopBorder ? (showLeftBorder ? box.topLeft : '') + box.top.repeat(contentWidth) + (showRightBorder ? box.topRight : '') : ''; let topBorder: string | undefined; if (showTopBorder && node.style.borderText?.position === 'top') { const [before, text, after] = embedTextInBorder( topBorderLine, node.style.borderText.content, node.style.borderText.align, node.style.borderText.offset, box.top, ); // Style the border slices around the text but leave the text itself // alone — callers pass already-coloured content. topBorder = styleBorderLine(before, topBorderColor, dimTopBorderColor) + text + styleBorderLine(after, topBorderColor, dimTopBorderColor); } else if (showTopBorder) { topBorder = styleBorderLine( topBorderLine, topBorderColor, dimTopBorderColor, ); } let verticalBorderHeight = height; if (showTopBorder) verticalBorderHeight -= 1; if (showBottomBorder) verticalBorderHeight -= 1; verticalBorderHeight = Math.max(0, verticalBorderHeight); // Build the vertical borders as one tall string with embedded newlines — // Output.write will place each line at successive rows. Dim is applied // once at the end so the SGR pair brackets the whole column instead of // each character. let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat( verticalBorderHeight, ); if (dimLeftBorderColor) { leftBorder = chalk.dim(leftBorder); } let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat( verticalBorderHeight, ); if (dimRightBorderColor) { rightBorder = chalk.dim(rightBorder); } const bottomBorderLine = showBottomBorder ? (showLeftBorder ? box.bottomLeft : '') + box.bottom.repeat(contentWidth) + (showRightBorder ? box.bottomRight : '') : ''; let bottomBorder: string | undefined; if (showBottomBorder && node.style.borderText?.position === 'bottom') { const [before, text, after] = embedTextInBorder( bottomBorderLine, node.style.borderText.content, node.style.borderText.align, node.style.borderText.offset, box.bottom, ); bottomBorder = styleBorderLine(before, bottomBorderColor, dimBottomBorderColor) + text + styleBorderLine(after, bottomBorderColor, dimBottomBorderColor); } else if (showBottomBorder) { bottomBorder = styleBorderLine( bottomBorderLine, bottomBorderColor, dimBottomBorderColor, ); } const offsetY = showTopBorder ? 1 : 0; if (topBorder) { output.write(x, y, topBorder); } if (showLeftBorder) { output.write(x, y + offsetY, leftBorder); } if (showRightBorder) { output.write(x + width - 1, y + offsetY, rightBorder); } if (bottomBorder) { output.write(x, y + height - 1, bottomBorder); } }; export default renderBorder;