tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
457 lines (411 loc) • 11.1 kB
text/typescript
/**
* Border System - Comprehensive border styles for UI components
*
* Inspired by Lipgloss's border system with support for:
* - Multiple border styles (normal, rounded, thick, double, etc.)
* - Partial borders (only specific sides)
* - Custom border characters
* - Border inheritance and composition
*/
import { stringWidth } from "@/utils/string-width.ts"
import { Data } from "effect"
// =============================================================================
// Border Types
// =============================================================================
/**
* Border representation with all possible border characters
*/
export interface Border {
readonly top: string
readonly bottom: string
readonly left: string
readonly right: string
readonly topLeft: string
readonly topRight: string
readonly bottomLeft: string
readonly bottomRight: string
// Optional middle characters for complex layouts
readonly middleLeft?: string
readonly middleRight?: string
readonly middleTop?: string
readonly middleBottom?: string
readonly middle?: string
}
/**
* Enum for specifying which sides of a border to render
* Uses bitflags for efficient combination
*/
export enum BorderSide {
None = 0,
Top = 1 << 0, // 1
Right = 1 << 1, // 2
Bottom = 1 << 2, // 4
Left = 1 << 3, // 8
All = Top | Right | Bottom | Left // 15
}
/**
* Helper to check if a side is enabled
*/
export const hasSide = (sides: BorderSide, side: BorderSide): boolean =>
(sides & side) === side
/**
* Helper to combine border sides
*/
export const combineSides = (...sides: BorderSide[]): BorderSide =>
sides.reduce((acc, side) => acc | side, BorderSide.None)
// =============================================================================
// Border Factory
// =============================================================================
/**
* Create a border with the same character for all positions
*/
const createUniformBorder = (char: string): Border => ({
top: char,
bottom: char,
left: char,
right: char,
topLeft: char,
topRight: char,
bottomLeft: char,
bottomRight: char
})
/**
* Create a custom border with specified characters
*/
export const createBorder = (chars: Partial<Border>): Border => ({
top: chars.top ?? " ",
bottom: chars.bottom ?? " ",
left: chars.left ?? " ",
right: chars.right ?? " ",
topLeft: chars.topLeft ?? " ",
topRight: chars.topRight ?? " ",
bottomLeft: chars.bottomLeft ?? " ",
bottomRight: chars.bottomRight ?? " ",
middleLeft: chars.middleLeft,
middleRight: chars.middleRight,
middleTop: chars.middleTop,
middleBottom: chars.middleBottom,
middle: chars.middle
})
// =============================================================================
// Predefined Borders
// =============================================================================
/**
* Collection of predefined border styles
*/
export const Borders = {
/**
* No visible border (spaces)
*/
None: createUniformBorder(" "),
/**
* Standard box-drawing border
* ┌─┐
* │ │
* └─┘
*/
Normal: {
top: "─",
bottom: "─",
left: "│",
right: "│",
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
middleLeft: "├",
middleRight: "┤",
middleTop: "┬",
middleBottom: "┴",
middle: "┼"
},
/**
* Rounded corner border
* ╭─╮
* │ │
* ╰─╯
*/
Rounded: {
top: "─",
bottom: "─",
left: "│",
right: "│",
topLeft: "╭",
topRight: "╮",
bottomLeft: "╰",
bottomRight: "╯",
middleLeft: "├",
middleRight: "┤",
middleTop: "┬",
middleBottom: "┴",
middle: "┼"
},
/**
* Thick/bold border
* ┏━┓
* ┃ ┃
* ┗━┛
*/
Thick: {
top: "━",
bottom: "━",
left: "┃",
right: "┃",
topLeft: "┏",
topRight: "┓",
bottomLeft: "┗",
bottomRight: "┛",
middleLeft: "┣",
middleRight: "┫",
middleTop: "┳",
middleBottom: "┻",
middle: "╋"
},
/**
* Double-line border
* ╔═╗
* ║ ║
* ╚═╝
*/
Double: {
top: "═",
bottom: "═",
left: "║",
right: "║",
topLeft: "╔",
topRight: "╗",
bottomLeft: "╚",
bottomRight: "╝",
middleLeft: "╠",
middleRight: "╣",
middleTop: "╦",
middleBottom: "╩",
middle: "╬"
},
/**
* Classic ASCII border
* +-+
* | |
* +-+
*/
ASCII: {
top: "-",
bottom: "-",
left: "|",
right: "|",
topLeft: "+",
topRight: "+",
bottomLeft: "+",
bottomRight: "+",
middleLeft: "+",
middleRight: "+",
middleTop: "+",
middleBottom: "+",
middle: "+"
},
/**
* Dotted border
* ·─·
* │ │
* ·─·
*/
Dotted: {
top: "─",
bottom: "─",
left: "│",
right: "│",
topLeft: "·",
topRight: "·",
bottomLeft: "·",
bottomRight: "·"
},
/**
* Dashed border
* ┌╌┐
* ╎ ╎
* └╌┘
*/
Dashed: {
top: "╌",
bottom: "╌",
left: "╎",
right: "╎",
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘"
},
/**
* Block border (solid blocks)
* ███
* █ █
* ███
*/
Block: {
top: "█",
bottom: "█",
left: "█",
right: "█",
topLeft: "█",
topRight: "█",
bottomLeft: "█",
bottomRight: "█"
},
/**
* Minimal border (only corners)
* ┌ ┐
*
* └ ┘
*/
Minimal: {
top: " ",
bottom: " ",
left: " ",
right: " ",
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘"
},
/**
* Hidden border (no characters, but preserves spacing)
* Useful for alignment without visible borders
*/
Hidden: createUniformBorder("\x00")
} as const
// Aliases for common border styles
Object.defineProperty(Borders, 'Single', {
value: Borders.Normal,
enumerable: true,
configurable: false
})
// =============================================================================
// Border Utilities
// =============================================================================
/**
* Get the character for a specific position considering which sides are enabled
*/
export const getBorderChar = (
border: Border,
position: keyof Border,
sides: BorderSide
): string => {
// If no sides are enabled, return empty
if (sides === BorderSide.None) return " "
// Handle corners based on which sides are enabled
switch (position) {
case "topLeft":
if (!hasSide(sides, BorderSide.Top) && !hasSide(sides, BorderSide.Left)) return " "
if (!hasSide(sides, BorderSide.Top)) return border.left
if (!hasSide(sides, BorderSide.Left)) return border.top
return border.topLeft
case "topRight":
if (!hasSide(sides, BorderSide.Top) && !hasSide(sides, BorderSide.Right)) return " "
if (!hasSide(sides, BorderSide.Top)) return border.right
if (!hasSide(sides, BorderSide.Right)) return border.top
return border.topRight
case "bottomLeft":
if (!hasSide(sides, BorderSide.Bottom) && !hasSide(sides, BorderSide.Left)) return " "
if (!hasSide(sides, BorderSide.Bottom)) return border.left
if (!hasSide(sides, BorderSide.Left)) return border.bottom
return border.bottomLeft
case "bottomRight":
if (!hasSide(sides, BorderSide.Bottom) && !hasSide(sides, BorderSide.Right)) return " "
if (!hasSide(sides, BorderSide.Bottom)) return border.right
if (!hasSide(sides, BorderSide.Right)) return border.bottom
return border.bottomRight
case "top":
return hasSide(sides, BorderSide.Top) ? border.top : " "
case "bottom":
return hasSide(sides, BorderSide.Bottom) ? border.bottom : " "
case "left":
return hasSide(sides, BorderSide.Left) ? border.left : " "
case "right":
return hasSide(sides, BorderSide.Right) ? border.right : " "
default:
return border[position] ?? " "
}
}
/**
* Render a box with the specified border
*/
export const renderBox = (
content: string[],
border: Border,
sides: BorderSide = BorderSide.All,
width?: number
): string[] => {
if (content.length === 0) return []
// Calculate the maximum content width using stringWidth for ANSI support
const contentWidth = width ?? Math.max(...content.map(line => stringWidth(line)))
const result: string[] = []
// Top border
if (hasSide(sides, BorderSide.Top)) {
const topLine =
getBorderChar(border, "topLeft", sides) +
getBorderChar(border, "top", sides).repeat(contentWidth) +
getBorderChar(border, "topRight", sides)
result.push(topLine)
}
// Content with side borders
for (const line of content) {
// Pad to visual width, accounting for ANSI escape sequences
const currentWidth = stringWidth(line)
const padding = Math.max(0, contentWidth - currentWidth)
const paddedLine = line + ' '.repeat(padding)
const contentLine =
getBorderChar(border, "left", sides) +
paddedLine +
getBorderChar(border, "right", sides)
result.push(contentLine)
}
// Bottom border
if (hasSide(sides, BorderSide.Bottom)) {
const bottomLine =
getBorderChar(border, "bottomLeft", sides) +
getBorderChar(border, "bottom", sides).repeat(contentWidth) +
getBorderChar(border, "bottomRight", sides)
result.push(bottomLine)
}
return result
}
/**
* Merge two borders, with the second border taking precedence for non-empty values
*/
export const mergeBorders = (base: Border, overlay: Partial<Border>): Border => ({
top: overlay.top ?? base.top,
bottom: overlay.bottom ?? base.bottom,
left: overlay.left ?? base.left,
right: overlay.right ?? base.right,
topLeft: overlay.topLeft ?? base.topLeft,
topRight: overlay.topRight ?? base.topRight,
bottomLeft: overlay.bottomLeft ?? base.bottomLeft,
bottomRight: overlay.bottomRight ?? base.bottomRight,
middleLeft: overlay.middleLeft ?? base.middleLeft,
middleRight: overlay.middleRight ?? base.middleRight,
middleTop: overlay.middleTop ?? base.middleTop,
middleBottom: overlay.middleBottom ?? base.middleBottom,
middle: overlay.middle ?? base.middle
})
/**
* Create a border from a string pattern
* Useful for quick custom borders
*
* @example
* borderFromPattern("╭─╮│ │╰─╯") creates a rounded border
*/
export const borderFromPattern = (pattern: string): Border => {
if (pattern.length < 8) {
throw new Error("Border pattern must have at least 8 characters")
}
return {
topLeft: pattern[0],
top: pattern[1],
topRight: pattern[2],
left: pattern[3],
right: pattern[4],
bottomLeft: pattern[5],
bottom: pattern[6],
bottomRight: pattern[7]
}
}