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 = { content: string // Pre-rendered string with ANSI color codes position: 'top' | 'bottom' align: 'start' | 'end' | 'center' offset?: number // Only used with 'start' or 'end' alignment. Number of characters from the edge. } export const CUSTOM_BORDER_STYLES = { dashed: { top: '╌', left: '╎', right: '╎', bottom: '╌', // there aren't any line-drawing characters for dashes unfortunately 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') { position = offset + 1 // +1 to account for corner character } else { // align === 'end' position = borderLength - textLength - offset - 1 // -1 for corner character } // Ensure position is valid 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) { 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 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 : '') : '' // Handle text in top border 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, ) 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) 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 : '') : '' // Handle text in bottom border 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