UNPKG

tuix

Version:

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

208 lines (182 loc) 6.28 kB
/** * View System - Basic view primitives for rendering */ import { Effect } from "effect" import { stringWidth } from "@/utils/string-width.ts" import type { View, RenderError } from "@/core/types.ts" import { RendererService } from "@/services/index.ts" import { style as createStyle, renderStyledSync, type Style } from "@/styling/index.ts" /** * Create a simple text view */ export const text = (content: string): View => ({ render: () => Effect.succeed(content), width: content.split('\n').reduce((max, line) => Math.max(max, stringWidth(line)), 0), height: content.split('\n').length }) /** * Create an empty view */ export const empty: View = text('') /** * Alias for text() to match test expectations */ export const createView = text /** * Check if an object is a View */ export const isView = (obj: any): obj is View => { return obj && typeof obj.render === 'function' } /** * Measure a view's dimensions */ export const measureView = (view: View) => Effect.succeed({ width: view.width || 0, height: view.height || 0 }) /** * Render a view to string */ export const renderView = (view: View) => view.render() /** * Combine multiple views vertically */ export const vstack = (...views: View[]): View => ({ render: () => Effect.gen(function* (_) { const rendered = yield* _( Effect.forEach(views, v => v.render()) ) return rendered.join('\n') }), width: Math.max(...views.map(v => v.width || 0)), height: views.reduce((sum, v) => sum + (v.height || 1), 0) }) /** * Combine multiple views horizontally with proper multi-line support * Note: For advanced alignment options, use joinHorizontal from @/layout/join.ts */ export const hstack = (...views: View[]): View => ({ render: () => Effect.gen(function* (_) { // Render all views const renderedViews = yield* _( Effect.forEach(views, (v, index) => Effect.gen(function* (_) { const content = yield* _(v.render()) return { content, width: v.width || 0, index } }) ) ) // Split each into lines const viewData = renderedViews.map(({ content, width }) => { const lines = content.split('\n') return { lines, width } }) // Find max height const maxHeight = Math.max(...viewData.map(({ lines }) => lines.length)) // Pad shorter views to max height and ensure each line is full width const aligned = viewData.map(({ lines, width }) => { // Pad each line to the view's width const paddedLines = lines.map(line => { const lineWidth = stringWidth(line) if (lineWidth < width) { return line + ' '.repeat(width - lineWidth) } return line }) // Add empty lines if needed const height = paddedLines.length if (height < maxHeight) { const emptyLine = ' '.repeat(width) const padding = maxHeight - height return [...paddedLines, ...Array(padding).fill(emptyLine)] } return paddedLines }) // Join horizontally line by line const result: string[] = [] for (let i = 0; i < maxHeight; i++) { const line = aligned.map(lines => lines[i] || '').join('') result.push(line) } return result.join('\n') }), width: views.reduce((sum, v) => sum + (v.width || 0), 0), height: Math.max(...views.map(v => v.height || 1)) }) /** * Create a box around a view */ export const box = (view: View): View => ({ render: () => Effect.gen(function* (_) { const content = yield* _(view.render()) const lines = content.split('\n') const width = Math.max(...lines.map(l => stringWidth(l))) const top = '┌' + '─'.repeat(width + 2) + '┐' const bottom = '└' + '─'.repeat(width + 2) + '┘' const boxedLines = lines.map(line => { const lineWidth = stringWidth(line) const padding = width - lineWidth return '│ ' + line + ' '.repeat(padding) + ' │' }) return [top, ...boxedLines, bottom].join('\n') }), width: (view.width || 0) + 4, height: (view.height || 0) + 2 }) /** * Center a view within a given width */ export const center = (view: View, totalWidth: number): View => ({ render: () => Effect.gen(function* (_) { const content = yield* _(view.render()) const lines = content.split('\n') return lines.map(line => { const lineWidth = stringWidth(line) const padding = Math.max(0, totalWidth - lineWidth) const leftPad = Math.floor(padding / 2) const rightPad = padding - leftPad return ' '.repeat(leftPad) + line + ' '.repeat(rightPad) }).join('\n') }), width: totalWidth, height: view.height }) /** * Apply ANSI styling to a view */ export const styled = (view: View, style: string): View => ({ render: () => Effect.gen(function* (_) { const content = yield* _(view.render()) return `${style}${content}\x1b[0m` }), width: view.width, height: view.height }) // Common styles export const bold = (view: View) => styled(view, '\x1b[1m') export const dim = (view: View) => styled(view, '\x1b[2m') export const italic = (view: View) => styled(view, '\x1b[3m') export const underline = (view: View) => styled(view, '\x1b[4m') // Colors export const red = (view: View) => styled(view, '\x1b[31m') export const green = (view: View) => styled(view, '\x1b[32m') export const yellow = (view: View) => styled(view, '\x1b[33m') export const blue = (view: View) => styled(view, '\x1b[34m') export const magenta = (view: View) => styled(view, '\x1b[35m') export const cyan = (view: View) => styled(view, '\x1b[36m') export const white = (view: View) => styled(view, '\x1b[37m') /** * Create a styled text view using the new styling system */ export const styledText = (content: string, style: Style): View => ({ render: () => Effect.succeed(renderStyledSync(content, style)), width: style.get("width") || content.split('\n').reduce((max, line) => Math.max(max, stringWidth(line)), 0), height: style.get("height") || content.split('\n').length })