UNPKG

tuix

Version:

A performant TUI framework for Bun with JSX and reactive state management

395 lines (343 loc) 11.6 kB
/** * Flexbox Layout - Flexible box layout container * * Implements a CSS flexbox-inspired layout system for TUI applications. * Supports: * - Row and column layouts * - Justify content (main axis alignment) * - Align items (cross axis alignment) * - Flex grow/shrink/basis * - Gap between items * - Wrapping */ import { Effect } from "effect" import { stringWidth } from "@/utils/string-width.ts" import type { View } from "@/core/types.ts" import { type FlexboxProps, type FlexItem, type LayoutRect, type LayoutResult, FlexDirection, JustifyContent, AlignItems, FlexWrap } from "./types.ts" // ============================================================================= // Helper Functions // ============================================================================= /** * Get the size of a view */ const getViewSize = (view: View): { width: number; height: number } => { return { width: view.width || 0, height: view.height || 1 } } /** * Calculate the main axis size for a flex item */ const getMainAxisSize = ( item: { view: View }, direction: FlexDirection ): number => { const size = getViewSize(item.view) return direction === FlexDirection.Row || direction === FlexDirection.RowReverse ? size.width : size.height } /** * Calculate the cross axis size for a flex item */ const getCrossAxisSize = ( item: { view: View }, direction: FlexDirection ): number => { const size = getViewSize(item.view) return direction === FlexDirection.Row || direction === FlexDirection.RowReverse ? size.height : size.width } /** * Calculate flex basis for an item */ const calculateFlexBasis = ( item: FlexItem, direction: FlexDirection ): number => { if (item.basis === "auto" || item.basis === undefined) { return getMainAxisSize(item, direction) } return item.basis } /** * Calculate total gap size */ const calculateTotalGap = ( itemCount: number, gap: number, direction: FlexDirection, props: FlexboxProps ): number => { if (itemCount <= 1) return 0 const effectiveGap = direction === FlexDirection.Row || direction === FlexDirection.RowReverse ? props.columnGap ?? gap : props.rowGap ?? gap return (itemCount - 1) * effectiveGap } // ============================================================================= // Layout Calculation // ============================================================================= /** * Calculate flexbox layout */ const calculateFlexLayout = ( items: ReadonlyArray<FlexItem>, containerWidth: number, containerHeight: number, props: FlexboxProps ): LayoutResult => { const direction = props.direction ?? FlexDirection.Row const justifyContent = props.justifyContent ?? JustifyContent.Start const alignItems = props.alignItems ?? AlignItems.Start const wrap = props.wrap ?? FlexWrap.NoWrap const gap = props.gap ?? 0 const padding = props.padding ?? {} // Apply padding to container dimensions const availableWidth = containerWidth - (padding.left ?? 0) - (padding.right ?? 0) const availableHeight = containerHeight - (padding.top ?? 0) - (padding.bottom ?? 0) // Determine main and cross axis sizes const isRow = direction === FlexDirection.Row || direction === FlexDirection.RowReverse const mainAxisSize = isRow ? availableWidth : availableHeight const crossAxisSize = isRow ? availableHeight : availableWidth // Calculate flex basis for each item const flexBases = items.map(item => calculateFlexBasis(item, direction)) // Calculate total flex grow and shrink const totalFlexGrow = items.reduce((sum, item) => sum + (item.grow ?? 0), 0) const totalFlexShrink = items.reduce((sum, item) => sum + (item.shrink ?? 1), 0) // Calculate total gap const totalGap = calculateTotalGap(items.length, gap, direction, props) // Calculate total basis size const totalBasisSize = flexBases.reduce((sum, basis) => sum + basis, 0) + totalGap // Calculate remaining space const remainingSpace = mainAxisSize - totalBasisSize // Distribute space based on flex properties const finalSizes = items.map((item, index) => { const basis = flexBases[index] let size = basis if (remainingSpace > 0 && totalFlexGrow > 0) { // Distribute positive space based on flex-grow const grow = item.grow ?? 0 size += (remainingSpace * grow) / totalFlexGrow } else if (remainingSpace < 0 && totalFlexShrink > 0) { // Distribute negative space based on flex-shrink const shrink = item.shrink ?? 1 size += (remainingSpace * shrink) / totalFlexShrink } return Math.max(0, Math.floor(size)) }) // Calculate positions based on justify content const positions: number[] = [] let currentPos = padding.top ?? 0 if (isRow) { currentPos = padding.left ?? 0 } // Handle justify content const totalItemSize = finalSizes.reduce((sum, size) => sum + size, 0) + totalGap const freeSpace = mainAxisSize - totalItemSize switch (justifyContent) { case JustifyContent.Center: currentPos += freeSpace / 2 break case JustifyContent.End: currentPos += freeSpace break case JustifyContent.SpaceAround: currentPos += freeSpace / (items.length * 2) break case JustifyContent.SpaceEvenly: currentPos += freeSpace / (items.length + 1) break } // Calculate positions for each item items.forEach((_, index) => { positions.push(currentPos) currentPos += finalSizes[index] // Add gap if (index < items.length - 1) { currentPos += gap // Add extra space for justify content switch (justifyContent) { case JustifyContent.SpaceBetween: if (items.length > 1) { currentPos += freeSpace / (items.length - 1) } break case JustifyContent.SpaceAround: currentPos += freeSpace / items.length break case JustifyContent.SpaceEvenly: currentPos += freeSpace / (items.length + 1) break } } }) // Calculate cross axis positions based on align items const crossPositions = items.map((item, index) => { const itemCrossSize = getCrossAxisSize(item, direction) const alignSelf = item.alignSelf ?? alignItems switch (alignSelf) { case AlignItems.Center: return (crossAxisSize - itemCrossSize) / 2 case AlignItems.End: return crossAxisSize - itemCrossSize case AlignItems.Stretch: // Stretch doesn't change position, just size return 0 default: return 0 } }) // Create layout result const children = items.map((item, index) => { const mainPos = positions[index] const crossPos = crossPositions[index] + (isRow ? (padding.top ?? 0) : (padding.left ?? 0)) const mainSize = finalSizes[index] const crossSize = item.alignSelf === AlignItems.Stretch || alignItems === AlignItems.Stretch ? crossAxisSize : getCrossAxisSize(item, direction) const bounds: LayoutRect = isRow ? { x: mainPos, y: crossPos, width: mainSize, height: crossSize } : { x: crossPos, y: mainPos, width: crossSize, height: mainSize } return { view: item.view, bounds } }) return { bounds: { x: 0, y: 0, width: containerWidth, height: containerHeight }, children } } // ============================================================================= // Flexbox View Creation // ============================================================================= /** * Create a flexbox container view */ export const flexbox = ( items: ReadonlyArray<FlexItem | View>, props: FlexboxProps = {} ): View => { // Normalize items to FlexItem const flexItems = items.map(item => 'view' in item ? item : { view: item } ) // Calculate dimensions const padding = props.padding ?? {} const paddingH = (padding.left ?? 0) + (padding.right ?? 0) const paddingV = (padding.top ?? 0) + (padding.bottom ?? 0) // For now, use a simple size calculation // In a real implementation, this would be more sophisticated const isRow = props.direction === FlexDirection.Row || props.direction === FlexDirection.RowReverse let totalWidth = paddingH let totalHeight = paddingV if (isRow) { // Row layout totalWidth += flexItems.reduce((sum, item) => sum + getViewSize(item.view).width, 0) totalWidth += calculateTotalGap(flexItems.length, props.gap ?? 0, props.direction ?? FlexDirection.Row, props) totalHeight += Math.max(...flexItems.map(item => getViewSize(item.view).height)) } else { // Column layout totalHeight += flexItems.reduce((sum, item) => sum + getViewSize(item.view).height, 0) totalHeight += calculateTotalGap(flexItems.length, props.gap ?? 0, props.direction ?? FlexDirection.Column, props) totalWidth += Math.max(...flexItems.map(item => getViewSize(item.view).width)) } return { render: () => Effect.gen(function* (_) { const layout = calculateFlexLayout(flexItems, totalWidth, totalHeight, props) // Create a 2D buffer for rendering const buffer: string[][] = Array(totalHeight).fill(null).map(() => Array(totalWidth).fill(' ') ) // Render each child into the buffer for (const child of layout.children) { const content = yield* _(child.view.render()) const lines = content.split('\n') for (let y = 0; y < lines.length && y < child.bounds.height; y++) { const line = lines[y] const chars = [...line] for (let x = 0; x < chars.length && x < child.bounds.width; x++) { const bufferY = child.bounds.y + y const bufferX = child.bounds.x + x if (bufferY >= 0 && bufferY < totalHeight && bufferX >= 0 && bufferX < totalWidth) { buffer[bufferY][bufferX] = chars[x] } } } } // Convert buffer to string return buffer.map(row => row.join('')).join('\n') }), width: totalWidth, height: totalHeight } } // ============================================================================= // Convenience Functions // ============================================================================= /** * Create a horizontal flexbox (row) */ export const hbox = ( items: ReadonlyArray<FlexItem | View>, props: Omit<FlexboxProps, 'direction'> = {} ): View => { return flexbox(items, { ...props, direction: FlexDirection.Row }) } /** * Create a vertical flexbox (column) */ export const vbox = ( items: ReadonlyArray<FlexItem | View>, props: Omit<FlexboxProps, 'direction'> = {} ): View => { return flexbox(items, { ...props, direction: FlexDirection.Column }) } /** * Center a view both horizontally and vertically */ export const center = (view: View, options?: { width?: number; height?: number }): View => { return flexbox([view], { justifyContent: JustifyContent.Center, alignItems: AlignItems.Center, minWidth: options?.width || 80, minHeight: options?.height || 24 }) } /** * Create a flexbox with items spaced evenly */ export const spread = ( items: ReadonlyArray<FlexItem | View>, props: Omit<FlexboxProps, 'justifyContent'> = {} ): View => { return flexbox(items, { ...props, justifyContent: JustifyContent.SpaceBetween }) }