/ ink / render-border.ts
render-border.ts
  1  import chalk from 'chalk'
  2  import cliBoxes, { type Boxes, type BoxStyle } from 'cli-boxes'
  3  import { applyColor } from './colorize.js'
  4  import type { DOMNode } from './dom.js'
  5  import type Output from './output.js'
  6  import { stringWidth } from './stringWidth.js'
  7  import type { Color } from './styles.js'
  8  
  9  export type BorderTextOptions = {
 10    content: string // Pre-rendered string with ANSI color codes
 11    position: 'top' | 'bottom'
 12    align: 'start' | 'end' | 'center'
 13    offset?: number // Only used with 'start' or 'end' alignment. Number of characters from the edge.
 14  }
 15  
 16  export const CUSTOM_BORDER_STYLES = {
 17    dashed: {
 18      top: '╌',
 19      left: '╎',
 20      right: '╎',
 21      bottom: '╌',
 22      // there aren't any line-drawing characters for dashes unfortunately
 23      topLeft: ' ',
 24      topRight: ' ',
 25      bottomLeft: ' ',
 26      bottomRight: ' ',
 27    },
 28  } as const
 29  
 30  export type BorderStyle =
 31    | keyof Boxes
 32    | keyof typeof CUSTOM_BORDER_STYLES
 33    | BoxStyle
 34  
 35  function embedTextInBorder(
 36    borderLine: string,
 37    text: string,
 38    align: 'start' | 'end' | 'center',
 39    offset: number = 0,
 40    borderChar: string,
 41  ): [before: string, text: string, after: string] {
 42    const textLength = stringWidth(text)
 43    const borderLength = borderLine.length
 44  
 45    if (textLength >= borderLength - 2) {
 46      return ['', text.substring(0, borderLength), '']
 47    }
 48  
 49    let position: number
 50    if (align === 'center') {
 51      position = Math.floor((borderLength - textLength) / 2)
 52    } else if (align === 'start') {
 53      position = offset + 1 // +1 to account for corner character
 54    } else {
 55      // align === 'end'
 56      position = borderLength - textLength - offset - 1 // -1 for corner character
 57    }
 58  
 59    // Ensure position is valid
 60    position = Math.max(1, Math.min(position, borderLength - textLength - 1))
 61  
 62    const before = borderLine.substring(0, 1) + borderChar.repeat(position - 1)
 63    const after =
 64      borderChar.repeat(borderLength - position - textLength - 1) +
 65      borderLine.substring(borderLength - 1)
 66  
 67    return [before, text, after]
 68  }
 69  
 70  function styleBorderLine(
 71    line: string,
 72    color: Color | undefined,
 73    dim: boolean | undefined,
 74  ): string {
 75    let styled = applyColor(line, color)
 76    if (dim) {
 77      styled = chalk.dim(styled)
 78    }
 79    return styled
 80  }
 81  
 82  const renderBorder = (
 83    x: number,
 84    y: number,
 85    node: DOMNode,
 86    output: Output,
 87  ): void => {
 88    if (node.style.borderStyle) {
 89      const width = Math.floor(node.yogaNode!.getComputedWidth())
 90      const height = Math.floor(node.yogaNode!.getComputedHeight())
 91      const box =
 92        typeof node.style.borderStyle === 'string'
 93          ? (CUSTOM_BORDER_STYLES[
 94              node.style.borderStyle as keyof typeof CUSTOM_BORDER_STYLES
 95            ] ?? cliBoxes[node.style.borderStyle as keyof Boxes])
 96          : node.style.borderStyle
 97  
 98      const topBorderColor = node.style.borderTopColor ?? node.style.borderColor
 99      const bottomBorderColor =
100        node.style.borderBottomColor ?? node.style.borderColor
101      const leftBorderColor = node.style.borderLeftColor ?? node.style.borderColor
102      const rightBorderColor =
103        node.style.borderRightColor ?? node.style.borderColor
104  
105      const dimTopBorderColor =
106        node.style.borderTopDimColor ?? node.style.borderDimColor
107  
108      const dimBottomBorderColor =
109        node.style.borderBottomDimColor ?? node.style.borderDimColor
110  
111      const dimLeftBorderColor =
112        node.style.borderLeftDimColor ?? node.style.borderDimColor
113  
114      const dimRightBorderColor =
115        node.style.borderRightDimColor ?? node.style.borderDimColor
116  
117      const showTopBorder = node.style.borderTop !== false
118      const showBottomBorder = node.style.borderBottom !== false
119      const showLeftBorder = node.style.borderLeft !== false
120      const showRightBorder = node.style.borderRight !== false
121  
122      const contentWidth = Math.max(
123        0,
124        width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0),
125      )
126  
127      const topBorderLine = showTopBorder
128        ? (showLeftBorder ? box.topLeft : '') +
129          box.top.repeat(contentWidth) +
130          (showRightBorder ? box.topRight : '')
131        : ''
132  
133      // Handle text in top border
134      let topBorder: string | undefined
135      if (showTopBorder && node.style.borderText?.position === 'top') {
136        const [before, text, after] = embedTextInBorder(
137          topBorderLine,
138          node.style.borderText.content,
139          node.style.borderText.align,
140          node.style.borderText.offset,
141          box.top,
142        )
143        topBorder =
144          styleBorderLine(before, topBorderColor, dimTopBorderColor) +
145          text +
146          styleBorderLine(after, topBorderColor, dimTopBorderColor)
147      } else if (showTopBorder) {
148        topBorder = styleBorderLine(
149          topBorderLine,
150          topBorderColor,
151          dimTopBorderColor,
152        )
153      }
154  
155      let verticalBorderHeight = height
156  
157      if (showTopBorder) {
158        verticalBorderHeight -= 1
159      }
160  
161      if (showBottomBorder) {
162        verticalBorderHeight -= 1
163      }
164  
165      verticalBorderHeight = Math.max(0, verticalBorderHeight)
166  
167      let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat(
168        verticalBorderHeight,
169      )
170  
171      if (dimLeftBorderColor) {
172        leftBorder = chalk.dim(leftBorder)
173      }
174  
175      let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat(
176        verticalBorderHeight,
177      )
178  
179      if (dimRightBorderColor) {
180        rightBorder = chalk.dim(rightBorder)
181      }
182  
183      const bottomBorderLine = showBottomBorder
184        ? (showLeftBorder ? box.bottomLeft : '') +
185          box.bottom.repeat(contentWidth) +
186          (showRightBorder ? box.bottomRight : '')
187        : ''
188  
189      // Handle text in bottom border
190      let bottomBorder: string | undefined
191      if (showBottomBorder && node.style.borderText?.position === 'bottom') {
192        const [before, text, after] = embedTextInBorder(
193          bottomBorderLine,
194          node.style.borderText.content,
195          node.style.borderText.align,
196          node.style.borderText.offset,
197          box.bottom,
198        )
199        bottomBorder =
200          styleBorderLine(before, bottomBorderColor, dimBottomBorderColor) +
201          text +
202          styleBorderLine(after, bottomBorderColor, dimBottomBorderColor)
203      } else if (showBottomBorder) {
204        bottomBorder = styleBorderLine(
205          bottomBorderLine,
206          bottomBorderColor,
207          dimBottomBorderColor,
208        )
209      }
210  
211      const offsetY = showTopBorder ? 1 : 0
212  
213      if (topBorder) {
214        output.write(x, y, topBorder)
215      }
216  
217      if (showLeftBorder) {
218        output.write(x, y + offsetY, leftBorder)
219      }
220  
221      if (showRightBorder) {
222        output.write(x + width - 1, y + offsetY, rightBorder)
223      }
224  
225      if (bottomBorder) {
226        output.write(x, y + height - 1, bottomBorder)
227      }
228    }
229  }
230  
231  export default renderBorder