From 685bae8afd57cedfd8097576f1d456c4ca595a9e Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 27 Jan 2026 20:52:54 -0500 Subject: [PATCH 1/2] feat: Add TableRenderable component with full styling and rendering support --- packages/core/src/renderables/Table.test.ts | 372 ++++++++ packages/core/src/renderables/Table.ts | 872 ++++++++++++++++++ .../src/renderables/composition/constructs.ts | 34 + packages/core/src/renderables/index.ts | 1 + packages/react/jsx-namespace.d.ts | 12 + packages/react/src/components/index.ts | 12 + packages/react/src/types/components.ts | 22 + packages/solid/jsx-runtime.d.ts | 12 + packages/solid/src/elements/index.ts | 12 + packages/solid/src/types/elements.ts | 22 + 10 files changed, 1371 insertions(+) create mode 100644 packages/core/src/renderables/Table.test.ts create mode 100644 packages/core/src/renderables/Table.ts diff --git a/packages/core/src/renderables/Table.test.ts b/packages/core/src/renderables/Table.test.ts new file mode 100644 index 000000000..f49c8d23b --- /dev/null +++ b/packages/core/src/renderables/Table.test.ts @@ -0,0 +1,372 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test" +import { + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, +} from "./Table" +import { createTestRenderer, type TestRenderer } from "../testing/test-renderer" + +let testRenderer: TestRenderer +let renderOnce: () => Promise +let captureCharFrame: () => string + +beforeEach(async () => { + ;({ + renderer: testRenderer, + renderOnce, + captureCharFrame, + } = await createTestRenderer({ + width: 40, + height: 20, + })) +}) + +afterEach(() => { + testRenderer.destroy() +}) + +describe("TableRenderable", () => { + test("creates a basic table with default options", async () => { + const table = new TableRenderable(testRenderer, { + id: "test-table", + }) + + testRenderer.root.add(table) + await renderOnce() + + expect(table.borderStyle).toBe("single") + expect(table.cellPadding).toBe(1) + expect(table.showRowSeparators).toBe(false) + expect(table.showHeaderSeparator).toBe(false) + expect(table.isDestroyed).toBe(false) + }) + + test("renders table with header and body", async () => { + const table = new TableRenderable(testRenderer, { + id: "test-table", + borderStyle: "single", + }) + + const thead = new TableHeadRenderable(testRenderer, { id: "thead" }) + const headerRow = new TableRowRenderable(testRenderer, { id: "header-row" }) + const th1 = new TableHeaderCellRenderable(testRenderer, { id: "th1", content: "Name" }) + const th2 = new TableHeaderCellRenderable(testRenderer, { id: "th2", content: "Age" }) + + headerRow.add(th1) + headerRow.add(th2) + thead.add(headerRow) + table.add(thead) + + const tbody = new TableBodyRenderable(testRenderer, { id: "tbody" }) + const dataRow = new TableRowRenderable(testRenderer, { id: "data-row" }) + const td1 = new TableDataCellRenderable(testRenderer, { id: "td1", content: "John" }) + const td2 = new TableDataCellRenderable(testRenderer, { id: "td2", content: "30" }) + + dataRow.add(td1) + dataRow.add(td2) + tbody.add(dataRow) + table.add(tbody) + + testRenderer.root.add(table) + await renderOnce() + + const frame = captureCharFrame() + + // Verify table structure is rendered + expect(frame).toContain("Name") + expect(frame).toContain("Age") + expect(frame).toContain("John") + expect(frame).toContain("30") + }) + + test("calculates column widths based on content", async () => { + const table = new TableRenderable(testRenderer, { + id: "test-table", + cellPadding: 1, + }) + + const tbody = new TableBodyRenderable(testRenderer, { id: "tbody" }) + const row1 = new TableRowRenderable(testRenderer, { id: "row1" }) + const td1 = new TableDataCellRenderable(testRenderer, { id: "td1", content: "Short" }) + const td2 = new TableDataCellRenderable(testRenderer, { id: "td2", content: "LongerContent" }) + + row1.add(td1) + row1.add(td2) + tbody.add(row1) + table.add(tbody) + + testRenderer.root.add(table) + await renderOnce() + + // Column widths should accommodate content + padding + const columnWidths = table.columnWidths + expect(columnWidths.length).toBe(2) + expect(columnWidths[0]).toBeGreaterThanOrEqual("Short".length + 2) // content + padding*2 + expect(columnWidths[1]).toBeGreaterThanOrEqual("LongerContent".length + 2) + }) + + test("supports different border styles", async () => { + const table = new TableRenderable(testRenderer, { + id: "test-table", + borderStyle: "double", + }) + + expect(table.borderStyle).toBe("double") + expect(table.borderChars.horizontal).toBe("═") + expect(table.borderChars.vertical).toBe("║") + + table.borderStyle = "rounded" + expect(table.borderStyle).toBe("rounded") + expect(table.borderChars.topLeft).toBe("╭") + }) + + test("can disable row separators", async () => { + const table = new TableRenderable(testRenderer, { + id: "test-table", + showRowSeparators: false, + }) + + expect(table.showRowSeparators).toBe(false) + + table.showRowSeparators = true + expect(table.showRowSeparators).toBe(true) + }) + + test("can disable header separator", async () => { + const table = new TableRenderable(testRenderer, { + id: "test-table", + showHeaderSeparator: false, + }) + + expect(table.showHeaderSeparator).toBe(false) + + table.showHeaderSeparator = true + expect(table.showHeaderSeparator).toBe(true) + }) +}) + +describe("TableCellRenderable", () => { + test("TableHeaderCellRenderable defaults to center alignment and bold", async () => { + const th = new TableHeaderCellRenderable(testRenderer, { + id: "th", + content: "Header", + }) + + expect(th.textAlign).toBe("center") + }) + + test("TableDataCellRenderable defaults to left alignment", async () => { + const td = new TableDataCellRenderable(testRenderer, { + id: "td", + content: "Data", + }) + + expect(td.textAlign).toBe("left") + }) + + test("cells support custom text alignment", async () => { + const td = new TableDataCellRenderable(testRenderer, { + id: "td", + content: "Data", + textAlign: "right", + }) + + expect(td.textAlign).toBe("right") + + td.textAlign = "center" + expect(td.textAlign).toBe("center") + }) + + test("cells support custom padding", async () => { + const td = new TableDataCellRenderable(testRenderer, { + id: "td", + content: "Data", + padding: 2, + }) + + expect(td.padding).toBe(2) + + td.padding = 0 + expect(td.padding).toBe(0) + }) + + test("cells support explicit width", async () => { + const table = new TableRenderable(testRenderer, { id: "table" }) + const tbody = new TableBodyRenderable(testRenderer, { id: "tbody" }) + const row = new TableRowRenderable(testRenderer, { id: "row" }) + + const td1 = new TableDataCellRenderable(testRenderer, { + id: "td1", + content: "A", + width: 10, + }) + const td2 = new TableDataCellRenderable(testRenderer, { + id: "td2", + content: "B", + }) + + row.add(td1) + row.add(td2) + tbody.add(row) + table.add(tbody) + testRenderer.root.add(table) + await renderOnce() + + // First column should use explicit width + expect(table.columnWidths[0]).toBe(10) + }) + + test("cells can update content dynamically", async () => { + const td = new TableDataCellRenderable(testRenderer, { + id: "td", + content: "Initial", + }) + + expect(td.content).toBe("Initial") + + td.content = "Updated" + expect(td.content).toBe("Updated") + }) + + test("getContentWidth returns correct width", async () => { + const td = new TableDataCellRenderable(testRenderer, { + id: "td", + content: "Hello", + }) + + expect(td.getContentWidth()).toBe(5) + }) +}) + +describe("TableRowRenderable", () => { + test("getCells returns all cell children", async () => { + const row = new TableRowRenderable(testRenderer, { id: "row" }) + const td1 = new TableDataCellRenderable(testRenderer, { id: "td1", content: "A" }) + const td2 = new TableDataCellRenderable(testRenderer, { id: "td2", content: "B" }) + const th = new TableHeaderCellRenderable(testRenderer, { id: "th", content: "C" }) + + row.add(td1) + row.add(td2) + row.add(th) + + const cells = row.getCells() + expect(cells.length).toBe(3) + expect(cells[0]).toBe(td1) + expect(cells[1]).toBe(td2) + expect(cells[2]).toBe(th) + }) +}) + +describe("TableSectionRenderable", () => { + test("TableHeadRenderable accepts backgroundColor", async () => { + const thead = new TableHeadRenderable(testRenderer, { + id: "thead", + backgroundColor: "#FF0000", + }) + + expect(thead.backgroundColor.r).toBeCloseTo(1) + expect(thead.backgroundColor.g).toBeCloseTo(0) + expect(thead.backgroundColor.b).toBeCloseTo(0) + }) + + test("TableBodyRenderable accepts backgroundColor", async () => { + const tbody = new TableBodyRenderable(testRenderer, { + id: "tbody", + backgroundColor: "#00FF00", + }) + + expect(tbody.backgroundColor.r).toBeCloseTo(0) + expect(tbody.backgroundColor.g).toBeCloseTo(1) + expect(tbody.backgroundColor.b).toBeCloseTo(0) + }) + + test("getSectionChildren returns children", async () => { + const thead = new TableHeadRenderable(testRenderer, { id: "thead" }) + const row1 = new TableRowRenderable(testRenderer, { id: "row1" }) + const row2 = new TableRowRenderable(testRenderer, { id: "row2" }) + + thead.add(row1) + thead.add(row2) + + const children = thead.getSectionChildren() + expect(children.length).toBe(2) + expect(children[0]).toBe(row1) + expect(children[1]).toBe(row2) + }) +}) + +describe("Table rendering output", () => { + test("renders complete table with borders", async () => { + const table = new TableRenderable(testRenderer, { + id: "table", + borderStyle: "single", + border: true, + showHeaderSeparator: true, + }) + + const thead = new TableHeadRenderable(testRenderer, { id: "thead" }) + const headerRow = new TableRowRenderable(testRenderer, { id: "header-row" }) + headerRow.add(new TableHeaderCellRenderable(testRenderer, { id: "th1", content: "Col1" })) + headerRow.add(new TableHeaderCellRenderable(testRenderer, { id: "th2", content: "Col2" })) + thead.add(headerRow) + table.add(thead) + + const tbody = new TableBodyRenderable(testRenderer, { id: "tbody" }) + const dataRow = new TableRowRenderable(testRenderer, { id: "data-row" }) + dataRow.add(new TableDataCellRenderable(testRenderer, { id: "td1", content: "A" })) + dataRow.add(new TableDataCellRenderable(testRenderer, { id: "td2", content: "B" })) + tbody.add(dataRow) + table.add(tbody) + + testRenderer.root.add(table) + await renderOnce() + + const frame = captureCharFrame() + + // Check for border characters + expect(frame).toContain("┌") + expect(frame).toContain("┐") + expect(frame).toContain("└") + expect(frame).toContain("┘") + expect(frame).toContain("│") + expect(frame).toContain("─") + expect(frame).toContain("┬") + expect(frame).toContain("┴") + expect(frame).toContain("├") + expect(frame).toContain("┤") + expect(frame).toContain("┼") + }) + + test("renders table with multiple body rows and separators", async () => { + const table = new TableRenderable(testRenderer, { + id: "table", + showRowSeparators: true, + }) + + const tbody = new TableBodyRenderable(testRenderer, { id: "tbody" }) + + for (let i = 1; i <= 3; i++) { + const row = new TableRowRenderable(testRenderer, { id: `row${i}` }) + row.add(new TableDataCellRenderable(testRenderer, { id: `td${i}a`, content: `R${i}C1` })) + row.add(new TableDataCellRenderable(testRenderer, { id: `td${i}b`, content: `R${i}C2` })) + tbody.add(row) + } + + table.add(tbody) + testRenderer.root.add(table) + await renderOnce() + + const frame = captureCharFrame() + + // Check all rows are rendered + expect(frame).toContain("R1C1") + expect(frame).toContain("R2C1") + expect(frame).toContain("R3C1") + expect(frame).toContain("R1C2") + expect(frame).toContain("R2C2") + expect(frame).toContain("R3C2") + }) +}) diff --git a/packages/core/src/renderables/Table.ts b/packages/core/src/renderables/Table.ts new file mode 100644 index 000000000..f97afec90 --- /dev/null +++ b/packages/core/src/renderables/Table.ts @@ -0,0 +1,872 @@ +import { Renderable, type RenderableOptions } from "../Renderable" +import type { OptimizedBuffer } from "../buffer" +import { type BorderStyle, type BorderCharacters, BorderChars, parseBorderStyle } from "../lib/border" +import { type ColorInput, RGBA, parseColor } from "../lib/RGBA" +import { isStyledText, type StyledText } from "../lib/styled-text" +import type { RenderContext } from "../types" +import { TextRenderable } from "./Text" + +export type TextAlign = "left" | "center" | "right" +export type VerticalAlign = "top" | "middle" | "bottom" + +export interface TableOptions extends RenderableOptions { + border?: boolean + borderStyle?: BorderStyle + borderColor?: ColorInput + backgroundColor?: ColorInput + cellPadding?: number + showRowSeparators?: boolean + showHeaderSeparator?: boolean +} + +export interface TableSectionOptions extends RenderableOptions { + backgroundColor?: ColorInput +} + +export interface TableRowOptions extends RenderableOptions { + backgroundColor?: ColorInput +} + +export interface TableCellOptions extends RenderableOptions { + textAlign?: TextAlign + verticalAlign?: VerticalAlign + padding?: number + color?: ColorInput + backgroundColor?: ColorInput + width?: number | "auto" + content?: string +} + +interface ColumnInfo { + width: number + explicitWidth: boolean +} + +function getTable(renderable: Renderable): TableRenderable | null { + let current: Renderable | null = renderable + while (current) { + if (current instanceof TableRenderable) { + return current + } + current = current.parent + } + return null +} + +interface ExtractedTextInfo { + text: string + fg?: RGBA + attributes?: number +} + +function extractTextFromStyledText(styledText: StyledText): string { + if (!styledText || !styledText.chunks) return "" + return styledText.chunks.map((chunk) => chunk.text || "").join("") +} + +function extractTextFromRenderable(renderable: Renderable): string { + const info = extractStyledTextFromRenderable(renderable) + return info.text +} + +function extractStyledTextFromRenderable(renderable: Renderable): ExtractedTextInfo { + if (renderable instanceof TextRenderable) { + // Get styling from the TextRenderable + const fg = (renderable as any)._defaultFg as RGBA | undefined + const attributes = (renderable as any)._defaultAttributes as number | undefined + + // Try to get text from the TextBuffer which has the actual rendered text + const textBuffer = (renderable as any).textBuffer + if (textBuffer && typeof textBuffer.getPlainText === "function") { + const text = textBuffer.getPlainText() + if (text) { + return { text, fg, attributes } + } + } + + // Fallback: try to gather from text nodes + const chunks = renderable.textNode.gatherWithInheritedStyle({ + fg: undefined, + bg: undefined, + attributes: 0, + link: undefined, + }) + if (chunks && chunks.length > 0) { + return { text: chunks.map((chunk) => chunk.text || "").join(""), fg, attributes } + } + + const content = renderable.content + if (typeof content === "string") { + return { text: content, fg, attributes } + } + if (isStyledText(content)) { + return { text: extractTextFromStyledText(content), fg, attributes } + } + } + + if ("content" in renderable) { + const content = (renderable as any).content + if (typeof content === "string") { + return { text: content } + } + if (isStyledText(content)) { + return { text: extractTextFromStyledText(content) } + } + } + + return { text: "" } +} + +export class TableRenderable extends Renderable { + protected _border: boolean + protected _borderStyle: BorderStyle + protected _borderColor: RGBA + protected _backgroundColor: RGBA + protected _cellPadding: number + protected _showRowSeparators: boolean + protected _showHeaderSeparator: boolean + protected _borderChars: BorderCharacters + + private _columnWidths: number[] = [] + private _rowHeights: number[] = [] + + protected _defaultOptions = { + backgroundColor: "transparent", + borderStyle: "single" as BorderStyle, + border: false, + borderColor: "#FFFFFF", + cellPadding: 1, + showRowSeparators: false, + showHeaderSeparator: false, + } + + constructor(ctx: RenderContext, options: TableOptions) { + super(ctx, { ...options, flexDirection: "column" }) + + this._border = options.border ?? this._defaultOptions.border + this._borderStyle = parseBorderStyle(options.borderStyle, this._defaultOptions.borderStyle) + this._borderColor = parseColor(options.borderColor || this._defaultOptions.borderColor) + this._backgroundColor = parseColor(options.backgroundColor || this._defaultOptions.backgroundColor) + this._cellPadding = options.cellPadding ?? this._defaultOptions.cellPadding + this._showRowSeparators = options.showRowSeparators ?? this._defaultOptions.showRowSeparators + this._showHeaderSeparator = options.showHeaderSeparator ?? this._defaultOptions.showHeaderSeparator + this._borderChars = BorderChars[this._borderStyle] + } + + public get border(): boolean { + return this._border + } + + public set border(value: boolean) { + if (this._border !== value) { + this._border = value + this.requestRender() + } + } + + public get borderStyle(): BorderStyle { + return this._borderStyle + } + + public set borderStyle(value: BorderStyle) { + const parsed = parseBorderStyle(value, this._defaultOptions.borderStyle) + if (this._borderStyle !== parsed) { + this._borderStyle = parsed + this._borderChars = BorderChars[parsed] + this.requestRender() + } + } + + public get borderColor(): RGBA { + return this._borderColor + } + + public set borderColor(value: ColorInput) { + const newColor = parseColor(value || this._defaultOptions.borderColor) + if (this._borderColor !== newColor) { + this._borderColor = newColor + this.requestRender() + } + } + + public get backgroundColor(): RGBA { + return this._backgroundColor + } + + public set backgroundColor(value: ColorInput) { + const newColor = parseColor(value || this._defaultOptions.backgroundColor) + if (this._backgroundColor !== newColor) { + this._backgroundColor = newColor + this.requestRender() + } + } + + public get cellPadding(): number { + return this._cellPadding + } + + public set cellPadding(value: number) { + if (this._cellPadding !== value) { + this._cellPadding = value + this.requestRender() + } + } + + public get showRowSeparators(): boolean { + return this._showRowSeparators + } + + public set showRowSeparators(value: boolean) { + if (this._showRowSeparators !== value) { + this._showRowSeparators = value + this.requestRender() + } + } + + public get showHeaderSeparator(): boolean { + return this._showHeaderSeparator + } + + public set showHeaderSeparator(value: boolean) { + if (this._showHeaderSeparator !== value) { + this._showHeaderSeparator = value + this.requestRender() + } + } + + public get borderChars(): BorderCharacters { + return this._borderChars + } + + public get columnWidths(): number[] { + return this._columnWidths + } + + public get rowHeights(): number[] { + return this._rowHeights + } + + public markColumnsDirty(): void { + this.requestRender() + } + + protected _getVisibleChildren(): number[] { + return [] + } + + public add(obj: any, index?: number): number { + const result = super.add(obj, index) + this.markColumnsDirty() + return result + } + + public remove(id: string): void { + super.remove(id) + this.markColumnsDirty() + } + + private getAllRows(): TableRowRenderable[] { + const rows: TableRowRenderable[] = [] + for (const child of this._childrenInLayoutOrder) { + if (child instanceof TableSectionRenderable) { + for (const sectionChild of child.getSectionChildren()) { + if (sectionChild instanceof TableRowRenderable) { + rows.push(sectionChild) + } + } + } else if (child instanceof TableRowRenderable) { + rows.push(child) + } + } + return rows + } + + private calculateColumnWidths(): void { + const rows = this.getAllRows() + if (rows.length === 0) { + this._columnWidths = [] + this._rowHeights = [] + return + } + + const columnInfos: ColumnInfo[] = [] + this._rowHeights = [] + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const row = rows[rowIndex] + const cells = row.getCells() + let maxRowHeight = 1 + + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + const cellPadding = cell._padding ?? this._cellPadding + const contentText = cell.getTextContent() + const contentWidth = contentText.length + cellPadding * 2 + + if (!columnInfos[i]) { + columnInfos[i] = { width: 0, explicitWidth: false } + } + + if (cell._explicitWidth !== undefined) { + columnInfos[i].width = Math.max(columnInfos[i].width, cell._explicitWidth) + columnInfos[i].explicitWidth = true + } else if (!columnInfos[i].explicitWidth) { + columnInfos[i].width = Math.max(columnInfos[i].width, contentWidth) + } + } + + this._rowHeights.push(maxRowHeight) + } + + this._columnWidths = columnInfos.map((info) => Math.max(info.width, 3)) + } + + public getTotalWidth(): number { + this.calculateColumnWidths() + if (this._columnWidths.length === 0) return this._border ? 2 : 0 + const contentWidth = this._columnWidths.reduce((sum, w) => sum + w, 0) + if (this._border) { + // Add border chars: left border (1) + separators between columns (columnCount - 1) + right border (1) + return contentWidth + this._columnWidths.length + 1 + } + return contentWidth + } + + public getTotalHeight(): number { + this.calculateColumnWidths() + // Start with border height (2 for top+bottom) if border is enabled, otherwise 0 + let height = this._border ? 2 : 0 + + let hasHead = false + let bodyRowCount = 0 + + for (const child of this._childrenInLayoutOrder) { + if (child instanceof TableHeadRenderable) { + hasHead = true + for (const sectionChild of child.getSectionChildren()) { + if (sectionChild instanceof TableRowRenderable) { + height += 1 + } + } + } else if (child instanceof TableBodyRenderable) { + for (const sectionChild of child.getSectionChildren()) { + if (sectionChild instanceof TableRowRenderable) { + height += 1 + bodyRowCount++ + } + } + } else if (child instanceof TableRowRenderable) { + height += 1 + bodyRowCount++ + } + } + + // Only add separator heights if borders are enabled + if (this._border) { + if (hasHead && this._showHeaderSeparator) { + height += 1 + } + + if (this._showRowSeparators && bodyRowCount > 1) { + height += bodyRowCount - 1 + } + } + + return height + } + + protected onUpdate(): void { + this.calculateColumnWidths() + + // Set explicit dimensions based on content + const totalWidth = this.getTotalWidth() + const totalHeight = this.getTotalHeight() + + this.yogaNode.setWidth(totalWidth) + this.yogaNode.setHeight(totalHeight) + } + + protected renderSelf(buffer: OptimizedBuffer): void { + this.calculateColumnWidths() + + const x = this.x + const y = this.y + const chars = this._borderChars + const borderColor = this._borderColor + const bgColor = this._backgroundColor + const hasBorder = this._border + + if (bgColor.a > 0) { + buffer.fillRect(x, y, this.width, this.height, bgColor) + } + + const rows = this.getAllRows() + + if (rows.length === 0 || this._columnWidths.length === 0) { + return + } + + // Draw top border if enabled + if (hasBorder) { + buffer.drawText(chars.topLeft, x, y, borderColor) + let colX = x + 1 + for (let i = 0; i < this._columnWidths.length; i++) { + const colWidth = this._columnWidths[i] + for (let j = 0; j < colWidth; j++) { + buffer.drawText(chars.horizontal, colX + j, y, borderColor) + } + colX += colWidth + if (i < this._columnWidths.length - 1) { + buffer.drawText(chars.topT, colX, y, borderColor) + colX += 1 + } + } + buffer.drawText(chars.topRight, colX, y, borderColor) + } + + let rowY = hasBorder ? y + 1 : y + let headRowCount = 0 + + for (const child of this._childrenInLayoutOrder) { + if (child instanceof TableHeadRenderable) { + for (const sectionChild of child.getSectionChildren()) { + if (sectionChild instanceof TableRowRenderable) { + this.renderRow(buffer, sectionChild, x, rowY) + rowY++ + headRowCount++ + } + } + + if (this._showHeaderSeparator && headRowCount > 0 && hasBorder) { + this.renderHorizontalSeparator(buffer, x, rowY) + rowY++ + } + } else if (child instanceof TableBodyRenderable) { + const bodyRows = child + .getSectionChildren() + .filter((c) => c instanceof TableRowRenderable) as TableRowRenderable[] + + for (let i = 0; i < bodyRows.length; i++) { + const row = bodyRows[i] + this.renderRow(buffer, row, x, rowY) + rowY++ + + if (this._showRowSeparators && i < bodyRows.length - 1 && hasBorder) { + this.renderHorizontalSeparator(buffer, x, rowY) + rowY++ + } + } + } else if (child instanceof TableRowRenderable) { + this.renderRow(buffer, child, x, rowY) + rowY++ + } + } + + // Draw bottom border if enabled + if (hasBorder) { + buffer.drawText(chars.bottomLeft, x, rowY, borderColor) + let colX = x + 1 + for (let i = 0; i < this._columnWidths.length; i++) { + const colWidth = this._columnWidths[i] + for (let j = 0; j < colWidth; j++) { + buffer.drawText(chars.horizontal, colX + j, rowY, borderColor) + } + colX += colWidth + if (i < this._columnWidths.length - 1) { + buffer.drawText(chars.bottomT, colX, rowY, borderColor) + colX += 1 + } + } + buffer.drawText(chars.bottomRight, colX, rowY, borderColor) + } + } + + private renderRow(buffer: OptimizedBuffer, row: TableRowRenderable, x: number, y: number): void { + const chars = this._borderChars + const borderColor = this._borderColor + const hasBorder = this._border + + if (hasBorder) { + buffer.drawText(chars.vertical, x, y, borderColor) + } + + let colX = hasBorder ? x + 1 : x + const cells = row.getCells() + + for (let i = 0; i < this._columnWidths.length; i++) { + const colWidth = this._columnWidths[i] + const cell = cells[i] + + if (cell) { + cell.renderInColumn(buffer, colX, y, colWidth, this._cellPadding) + } + + colX += colWidth + if (hasBorder) { + buffer.drawText(chars.vertical, colX, y, borderColor) + colX += 1 + } + } + } + + private renderHorizontalSeparator(buffer: OptimizedBuffer, x: number, y: number): void { + const chars = this._borderChars + const borderColor = this._borderColor + + buffer.drawText(chars.leftT, x, y, borderColor) + + let colX = x + 1 + for (let i = 0; i < this._columnWidths.length; i++) { + const colWidth = this._columnWidths[i] + for (let j = 0; j < colWidth; j++) { + buffer.drawText(chars.horizontal, colX + j, y, borderColor) + } + colX += colWidth + if (i < this._columnWidths.length - 1) { + buffer.drawText(chars.cross, colX, y, borderColor) + colX += 1 + } + } + buffer.drawText(chars.rightT, colX, y, borderColor) + } +} + +abstract class TableSectionRenderable extends Renderable { + protected _backgroundColor: RGBA + + constructor(ctx: RenderContext, options: TableSectionOptions) { + super(ctx, { ...options, flexDirection: "column" }) + this._backgroundColor = parseColor(options.backgroundColor || "transparent") + } + + public get backgroundColor(): RGBA { + return this._backgroundColor + } + + public set backgroundColor(value: ColorInput) { + const newColor = parseColor(value || "transparent") + if (this._backgroundColor !== newColor) { + this._backgroundColor = newColor + this.requestRender() + } + } + + public getSectionChildren(): Renderable[] { + return [...this._childrenInLayoutOrder] + } + + protected _getVisibleChildren(): number[] { + return [] + } + + public add(obj: any, index?: number): number { + const result = super.add(obj, index) + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + return result + } + + public remove(id: string): void { + super.remove(id) + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + } +} + +export class TableHeadRenderable extends TableSectionRenderable { + constructor(ctx: RenderContext, options: TableSectionOptions) { + super(ctx, options) + } +} + +export class TableBodyRenderable extends TableSectionRenderable { + constructor(ctx: RenderContext, options: TableSectionOptions) { + super(ctx, options) + } +} + +export class TableRowRenderable extends Renderable { + protected _backgroundColor: RGBA + + constructor(ctx: RenderContext, options: TableRowOptions) { + super(ctx, { ...options, flexDirection: "row" }) + this._backgroundColor = parseColor(options.backgroundColor || "transparent") + } + + public get backgroundColor(): RGBA { + return this._backgroundColor + } + + public set backgroundColor(value: ColorInput) { + const newColor = parseColor(value || "transparent") + if (this._backgroundColor !== newColor) { + this._backgroundColor = newColor + this.requestRender() + } + } + + public getCells(): (TableHeaderCellRenderable | TableDataCellRenderable)[] { + return this._childrenInLayoutOrder.filter( + (child) => child instanceof TableHeaderCellRenderable || child instanceof TableDataCellRenderable, + ) as (TableHeaderCellRenderable | TableDataCellRenderable)[] + } + + protected _getVisibleChildren(): number[] { + return [] + } + + public add(obj: any, index?: number): number { + const result = super.add(obj, index) + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + return result + } + + public remove(id: string): void { + super.remove(id) + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + } +} + +abstract class TableCellRenderable extends Renderable { + protected _textAlign: TextAlign + protected _verticalAlign: VerticalAlign + public _padding: number | undefined + protected _color: RGBA + protected _backgroundColor: RGBA + public _explicitWidth: number | undefined + protected _content: string + protected _isBold: boolean + + protected abstract getDefaultTextAlign(): TextAlign + protected abstract getDefaultBold(): boolean + + constructor(ctx: RenderContext, options: TableCellOptions, defaultTextAlign: TextAlign, defaultBold: boolean) { + super(ctx, options) + + this._textAlign = options.textAlign || defaultTextAlign + this._verticalAlign = options.verticalAlign || "middle" + this._padding = options.padding + this._color = parseColor(options.color || "#FFFFFF") + this._backgroundColor = parseColor(options.backgroundColor || "transparent") + this._explicitWidth = typeof options.width === "number" ? options.width : undefined + this._content = options.content || "" + this._isBold = defaultBold + } + + protected _getVisibleChildren(): number[] { + return [] + } + + public get textAlign(): TextAlign { + return this._textAlign + } + + public set textAlign(value: TextAlign) { + if (this._textAlign !== value) { + this._textAlign = value + this.requestRender() + } + } + + public get verticalAlign(): VerticalAlign { + return this._verticalAlign + } + + public set verticalAlign(value: VerticalAlign) { + if (this._verticalAlign !== value) { + this._verticalAlign = value + this.requestRender() + } + } + + public get padding(): number | undefined { + return this._padding + } + + public set padding(value: number | undefined) { + if (this._padding !== value) { + this._padding = value + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + this.requestRender() + } + } + + public get color(): RGBA { + return this._color + } + + public set color(value: ColorInput) { + const newColor = parseColor(value || "#FFFFFF") + if (this._color !== newColor) { + this._color = newColor + this.requestRender() + } + } + + public get backgroundColor(): RGBA { + return this._backgroundColor + } + + public set backgroundColor(value: ColorInput) { + const newColor = parseColor(value || "transparent") + if (this._backgroundColor !== newColor) { + this._backgroundColor = newColor + this.requestRender() + } + } + + public get content(): string { + return this._content + } + + public set content(value: string) { + if (this._content !== value) { + this._content = value + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + this.requestRender() + } + } + + public getTextContent(): string { + if (this._content) { + return this._content + } + + const textParts: string[] = [] + for (const child of this._childrenInLayoutOrder) { + const text = extractTextFromRenderable(child) + if (text) { + textParts.push(text) + } + } + return textParts.join("") + } + + public getStyledTextContent(): ExtractedTextInfo { + if (this._content) { + return { text: this._content } + } + + // If there's a single child, extract its styling + if (this._childrenInLayoutOrder.length === 1) { + return extractStyledTextFromRenderable(this._childrenInLayoutOrder[0]) + } + + // For multiple children, just extract text (mixed styles not supported) + const textParts: string[] = [] + for (const child of this._childrenInLayoutOrder) { + const text = extractTextFromRenderable(child) + if (text) { + textParts.push(text) + } + } + return { text: textParts.join("") } + } + + public getContentWidth(): number { + return this.getTextContent().length + } + + public renderInColumn(buffer: OptimizedBuffer, x: number, y: number, colWidth: number, defaultPadding: number): void { + const padding = this._padding ?? defaultPadding + const availableWidth = colWidth - padding * 2 + const bgColor = this._backgroundColor + + if (bgColor.a > 0) { + buffer.fillRect(x, y, colWidth, 1, bgColor) + } + + const styledContent = this.getStyledTextContent() + let text = styledContent.text + + if (!text) return + + if (text.length > availableWidth) { + text = text.slice(0, Math.max(0, availableWidth)) + } + + if (availableWidth <= 0) return + + let textX = x + padding + switch (this._textAlign) { + case "center": + textX = x + padding + Math.floor((availableWidth - text.length) / 2) + break + case "right": + textX = x + padding + availableWidth - text.length + break + case "left": + default: + textX = x + padding + break + } + + // Use child's styling if available, otherwise fall back to cell defaults + const fgColor = styledContent.fg ?? this._color + const defaultAttributes = this._isBold ? 1 : 0 + const attributes = styledContent.attributes ?? defaultAttributes + + buffer.drawText(text, textX, y, fgColor, undefined, attributes) + } + + public add(obj: any, index?: number): number { + const result = super.add(obj, index) + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + return result + } + + public remove(id: string): void { + super.remove(id) + const table = getTable(this) + if (table) { + table.markColumnsDirty() + } + } +} + +export class TableHeaderCellRenderable extends TableCellRenderable { + protected getDefaultTextAlign(): TextAlign { + return "center" + } + + protected getDefaultBold(): boolean { + return true + } + + constructor(ctx: RenderContext, options: TableCellOptions) { + super(ctx, options, "center", true) + } +} + +export class TableDataCellRenderable extends TableCellRenderable { + protected getDefaultTextAlign(): TextAlign { + return "left" + } + + protected getDefaultBold(): boolean { + return false + } + + constructor(ctx: RenderContext, options: TableCellOptions) { + super(ctx, options, "left", false) + } +} diff --git a/packages/core/src/renderables/composition/constructs.ts b/packages/core/src/renderables/composition/constructs.ts index 1f55dd3a8..507190ce8 100644 --- a/packages/core/src/renderables/composition/constructs.ts +++ b/packages/core/src/renderables/composition/constructs.ts @@ -19,6 +19,16 @@ import { type SelectRenderableOptions, type TabSelectRenderableOptions, type FrameBufferOptions, + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, + type TableOptions, + type TableSectionOptions, + type TableRowOptions, + type TableCellOptions, } from "../" import { TextNodeRenderable, type TextNodeOptions } from "../TextNode" import { h, type VChild } from "./vnode" @@ -57,6 +67,30 @@ export function FrameBuffer(props: FrameBufferOptions, ...children: VChild[]) { return h(FrameBufferRenderable, props, ...children) } +export function Table(props?: TableOptions, ...children: VChild[]) { + return h(TableRenderable, props || {}, ...children) +} + +export function THead(props?: TableSectionOptions, ...children: VChild[]) { + return h(TableHeadRenderable, props || {}, ...children) +} + +export function TBody(props?: TableSectionOptions, ...children: VChild[]) { + return h(TableBodyRenderable, props || {}, ...children) +} + +export function TR(props?: TableRowOptions, ...children: VChild[]) { + return h(TableRowRenderable, props || {}, ...children) +} + +export function TH(props?: TableCellOptions, ...children: VChild[]) { + return h(TableHeaderCellRenderable, props || {}, ...children) +} + +export function TD(props?: TableCellOptions, ...children: VChild[]) { + return h(TableDataCellRenderable, props || {}, ...children) +} + export function Code(props: CodeOptions, ...children: VChild[]) { return h(CodeRenderable, props, ...children) } diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 68b6a11f5..3ac02a130 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -14,6 +14,7 @@ export * from "./ScrollBox" export * from "./Select" export * from "./Slider" export * from "./TabSelect" +export * from "./Table" export * from "./Text" export * from "./TextBufferRenderable" export * from "./TextNode" diff --git a/packages/react/jsx-namespace.d.ts b/packages/react/jsx-namespace.d.ts index d5658e9a3..a22171994 100644 --- a/packages/react/jsx-namespace.d.ts +++ b/packages/react/jsx-namespace.d.ts @@ -15,6 +15,12 @@ import type { SelectProps, SpanProps, TabSelectProps, + TableProps, + TableHeadProps, + TableBodyProps, + TableRowProps, + TableHeaderCellProps, + TableDataCellProps, TextareaProps, TextProps, } from "./src/types/components" @@ -47,6 +53,12 @@ export namespace JSX { textarea: TextareaProps select: SelectProps scrollbox: ScrollBoxProps + table: TableProps + thead: TableHeadProps + tbody: TableBodyProps + tr: TableRowProps + th: TableHeaderCellProps + td: TableDataCellProps "ascii-font": AsciiFontProps "tab-select": TabSelectProps "line-number": LineNumberProps diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index d115cf73e..39e629882 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -9,6 +9,12 @@ import { ScrollBoxRenderable, SelectRenderable, TabSelectRenderable, + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, TextareaRenderable, TextRenderable, } from "@opentui/core" @@ -32,6 +38,12 @@ export const baseComponents = { select: SelectRenderable, textarea: TextareaRenderable, scrollbox: ScrollBoxRenderable, + table: TableRenderable, + thead: TableHeadRenderable, + tbody: TableBodyRenderable, + tr: TableRowRenderable, + th: TableHeaderCellRenderable, + td: TableDataCellRenderable, "ascii-font": ASCIIFontRenderable, "tab-select": TabSelectRenderable, "line-number": LineNumberRenderable, diff --git a/packages/react/src/types/components.ts b/packages/react/src/types/components.ts index 48162ce7b..315438efd 100644 --- a/packages/react/src/types/components.ts +++ b/packages/react/src/types/components.ts @@ -24,6 +24,16 @@ import type { TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, + TableOptions, + TableSectionOptions, + TableRowOptions, + TableCellOptions, TextareaOptions, TextareaRenderable, TextNodeOptions, @@ -174,6 +184,18 @@ export type LineNumberProps = ComponentProps, focused?: boolean } +export type TableProps = ComponentProps, TableRenderable> + +export type TableHeadProps = ComponentProps, TableHeadRenderable> + +export type TableBodyProps = ComponentProps, TableBodyRenderable> + +export type TableRowProps = ComponentProps, TableRowRenderable> + +export type TableHeaderCellProps = ComponentProps, TableHeaderCellRenderable> + +export type TableDataCellProps = ComponentProps, TableDataCellRenderable> + // ============================================================================ // Extended/Dynamic Component System // ============================================================================ diff --git a/packages/solid/jsx-runtime.d.ts b/packages/solid/jsx-runtime.d.ts index 600ad800f..e0b4dc769 100644 --- a/packages/solid/jsx-runtime.d.ts +++ b/packages/solid/jsx-runtime.d.ts @@ -12,6 +12,12 @@ import type { SelectProps, SpanProps, TabSelectProps, + TableProps, + TableHeadProps, + TableBodyProps, + TableRowProps, + TableHeaderCellProps, + TableDataCellProps, TextareaProps, TextProps, } from "./src/types/elements" @@ -35,6 +41,12 @@ declare namespace JSX { code: CodeProps textarea: TextareaProps markdown: MarkdownProps + table: TableProps + thead: TableHeadProps + tbody: TableBodyProps + tr: TableRowProps + th: TableHeaderCellProps + td: TableDataCellProps b: SpanProps strong: SpanProps diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index c1f258db3..b79086dc8 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -9,6 +9,12 @@ import { ScrollBoxRenderable, SelectRenderable, TabSelectRenderable, + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, TextareaRenderable, TextAttributes, TextNodeRenderable, @@ -104,6 +110,12 @@ export const baseComponents = { diff: DiffRenderable, line_number: LineNumberRenderable, markdown: MarkdownRenderable, + table: TableRenderable, + thead: TableHeadRenderable, + tbody: TableBodyRenderable, + tr: TableRowRenderable, + th: TableHeaderCellRenderable, + td: TableDataCellRenderable, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/solid/src/types/elements.ts b/packages/solid/src/types/elements.ts index 29509617c..4ed865e7f 100644 --- a/packages/solid/src/types/elements.ts +++ b/packages/solid/src/types/elements.ts @@ -21,6 +21,16 @@ import type { TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, + TableOptions, + TableSectionOptions, + TableRowOptions, + TableCellOptions, TextareaOptions, TextareaRenderable, TextNodeRenderable, @@ -160,6 +170,18 @@ export type CodeProps = ComponentProps export type MarkdownProps = ComponentProps +export type TableProps = ComponentProps, TableRenderable> + +export type TableHeadProps = ComponentProps, TableHeadRenderable> + +export type TableBodyProps = ComponentProps, TableBodyRenderable> + +export type TableRowProps = ComponentProps, TableRowRenderable> + +export type TableHeaderCellProps = ComponentProps, TableHeaderCellRenderable> + +export type TableDataCellProps = ComponentProps, TableDataCellRenderable> + // ============================================================================ // Extended/Dynamic Component System // ============================================================================ From f87fc37ee36fb11f9abb977fdf6467ac18573254 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 27 Jan 2026 21:06:56 -0500 Subject: [PATCH 2/2] feat: Add Table Demo to ExampleSelector with description and scene integration --- packages/core/src/examples/table-demo.ts | 272 ++++++++++++++++++ packages/react/examples/table-demo.tsx | 151 ++++++++++ .../examples/components/ExampleSelector.tsx | 9 + .../solid/examples/components/table-demo.tsx | 148 ++++++++++ 4 files changed, 580 insertions(+) create mode 100644 packages/core/src/examples/table-demo.ts create mode 100644 packages/react/examples/table-demo.tsx create mode 100644 packages/solid/examples/components/table-demo.tsx diff --git a/packages/core/src/examples/table-demo.ts b/packages/core/src/examples/table-demo.ts new file mode 100644 index 000000000..8853f839c --- /dev/null +++ b/packages/core/src/examples/table-demo.ts @@ -0,0 +1,272 @@ +import { + type CliRenderer, + createCliRenderer, + BoxRenderable, + ScrollBoxRenderable, + TableRenderable, + TableHeadRenderable, + TableBodyRenderable, + TableRowRenderable, + TableHeaderCellRenderable, + TableDataCellRenderable, +} from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let renderer: CliRenderer | null = null +let mainContainer: ScrollBoxRenderable | null = null + +const languages = [ + { name: "JavaScript", year: 1995, creator: "Brendan Eich" }, + { name: "Python", year: 1991, creator: "Guido van Rossum" }, + { name: "Rust", year: 2010, creator: "Graydon Hoare" }, + { name: "Go", year: 2009, creator: "Rob Pike" }, + { name: "TypeScript", year: 2012, creator: "Anders Hejlsberg" }, +] + +function createBasicTable(ctx: CliRenderer): BoxRenderable { + const container = new BoxRenderable(ctx, { + id: "basic-table-container", + border: true, + title: "Basic Table (no borders)", + marginBottom: 1, + flexDirection: "column", + }) + + const table = new TableRenderable(ctx, { + id: "basic-table", + }) + + const thead = new TableHeadRenderable(ctx, { id: "basic-thead" }) + const headerRow = new TableRowRenderable(ctx, { id: "basic-header-row" }) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: "basic-th1", content: "Language" })) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: "basic-th2", content: "Year" })) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: "basic-th3", content: "Creator" })) + thead.add(headerRow) + + const tbody = new TableBodyRenderable(ctx, { id: "basic-tbody" }) + languages.slice(0, 3).forEach((lang, index) => { + const row = new TableRowRenderable(ctx, { id: `basic-row-${index}` }) + row.add(new TableDataCellRenderable(ctx, { id: `basic-td1-${index}`, content: lang.name })) + row.add(new TableDataCellRenderable(ctx, { id: `basic-td2-${index}`, content: String(lang.year) })) + row.add(new TableDataCellRenderable(ctx, { id: `basic-td3-${index}`, content: lang.creator })) + tbody.add(row) + }) + + table.add(thead) + table.add(tbody) + container.add(table) + + return container +} + +function createBorderedTable(ctx: CliRenderer): BoxRenderable { + const container = new BoxRenderable(ctx, { + id: "bordered-table-container", + border: true, + title: "Bordered Table (single)", + marginBottom: 1, + flexDirection: "column", + }) + + const table = new TableRenderable(ctx, { + id: "bordered-table", + border: true, + borderStyle: "single", + }) + + const thead = new TableHeadRenderable(ctx, { id: "bordered-thead" }) + const headerRow = new TableRowRenderable(ctx, { id: "bordered-header-row" }) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: "bordered-th1", content: "Language" })) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: "bordered-th2", content: "Year" })) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: "bordered-th3", content: "Creator" })) + thead.add(headerRow) + + const tbody = new TableBodyRenderable(ctx, { id: "bordered-tbody" }) + languages.slice(0, 3).forEach((lang, index) => { + const row = new TableRowRenderable(ctx, { id: `bordered-row-${index}` }) + row.add(new TableDataCellRenderable(ctx, { id: `bordered-td1-${index}`, content: lang.name })) + row.add(new TableDataCellRenderable(ctx, { id: `bordered-td2-${index}`, content: String(lang.year) })) + row.add(new TableDataCellRenderable(ctx, { id: `bordered-td3-${index}`, content: lang.creator })) + tbody.add(row) + }) + + table.add(thead) + table.add(tbody) + container.add(table) + + return container +} + +function createStyledTable(ctx: CliRenderer): BoxRenderable { + const container = new BoxRenderable(ctx, { + id: "styled-table-container", + border: true, + title: "Styled Table (separators, colors, alignment)", + marginBottom: 1, + flexDirection: "column", + }) + + const table = new TableRenderable(ctx, { + id: "styled-table", + border: true, + borderStyle: "rounded", + borderColor: "#00AAFF", + showHeaderSeparator: true, + showRowSeparators: true, + cellPadding: 2, + }) + + const thead = new TableHeadRenderable(ctx, { id: "styled-thead", backgroundColor: "#003355" }) + const headerRow = new TableRowRenderable(ctx, { id: "styled-header-row" }) + headerRow.add( + new TableHeaderCellRenderable(ctx, { id: "styled-th1", content: "Language", textAlign: "left", color: "#FFFF00" }), + ) + headerRow.add( + new TableHeaderCellRenderable(ctx, { id: "styled-th2", content: "Year", textAlign: "center", color: "#FFFF00" }), + ) + headerRow.add( + new TableHeaderCellRenderable(ctx, { id: "styled-th3", content: "Creator", textAlign: "right", color: "#FFFF00" }), + ) + thead.add(headerRow) + + const tbody = new TableBodyRenderable(ctx, { id: "styled-tbody" }) + languages.forEach((lang, index) => { + const row = new TableRowRenderable(ctx, { + id: `styled-row-${index}`, + backgroundColor: index % 2 === 0 ? "#112233" : "#1a2a3a", + }) + row.add( + new TableDataCellRenderable(ctx, { + id: `styled-td1-${index}`, + content: lang.name, + textAlign: "left", + color: "#66CCFF", + }), + ) + row.add( + new TableDataCellRenderable(ctx, { + id: `styled-td2-${index}`, + content: String(lang.year), + textAlign: "center", + color: "#AAAAAA", + }), + ) + row.add( + new TableDataCellRenderable(ctx, { + id: `styled-td3-${index}`, + content: lang.creator, + textAlign: "right", + color: "#88FF88", + }), + ) + tbody.add(row) + }) + + table.add(thead) + table.add(tbody) + container.add(table) + + return container +} + +function createBorderStylesShowcase(ctx: CliRenderer): BoxRenderable { + const container = new BoxRenderable(ctx, { + id: "border-styles-container", + border: true, + title: "Border Styles", + marginBottom: 1, + flexDirection: "column", + }) + + const innerContainer = new BoxRenderable(ctx, { + id: "border-styles-inner", + flexDirection: "row", + flexWrap: "wrap", + gap: 2, + }) + + const borderStyles = ["single", "double", "rounded"] as const + const colors = ["#FFFFFF", "#FF6B6B", "#51CF66", "#FFD43B", "#748FFC"] + + borderStyles.forEach((style, i) => { + const table = new TableRenderable(ctx, { + id: `border-style-table-${style}`, + border: true, + borderStyle: style, + borderColor: colors[i], + cellPadding: 1, + }) + + const thead = new TableHeadRenderable(ctx, { id: `border-style-thead-${style}` }) + const headerRow = new TableRowRenderable(ctx, { id: `border-style-header-row-${style}` }) + headerRow.add(new TableHeaderCellRenderable(ctx, { id: `border-style-th-${style}`, content: style })) + thead.add(headerRow) + + const tbody = new TableBodyRenderable(ctx, { id: `border-style-tbody-${style}` }) + const row1 = new TableRowRenderable(ctx, { id: `border-style-row1-${style}` }) + row1.add(new TableDataCellRenderable(ctx, { id: `border-style-td1-${style}`, content: "Row 1" })) + tbody.add(row1) + + const row2 = new TableRowRenderable(ctx, { id: `border-style-row2-${style}` }) + row2.add(new TableDataCellRenderable(ctx, { id: `border-style-td2-${style}`, content: "Row 2" })) + tbody.add(row2) + + table.add(thead) + table.add(tbody) + innerContainer.add(table) + }) + + container.add(innerContainer) + + return container +} + +export function run(rendererInstance: CliRenderer): void { + renderer = rendererInstance + renderer.setBackgroundColor("#001122") + + mainContainer = new ScrollBoxRenderable(renderer, { + id: "table-demo-scrollbox", + flexGrow: 1, + padding: 1, + }) + mainContainer.focus() + renderer.root.add(mainContainer) + + const contentContainer = new BoxRenderable(renderer, { + id: "table-demo-content", + flexDirection: "column", + }) + + const instructionsText = new TextRenderable(renderer, { + id: "table-demo-instructions", + content: "Table Component Demo - Use arrow keys to scroll, Escape to return", + fg: "#AAAAAA", + marginBottom: 1, + }) + contentContainer.add(instructionsText) + + contentContainer.add(createBasicTable(renderer)) + contentContainer.add(createBorderedTable(renderer)) + contentContainer.add(createStyledTable(renderer)) + contentContainer.add(createBorderStylesShowcase(renderer)) + + mainContainer.add(contentContainer) +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.root.getRenderable("table-demo-scrollbox")?.destroyRecursively() + mainContainer = null + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/react/examples/table-demo.tsx b/packages/react/examples/table-demo.tsx new file mode 100644 index 000000000..abbd24b65 --- /dev/null +++ b/packages/react/examples/table-demo.tsx @@ -0,0 +1,151 @@ +import { createCliRenderer, ConsolePosition } from "@opentui/core" +import { createRoot, useRenderer } from "@opentui/react" +import { useEffect } from "react" + +const languages = [ + { name: "JavaScript", year: 1995, creator: "Brendan Eich" }, + { name: "Python", year: 1991, creator: "Guido van Rossum" }, + { name: "Rust", year: 2010, creator: "Graydon Hoare" }, + { name: "Go", year: 2009, creator: "Rob Pike" }, + { name: "TypeScript", year: 2012, creator: "Anders Hejlsberg" }, +] + +function BasicTable() { + return ( + + + + + + + + {languages.slice(0, 3).map((lang) => ( + + + ))} + +
+ + +
+ + +
+
+ ) +} + +function BorderedTable() { + return ( + + + + + + + + {languages.slice(0, 3).map((lang) => ( + + + ))} + +
+ + +
+ + +
+
+ ) +} + +function StyledTable() { + return ( + + + + + + + + {languages.map((lang, index) => ( + + + ))} + +
+ + +
+ + +
+
+ ) +} + +function BorderStylesShowcase() { + const borderStyles = ["single", "double", "rounded"] as const + const colors = ["#FFFFFF", "#FF6B6B", "#51CF66", "#FFD43B", "#748FFC"] + + return ( + + + {borderStyles.map((style, i) => ( + + + + + + + + + + + +
+
+
+
+ ))} +
+
+ ) +} + +export function TableDemo() { + const renderer = useRenderer() + + useEffect(() => { + if (renderer) { + renderer.useConsole = true + } + }, [renderer]) + + return ( + + + + Table Component Demo - Use arrow keys to scroll, Escape to return + + + + + + + + + ) +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + consoleOptions: { + position: ConsolePosition.BOTTOM, + maxStoredLogs: 1000, + sizePercent: 40, + }, + }) + createRoot(renderer).render() +} diff --git a/packages/solid/examples/components/ExampleSelector.tsx b/packages/solid/examples/components/ExampleSelector.tsx index 1c01a182b..e9b0afd4c 100644 --- a/packages/solid/examples/components/ExampleSelector.tsx +++ b/packages/solid/examples/components/ExampleSelector.tsx @@ -18,6 +18,7 @@ import TextStyleScene from "./text-style-demo.tsx" import { TextareaDemo } from "./textarea-demo.tsx" import { TextareaMinimalDemo } from "./textarea-minimal-demo.tsx" import { TextTruncationDemo } from "./text-truncation-demo.tsx" +import { TableDemo } from "./table-demo.tsx" const EXAMPLES = [ { @@ -110,6 +111,11 @@ const EXAMPLES = [ description: "Live message stream with chunked arrival simulation", scene: "session-scrollbox", }, + { + name: "Table Demo", + description: "Table component with borders, separators, colors, and alignment", + scene: "table-demo", + }, ] const ExampleSelector = () => { @@ -215,6 +221,9 @@ const ExampleSelector = () => { + + + diff --git a/packages/solid/examples/components/table-demo.tsx b/packages/solid/examples/components/table-demo.tsx new file mode 100644 index 000000000..6a159660a --- /dev/null +++ b/packages/solid/examples/components/table-demo.tsx @@ -0,0 +1,148 @@ +import { render, useRenderer } from "@opentui/solid" +import { ConsolePosition } from "@opentui/core" +import { onMount } from "solid-js" + +const languages = [ + { name: "JavaScript", year: 1995, creator: "Brendan Eich" }, + { name: "Python", year: 1991, creator: "Guido van Rossum" }, + { name: "Rust", year: 2010, creator: "Graydon Hoare" }, + { name: "Go", year: 2009, creator: "Rob Pike" }, + { name: "TypeScript", year: 2012, creator: "Anders Hejlsberg" }, +] + +function BasicTable() { + return ( + + + + + + + + {languages.slice(0, 3).map((lang) => ( + + + ))} + +
+ + +
+ + +
+
+ ) +} + +function BorderedTable() { + return ( + + + + + + + + {languages.slice(0, 3).map((lang) => ( + + + ))} + +
+ + +
+ + +
+
+ ) +} + +function StyledTable() { + return ( + + + + + + + + {languages.map((lang, index) => ( + + + ))} + +
+ + +
+ + +
+
+ ) +} + +function BorderStylesShowcase() { + const borderStyles = ["single", "double", "rounded"] as const + const colors = ["#FFFFFF", "#FF6B6B", "#51CF66", "#FFD43B", "#748FFC"] + + return ( + + + {borderStyles.map((style, i) => ( + + + + + + + + + + + +
+
+
+
+ ))} +
+
+ ) +} + +export function TableDemo() { + const renderer = useRenderer() + + onMount(() => { + renderer.useConsole = true + }) + + return ( + + + + Table Component Demo - Use arrow keys to scroll, Escape to return + + + + + + + + + ) +} + +if (import.meta.main) { + render(TableDemo, { + consoleOptions: { + position: ConsolePosition.BOTTOM, + maxStoredLogs: 1000, + sizePercent: 40, + }, + }) +}