@tooee/renderers 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Table.tsx ADDED
@@ -0,0 +1,263 @@
1
+ import { useTerminalDimensions } from "@opentui/react"
2
+ import { useTheme } from "@tooee/themes"
3
+ import type { ColumnDef, TableRow } from "./table-types.js"
4
+
5
+ export interface TableProps {
6
+ columns: ColumnDef[]
7
+ rows: TableRow[]
8
+ /** Maximum width for the table. If not provided, uses terminal width. */
9
+ maxWidth?: number
10
+ /** Minimum width for any column (default: 4) */
11
+ minColumnWidth?: number
12
+ /** Maximum width for any column (default: 50) */
13
+ maxColumnWidth?: number
14
+ /** Number of rows to sample for width calculation (default: 100) */
15
+ sampleSize?: number
16
+ cursor?: number
17
+ selectionStart?: number
18
+ selectionEnd?: number
19
+ matchingRows?: Set<number>
20
+ currentMatchRow?: number
21
+ toggledRows?: Set<number>
22
+ }
23
+
24
+ const PADDING = 1
25
+ const THRESHOLD = 20
26
+ const ELLIPSIS = "…"
27
+ const DEFAULT_MIN_COL_WIDTH = 4
28
+ const DEFAULT_MAX_COL_WIDTH = 50
29
+ const DEFAULT_SAMPLE_SIZE = 100
30
+
31
+ function isNumeric(value: string): boolean {
32
+ return /^\s*-?[\d,]+\.?\d*\s*$/.test(value)
33
+ }
34
+
35
+ interface ColumnWidthOptions {
36
+ minColumnWidth: number
37
+ maxColumnWidth: number
38
+ sampleSize: number
39
+ }
40
+
41
+ function sampleRows(rows: string[][], sampleSize: number): string[][] {
42
+ if (rows.length <= sampleSize) return rows
43
+ // Sample evenly distributed rows for representative widths
44
+ const step = rows.length / sampleSize
45
+ const sampled: string[][] = []
46
+ for (let i = 0; i < sampleSize; i++) {
47
+ sampled.push(rows[Math.floor(i * step)])
48
+ }
49
+ return sampled
50
+ }
51
+
52
+ function computeColumnWidths(
53
+ headers: string[],
54
+ rows: string[][],
55
+ maxWidth: number,
56
+ options: ColumnWidthOptions,
57
+ ): number[] {
58
+ const { minColumnWidth, maxColumnWidth, sampleSize } = options
59
+ const colCount = headers.length
60
+
61
+ // Sample rows for performance on large tables
62
+ const sampledRows = sampleRows(rows, sampleSize)
63
+
64
+ // Calculate natural width for each column (header + content + padding)
65
+ const naturalWidths = headers.map((header, col) => {
66
+ const headerLen = header.length
67
+ const maxRowLen = sampledRows.reduce((max, row) => Math.max(max, (row[col] ?? "").length), 0)
68
+ const contentWidth = Math.max(headerLen, maxRowLen)
69
+ // Apply min/max constraints before adding padding
70
+ const constrainedWidth = Math.min(maxColumnWidth, Math.max(minColumnWidth, contentWidth))
71
+ return constrainedWidth + PADDING * 2
72
+ })
73
+
74
+ // 1 for each column border + 1 for trailing border
75
+ const borderOverhead = colCount + 1
76
+ const totalNatural = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead
77
+
78
+ // If everything fits, use natural widths
79
+ if (totalNatural <= maxWidth) {
80
+ return naturalWidths
81
+ }
82
+
83
+ const available = maxWidth - borderOverhead
84
+ const minColWidthWithPadding = minColumnWidth + PADDING * 2
85
+
86
+ // Extreme case: not even minimum widths fit
87
+ if (available <= colCount * minColWidthWithPadding) {
88
+ return naturalWidths.map(() =>
89
+ Math.max(minColWidthWithPadding, Math.floor(available / colCount)),
90
+ )
91
+ }
92
+
93
+ // Give compact columns their natural width, distribute rest proportionally
94
+ const compact: boolean[] = naturalWidths.map((w) => w <= THRESHOLD)
95
+ const compactTotal = naturalWidths.reduce((sum, w, i) => sum + (compact[i] ? w : 0), 0)
96
+ const remaining = available - compactTotal
97
+ const longTotal = naturalWidths.reduce((sum, w, i) => sum + (compact[i] ? 0 : w), 0)
98
+
99
+ if (longTotal === 0 || remaining <= 0) {
100
+ // All compact or no space left — distribute evenly
101
+ const total = naturalWidths.reduce((a, b) => a + b, 0)
102
+ return naturalWidths.map((w) =>
103
+ Math.max(minColWidthWithPadding, Math.floor((w / total) * available)),
104
+ )
105
+ }
106
+
107
+ return naturalWidths.map((w, i) => {
108
+ if (compact[i]) return w
109
+ return Math.max(minColWidthWithPadding, Math.floor((w / longTotal) * remaining))
110
+ })
111
+ }
112
+
113
+ function truncate(text: string, width: number): string {
114
+ const contentWidth = width - PADDING * 2
115
+ if (contentWidth <= 0) return ""
116
+ if (text.length <= contentWidth) return text
117
+ if (contentWidth <= 1) return ELLIPSIS.slice(0, contentWidth)
118
+ return text.slice(0, contentWidth - 1) + ELLIPSIS
119
+ }
120
+
121
+ function padCell(text: string, width: number, rightAlign: boolean): string {
122
+ const contentWidth = width - PADDING * 2
123
+ const truncated = truncate(text, width)
124
+ const padLeft = " ".repeat(PADDING)
125
+ const padRight = " ".repeat(PADDING)
126
+ if (rightAlign) {
127
+ const space = contentWidth - truncated.length
128
+ return padLeft + " ".repeat(Math.max(0, space)) + truncated + padRight
129
+ }
130
+ const space = contentWidth - truncated.length
131
+ return padLeft + truncated + " ".repeat(Math.max(0, space)) + padRight
132
+ }
133
+
134
+ function buildBorderLine(
135
+ widths: number[],
136
+ left: string,
137
+ mid: string,
138
+ right: string,
139
+ fill: string,
140
+ ): string {
141
+ return left + widths.map((w) => fill.repeat(w)).join(mid) + right
142
+ }
143
+
144
+ function buildDataLine(cells: string[], widths: number[], alignments: boolean[]): string {
145
+ const parts = cells.map((cell, i) => padCell(cell, widths[i], alignments[i]))
146
+ return "│" + parts.join("│") + "│"
147
+ }
148
+
149
+ function formatCellValue(value: unknown): string {
150
+ if (value == null) return ""
151
+ if (typeof value === "string") return value
152
+ if (typeof value === "number" || typeof value === "boolean") return String(value)
153
+ if (value instanceof Date) return value.toISOString()
154
+ try {
155
+ return JSON.stringify(value)
156
+ } catch {
157
+ return String(value)
158
+ }
159
+ }
160
+
161
+ export function Table({
162
+ columns,
163
+ rows,
164
+ maxWidth,
165
+ minColumnWidth = DEFAULT_MIN_COL_WIDTH,
166
+ maxColumnWidth = DEFAULT_MAX_COL_WIDTH,
167
+ sampleSize = DEFAULT_SAMPLE_SIZE,
168
+ cursor,
169
+ selectionStart,
170
+ selectionEnd,
171
+ matchingRows,
172
+ currentMatchRow,
173
+ toggledRows,
174
+ }: TableProps) {
175
+ const { theme } = useTheme()
176
+ const { width: terminalWidth } = useTerminalDimensions()
177
+
178
+ // Use terminal width minus margins (1 on each side) if maxWidth not provided
179
+ const effectiveMaxWidth = maxWidth ?? terminalWidth - 2
180
+
181
+ const headers = columns.map((column) => column.header ?? column.key)
182
+ const normalizedRows = rows.map((row) =>
183
+ columns.map((column) => formatCellValue(row[column.key])),
184
+ )
185
+
186
+ const colWidths = computeColumnWidths(headers, normalizedRows, effectiveMaxWidth, {
187
+ minColumnWidth,
188
+ maxColumnWidth,
189
+ sampleSize,
190
+ })
191
+ const alignments = columns.map((column, colIdx) => {
192
+ if (column.align === "right") return true
193
+ if (column.align === "left") return false
194
+ const sampleValues = normalizedRows.slice(0, 10).map((row) => row[colIdx] ?? "")
195
+ const numericCount = sampleValues.filter(isNumeric).length
196
+ return numericCount > sampleValues.length / 2
197
+ })
198
+
199
+ const topBorder = buildBorderLine(colWidths, "┌", "┬", "┐", "─")
200
+ const headerSep = buildBorderLine(colWidths, "├", "┼", "┤", "─")
201
+ const bottomBorder = buildBorderLine(colWidths, "└", "┴", "┘", "─")
202
+
203
+ const headerLine = buildDataLine(headers, colWidths, alignments)
204
+ const dataLines = normalizedRows.map((row) => buildDataLine(row, colWidths, alignments))
205
+
206
+ const getRowStyle = (rowIndex: number): { fg?: string; bg?: string } => {
207
+ const isCursor = cursor === rowIndex
208
+ const isSelected =
209
+ selectionStart != null &&
210
+ selectionEnd != null &&
211
+ rowIndex >= selectionStart &&
212
+ rowIndex <= selectionEnd
213
+ const isMatch = matchingRows?.has(rowIndex)
214
+ const isCurrentMatch = currentMatchRow === rowIndex
215
+ const isToggled = toggledRows?.has(rowIndex)
216
+
217
+ let bg: string | undefined
218
+ let fg: string | undefined = theme.text
219
+
220
+ // Determine background: selection < cursor (cursor overwrites)
221
+ if (isSelected) {
222
+ bg = theme.selection
223
+ }
224
+ if (isToggled && !isCursor && !isSelected) {
225
+ bg = theme.backgroundPanel
226
+ }
227
+ if (isCursor) {
228
+ bg = theme.cursorLine
229
+ }
230
+
231
+ // Highlight match indicator on matching rows
232
+ if (isMatch && !isCursor) {
233
+ fg = isCurrentMatch ? theme.primary : theme.warning
234
+ }
235
+
236
+ return { fg, bg }
237
+ }
238
+
239
+ return (
240
+ <box style={{ flexDirection: "column", marginLeft: 1, marginRight: 1, marginBottom: 1 }}>
241
+ <text content={topBorder} fg={theme.border} />
242
+ <text content={headerLine} fg={theme.primary} />
243
+ <text content={headerSep} fg={theme.border} />
244
+ {dataLines.map((line, i) => {
245
+ const style = getRowStyle(i)
246
+ return <text key={i} content={line} fg={style.fg} bg={style.bg} />
247
+ })}
248
+ <text content={bottomBorder} fg={theme.border} />
249
+ </box>
250
+ )
251
+ }
252
+
253
+ // Exported for testing
254
+ export {
255
+ computeColumnWidths,
256
+ truncate,
257
+ padCell,
258
+ isNumeric,
259
+ buildBorderLine,
260
+ buildDataLine,
261
+ sampleRows,
262
+ }
263
+ export type { ColumnWidthOptions }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { MarkdownView } from "./MarkdownView.jsx"
2
+ export { CodeView } from "./CodeView.jsx"
3
+ export { ImageView } from "./ImageView.jsx"
4
+ export {
5
+ Table,
6
+ computeColumnWidths,
7
+ truncate,
8
+ padCell,
9
+ isNumeric,
10
+ buildBorderLine,
11
+ buildDataLine,
12
+ sampleRows,
13
+ } from "./Table.jsx"
14
+ export type { TableProps, ColumnWidthOptions } from "./Table.jsx"
15
+ export type { ColumnDef, TableRow } from "./table-types.js"
16
+ export { CommandPalette } from "./CommandPalette.jsx"
17
+ export type { CommandPaletteEntry } from "./CommandPalette.jsx"
18
+ export { parseCSV, parseTSV, parseJSON, parseAuto, detectFormat } from "./parsers.js"
19
+ export type { Format, ParsedTable } from "./parsers.js"
package/src/parsers.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { ColumnDef, TableRow } from "./table-types.js"
2
+
3
+ export interface ParsedTable {
4
+ columns: ColumnDef[]
5
+ rows: TableRow[]
6
+ format: Format
7
+ }
8
+
9
+ export function parseCSV(input: string): { columns: ColumnDef[]; rows: TableRow[] } {
10
+ const lines = splitLines(input)
11
+ if (lines.length === 0) return { columns: [], rows: [] }
12
+ const columns = createColumnDefs(parseCSVLine(lines[0]))
13
+ const rows = buildRows(columns, lines.slice(1).map(parseCSVLine))
14
+ return { columns, rows }
15
+ }
16
+
17
+ function parseCSVLine(line: string): string[] {
18
+ const fields: string[] = []
19
+ let i = 0
20
+ while (i < line.length) {
21
+ if (line[i] === '"') {
22
+ // Quoted field
23
+ i++
24
+ let field = ""
25
+ while (i < line.length) {
26
+ if (line[i] === '"') {
27
+ if (i + 1 < line.length && line[i + 1] === '"') {
28
+ field += '"'
29
+ i += 2
30
+ } else {
31
+ i++ // closing quote
32
+ break
33
+ }
34
+ } else {
35
+ field += line[i]
36
+ i++
37
+ }
38
+ }
39
+ fields.push(field)
40
+ if (i < line.length && line[i] === ",") i++ // skip comma
41
+ } else {
42
+ const nextComma = line.indexOf(",", i)
43
+ if (nextComma === -1) {
44
+ fields.push(line.slice(i))
45
+ break
46
+ } else {
47
+ fields.push(line.slice(i, nextComma))
48
+ i = nextComma + 1
49
+ }
50
+ }
51
+ }
52
+ return fields
53
+ }
54
+
55
+ export function parseTSV(input: string): { columns: ColumnDef[]; rows: TableRow[] } {
56
+ const lines = splitLines(input)
57
+ if (lines.length === 0) return { columns: [], rows: [] }
58
+ const columns = createColumnDefs(lines[0].split("\t"))
59
+ const rows = buildRows(columns, lines.slice(1).map((line) => line.split("\t")))
60
+ return { columns, rows }
61
+ }
62
+
63
+ export function parseJSON(input: string): { columns: ColumnDef[]; rows: TableRow[] } {
64
+ const data = JSON.parse(input)
65
+ if (!Array.isArray(data) || data.length === 0) return { columns: [], rows: [] }
66
+ const keys = Array.from(
67
+ new Set(data.flatMap((item: Record<string, unknown>) => Object.keys(item))),
68
+ )
69
+ const columns: ColumnDef[] = keys.map((key) => ({ key, header: key }))
70
+ const rows = data.map((item: Record<string, unknown>) => {
71
+ const row: TableRow = {}
72
+ for (const column of columns) {
73
+ row[column.key] = item[column.key] ?? ""
74
+ }
75
+ return row
76
+ })
77
+ return { columns, rows }
78
+ }
79
+
80
+ export type Format = "csv" | "tsv" | "json" | "unknown"
81
+
82
+ export function detectFormat(input: string): Format {
83
+ const trimmed = input.trimStart()
84
+ if (trimmed.startsWith("[")) {
85
+ try {
86
+ const parsed = JSON.parse(trimmed)
87
+ if (Array.isArray(parsed)) return "json"
88
+ } catch {}
89
+ }
90
+ const firstLine = input.split("\n")[0] ?? ""
91
+ if (firstLine.includes("\t")) return "tsv"
92
+ if (firstLine.includes(",")) return "csv"
93
+ return "unknown"
94
+ }
95
+
96
+ export function parseAuto(input: string): ParsedTable {
97
+ const format = detectFormat(input)
98
+ let columns: ColumnDef[]
99
+ let rows: TableRow[]
100
+ switch (format) {
101
+ case "csv":
102
+ ;({ columns, rows } = parseCSV(input))
103
+ break
104
+ case "tsv":
105
+ ;({ columns, rows } = parseTSV(input))
106
+ break
107
+ case "json":
108
+ ;({ columns, rows } = parseJSON(input))
109
+ break
110
+ default:
111
+ // Fall back to CSV
112
+ ;({ columns, rows } = parseCSV(input))
113
+ break
114
+ }
115
+ return { columns, rows, format }
116
+ }
117
+
118
+ function splitLines(input: string): string[] {
119
+ return input.split("\n").filter((line) => line.trim().length > 0)
120
+ }
121
+
122
+ function createColumnDefs(rawHeaders: string[]): ColumnDef[] {
123
+ const seen = new Map<string, number>()
124
+ return rawHeaders.map((header, index) => {
125
+ const trimmed = header.trim()
126
+ const fallback = `column_${index + 1}`
127
+ const base = trimmed === "" ? fallback : trimmed
128
+ const count = seen.get(base) ?? 0
129
+ seen.set(base, count + 1)
130
+ const key = count === 0 ? base : `${base}_${count + 1}`
131
+ return {
132
+ key,
133
+ header: trimmed || undefined,
134
+ }
135
+ })
136
+ }
137
+
138
+ function buildRows(columns: ColumnDef[], rawRows: string[][]): TableRow[] {
139
+ return rawRows.map((row) => {
140
+ const record: TableRow = {}
141
+ columns.forEach((column, index) => {
142
+ record[column.key] = row[index] ?? ""
143
+ })
144
+ return record
145
+ })
146
+ }
@@ -0,0 +1,7 @@
1
+ export interface ColumnDef {
2
+ key: string
3
+ header?: string
4
+ align?: "left" | "right"
5
+ }
6
+
7
+ export type TableRow = Record<string, unknown>