@f5-sales-demo/pi-tui 23.3.4 → 23.4.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-tui",
4
- "version": "23.3.4",
4
+ "version": "23.4.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@f5-sales-demo/pi-natives": "23.3.4",
41
- "@f5-sales-demo/pi-utils": "23.3.4",
40
+ "@f5-sales-demo/pi-natives": "23.4.1",
41
+ "@f5-sales-demo/pi-utils": "23.4.1",
42
42
  "marked": "18.0.11"
43
43
  },
44
44
  "devDependencies": {
@@ -14,6 +14,7 @@ import {
14
14
  visibleWidth,
15
15
  wrapTextWithAnsi,
16
16
  } from "../utils";
17
+ import { renderTable } from "./table";
17
18
 
18
19
  interface LatexToken extends Tokens.Generic {
19
20
  type: "latex" | "latexBlock";
@@ -236,6 +237,8 @@ export interface MarkdownMediaOptions {
236
237
  }
237
238
 
238
239
  export interface MarkdownOptions {
240
+ /** Complete data-row preview; omitted means show every row. */
241
+ tablePreviewRows?: number;
239
242
  /** Render supported LaTeX expressions as Unicode text (default: true). */
240
243
  renderLatex?: boolean;
241
244
  /** Number of spaces used to indent fenced code content (default: 2). */
@@ -303,7 +306,12 @@ interface InlineStyleContext {
303
306
 
304
307
  type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
305
308
  type TableCellToken = { tokens?: Token[] };
306
- type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
309
+ type TableToken = Token & {
310
+ header: TableCellToken[];
311
+ rows: TableCellToken[][];
312
+ raw?: string;
313
+ align?: ("left" | "right" | "center" | null)[];
314
+ };
307
315
 
308
316
  function isImageToken(token: Token): token is Tokens.Image {
309
317
  return (
@@ -366,6 +374,7 @@ export class Markdown implements Component {
366
374
  #codeBlockIndent: number;
367
375
  #mediaOptions?: MarkdownMediaOptions;
368
376
  #renderLatex: boolean;
377
+ #tablePreviewRows?: number;
369
378
  #linkBasePath?: string;
370
379
  #mediaCache = new Map<
371
380
  string,
@@ -419,6 +428,7 @@ export class Markdown implements Component {
419
428
  this.#mediaOptions = options.mediaOptions;
420
429
  this.#renderLatex = options.renderLatex !== false;
421
430
  this.#linkBasePath = options.linkBasePath;
431
+ this.#tablePreviewRows = options.tablePreviewRows;
422
432
  }
423
433
 
424
434
  setText(text: string): void {
@@ -1068,210 +1078,25 @@ export class Markdown implements Component {
1068
1078
  return lines;
1069
1079
  }
1070
1080
 
1071
- /**
1072
- * Get the visible width of the longest word in a string.
1073
- */
1074
- #getLongestWordWidth(text: string, maxWidth?: number): number {
1075
- const words = text.split(/\s+/).filter(word => word.length > 0);
1076
- let longest = 0;
1077
- for (const word of words) {
1078
- longest = Math.max(longest, visibleWidth(word));
1079
- }
1080
- if (maxWidth === undefined) {
1081
- return longest;
1082
- }
1083
- return Math.min(longest, maxWidth);
1084
- }
1085
-
1086
- /**
1087
- * Wrap a table cell to fit into a column.
1088
- *
1089
- * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
1090
- * consistently with the rest of the renderer.
1091
- */
1092
- #wrapCellText(text: string, maxWidth: number): string[] {
1093
- return wrapTextWithAnsi(text, Math.max(1, maxWidth));
1094
- }
1095
-
1096
- /**
1097
- * Render a table with width-aware cell wrapping.
1098
- * Cells that don't fit are wrapped to multiple lines.
1099
- */
1081
+ /** Shared layout for Markdown and structured plugin tables. */
1100
1082
  #renderTable(
1101
1083
  token: TableToken,
1102
1084
  availableWidth: number,
1103
1085
  nextTokenType?: string,
1104
1086
  styleContext?: InlineStyleContext,
1105
1087
  ): string[] {
1106
- const lines: string[] = [];
1107
- const numCols = token.header.length;
1108
-
1109
- if (numCols === 0) {
1110
- return lines;
1111
- }
1112
-
1113
- // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
1114
- // = 2 + (n-1) * 3 + 2 = 3n + 1
1115
- const borderOverhead = 3 * numCols + 1;
1116
- const availableForCells = availableWidth - borderOverhead;
1117
- if (availableForCells < numCols) {
1118
- // Too narrow to render a stable table. Fall back to raw markdown.
1119
- const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
1120
- if (nextTokenType && nextTokenType !== "space") {
1121
- fallbackLines.push("");
1122
- }
1123
- return fallbackLines;
1124
- }
1125
-
1126
- const maxUnbrokenWordWidth = 30;
1127
-
1128
- // Calculate natural column widths (what each column needs without constraints)
1129
- const naturalWidths: number[] = [];
1130
- const minWordWidths: number[] = [];
1131
- for (let i = 0; i < numCols; i++) {
1132
- const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext);
1133
- naturalWidths[i] = visibleWidth(headerText);
1134
- minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
1135
- }
1136
- for (const row of token.rows) {
1137
- for (let i = 0; i < row.length; i++) {
1138
- const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
1139
- naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
1140
- minWordWidths[i] = Math.max(
1141
- minWordWidths[i] || 1,
1142
- this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth),
1143
- );
1144
- }
1145
- }
1146
-
1147
- let minColumnWidths = minWordWidths;
1148
- let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
1149
-
1150
- if (minCellsWidth > availableForCells) {
1151
- minColumnWidths = new Array(numCols).fill(1);
1152
- const remaining = availableForCells - numCols;
1153
-
1154
- if (remaining > 0) {
1155
- const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
1156
- const growth = minWordWidths.map(width => {
1157
- const weight = Math.max(0, width - 1);
1158
- return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
1159
- });
1160
-
1161
- for (let i = 0; i < numCols; i++) {
1162
- minColumnWidths[i] += growth[i] ?? 0;
1163
- }
1164
-
1165
- const allocated = growth.reduce((total, width) => total + width, 0);
1166
- let leftover = remaining - allocated;
1167
- for (let i = 0; leftover > 0 && i < numCols; i++) {
1168
- minColumnWidths[i]++;
1169
- leftover--;
1170
- }
1171
- }
1172
-
1173
- minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
1174
- }
1175
-
1176
- // Calculate column widths that fit within available width
1177
- const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
1178
- let columnWidths: number[];
1179
-
1180
- if (totalNaturalWidth <= availableWidth) {
1181
- // Everything fits naturally
1182
- columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
1183
- } else {
1184
- // Need to shrink columns to fit
1185
- const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
1186
- return total + Math.max(0, width - minColumnWidths[index]);
1187
- }, 0);
1188
- const extraWidth = Math.max(0, availableForCells - minCellsWidth);
1189
- columnWidths = minColumnWidths.map((minWidth, index) => {
1190
- const naturalWidth = naturalWidths[index];
1191
- const minWidthDelta = Math.max(0, naturalWidth - minWidth);
1192
- let grow = 0;
1193
- if (totalGrowPotential > 0) {
1194
- grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
1195
- }
1196
- return minWidth + grow;
1197
- });
1198
-
1199
- // Adjust for rounding errors - distribute remaining space
1200
- const allocated = columnWidths.reduce((a, b) => a + b, 0);
1201
- let remaining = availableForCells - allocated;
1202
- while (remaining > 0) {
1203
- let grew = false;
1204
- for (let i = 0; i < numCols && remaining > 0; i++) {
1205
- if (columnWidths[i] < naturalWidths[i]) {
1206
- columnWidths[i]++;
1207
- remaining--;
1208
- grew = true;
1209
- }
1210
- }
1211
- if (!grew) {
1212
- break;
1213
- }
1214
- }
1215
- }
1216
-
1217
- const t = this.#theme.symbols.table;
1218
- const h = t.horizontal;
1219
- const v = t.vertical;
1220
-
1221
- // Render top border
1222
- const topBorderCells = columnWidths.map(w => h.repeat(w));
1223
- lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
1224
-
1225
- // Render header with wrapping
1226
- const headerCellLines: string[][] = token.header.map((cell, i) => {
1227
- const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
1228
- return this.#wrapCellText(text, columnWidths[i]);
1229
- });
1230
- const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
1231
-
1232
- for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
1233
- const rowParts = headerCellLines.map((cellLines, colIdx) => {
1234
- const text = cellLines[lineIdx] || "";
1235
- const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1236
- return this.#theme.bold(padded);
1237
- });
1238
- lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
1239
- }
1240
-
1241
- // Render separator
1242
- const separatorCells = columnWidths.map(w => h.repeat(w));
1243
- const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
1244
- lines.push(separatorLine);
1245
-
1246
- // Render rows with wrapping
1247
- for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
1248
- const row = token.rows[rowIndex];
1249
- const rowCellLines: string[][] = row.map((cell, i) => {
1250
- const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
1251
- return this.#wrapCellText(text, columnWidths[i]);
1252
- });
1253
- const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
1254
-
1255
- for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
1256
- const rowParts = rowCellLines.map((cellLines, colIdx) => {
1257
- const text = cellLines[lineIdx] || "";
1258
- return text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1259
- });
1260
- lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
1261
- }
1262
-
1263
- if (rowIndex < token.rows.length - 1) {
1264
- lines.push(separatorLine);
1265
- }
1266
- }
1267
-
1268
- // Render bottom border
1269
- const bottomBorderCells = columnWidths.map(w => h.repeat(w));
1270
- lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
1271
-
1272
- if (nextTokenType && nextTokenType !== "space") {
1273
- lines.push(""); // Add spacing after table
1274
- }
1088
+ const lines = renderTable(
1089
+ token.header.map(cell => this.#renderInlineTokens(cell.tokens || [], styleContext)),
1090
+ (this.#tablePreviewRows === undefined ? token.rows : token.rows.slice(0, this.#tablePreviewRows)).map(row =>
1091
+ row.map(cell => this.#renderInlineTokens(cell.tokens || [], styleContext)),
1092
+ ),
1093
+ availableWidth,
1094
+ { symbols: this.#theme.symbols.table, bold: text => this.#theme.bold(text) },
1095
+ token.align,
1096
+ );
1097
+ if (this.#tablePreviewRows !== undefined && token.rows.length > this.#tablePreviewRows)
1098
+ lines.push(...wrapTextWithAnsi(`${token.rows.length - this.#tablePreviewRows} more rows`, availableWidth));
1099
+ if (nextTokenType && nextTokenType !== "space") lines.push("");
1275
1100
  return lines;
1276
1101
  }
1277
1102
  }
@@ -0,0 +1,187 @@
1
+ import type { BoxSymbols } from "../symbols";
2
+ import { padding, visibleWidth, wrapTextWithAnsi } from "../utils";
3
+
4
+ export interface TableTheme {
5
+ symbols: BoxSymbols;
6
+ bold(text: string): string;
7
+ }
8
+ function longestWordWidth(text: string, maxWidth: number): number {
9
+ return Math.min(maxWidth, Math.max(1, ...text.split(/\s+/).map(visibleWidth)));
10
+ }
11
+ /** Width includes borders and cell padding. Narrow records reflow without losing fields. */
12
+ export function renderTable(
13
+ headers: readonly string[],
14
+ rows: readonly (readonly string[])[],
15
+ availableWidth: number,
16
+ theme: TableTheme,
17
+ alignment?: readonly ("left" | "right" | "center" | null)[],
18
+ ): string[] {
19
+ const lines: string[] = [];
20
+ const numCols = headers.length;
21
+
22
+ if (numCols === 0) {
23
+ return lines;
24
+ }
25
+
26
+ // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
27
+ // = 2 + (n-1) * 3 + 2 = 3n + 1
28
+ const borderOverhead = 3 * numCols + 1;
29
+ const availableForCells = availableWidth - borderOverhead;
30
+ const reflow = (): string[] => {
31
+ const records = rows.length ? rows : [headers.map(() => "")];
32
+ for (const row of records) {
33
+ for (let i = 0; i < numCols; i++) {
34
+ lines.push(...wrapTextWithAnsi(`${theme.bold(headers[i])}: ${row[i] ?? ""}`, Math.max(1, availableWidth)));
35
+ }
36
+ lines.push("");
37
+ }
38
+ return lines;
39
+ };
40
+ if (availableForCells < numCols * 6) return reflow();
41
+
42
+ const maxUnbrokenWordWidth = 30;
43
+
44
+ // Calculate natural column widths (what each column needs without constraints)
45
+ const naturalWidths: number[] = [];
46
+ const minWordWidths: number[] = [];
47
+ for (let i = 0; i < numCols; i++) {
48
+ const headerText = headers[i];
49
+ naturalWidths[i] = visibleWidth(headerText);
50
+ minWordWidths[i] = Math.max(1, longestWordWidth(headerText, maxUnbrokenWordWidth));
51
+ }
52
+ for (const row of rows) {
53
+ for (let i = 0; i < row.length; i++) {
54
+ const cellText = row[i];
55
+ naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
56
+ minWordWidths[i] = Math.max(minWordWidths[i] || 1, longestWordWidth(cellText, maxUnbrokenWordWidth));
57
+ }
58
+ }
59
+
60
+ let minColumnWidths = minWordWidths;
61
+ let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
62
+
63
+ if (minCellsWidth > availableForCells) {
64
+ minColumnWidths = new Array(numCols).fill(1);
65
+ const remaining = availableForCells - numCols;
66
+
67
+ if (remaining > 0) {
68
+ const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
69
+ const growth = minWordWidths.map(width => {
70
+ const weight = Math.max(0, width - 1);
71
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
72
+ });
73
+
74
+ for (let i = 0; i < numCols; i++) {
75
+ minColumnWidths[i] += growth[i] ?? 0;
76
+ }
77
+
78
+ const allocated = growth.reduce((total, width) => total + width, 0);
79
+ let leftover = remaining - allocated;
80
+ for (let i = 0; leftover > 0 && i < numCols; i++) {
81
+ minColumnWidths[i]++;
82
+ leftover--;
83
+ }
84
+ }
85
+
86
+ minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
87
+ }
88
+
89
+ // Calculate column widths that fit within available width
90
+ const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
91
+ let columnWidths: number[];
92
+
93
+ if (totalNaturalWidth <= availableWidth) {
94
+ // Everything fits naturally
95
+ columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
96
+ } else {
97
+ // Need to shrink columns to fit
98
+ const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
99
+ return total + Math.max(0, width - minColumnWidths[index]);
100
+ }, 0);
101
+ const extraWidth = Math.max(0, availableForCells - minCellsWidth);
102
+ columnWidths = minColumnWidths.map((minWidth, index) => {
103
+ const naturalWidth = naturalWidths[index];
104
+ const minWidthDelta = Math.max(0, naturalWidth - minWidth);
105
+ let grow = 0;
106
+ if (totalGrowPotential > 0) {
107
+ grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
108
+ }
109
+ return minWidth + grow;
110
+ });
111
+
112
+ // Adjust for rounding errors - distribute remaining space
113
+ const allocated = columnWidths.reduce((a, b) => a + b, 0);
114
+ let remaining = availableForCells - allocated;
115
+ while (remaining > 0) {
116
+ let grew = false;
117
+ for (let i = 0; i < numCols && remaining > 0; i++) {
118
+ if (columnWidths[i] < naturalWidths[i]) {
119
+ columnWidths[i]++;
120
+ remaining--;
121
+ grew = true;
122
+ }
123
+ }
124
+ if (!grew) {
125
+ break;
126
+ }
127
+ }
128
+ }
129
+
130
+ if (columnWidths.some((width, index) => width < Math.min(6, naturalWidths[index]))) return reflow();
131
+
132
+ const t = theme.symbols;
133
+ const h = t.horizontal;
134
+ const v = t.vertical;
135
+
136
+ // Render top border
137
+ const topBorderCells = columnWidths.map(w => h.repeat(w));
138
+ lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
139
+
140
+ // Render header with wrapping
141
+ const headerCellLines: string[][] = headers.map((text, i) => wrapTextWithAnsi(text, columnWidths[i]));
142
+ const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
143
+
144
+ for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
145
+ const rowParts = headerCellLines.map((cellLines, colIdx) => {
146
+ const text = cellLines[lineIdx] || "";
147
+ const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
148
+ return theme.bold(padded);
149
+ });
150
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
151
+ }
152
+
153
+ // Render separator
154
+ const separatorCells = columnWidths.map(w => h.repeat(w));
155
+ const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
156
+ lines.push(separatorLine);
157
+
158
+ // Render rows with wrapping
159
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
160
+ const row = rows[rowIndex];
161
+ const rowCellLines: string[][] = headers.map((_, i) => wrapTextWithAnsi(row[i] ?? "", columnWidths[i]));
162
+ const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
163
+
164
+ for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
165
+ const rowParts = rowCellLines.map((cellLines, colIdx) => {
166
+ const text = cellLines[lineIdx] || "";
167
+ const pad = padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
168
+ const numeric =
169
+ alignment?.[colIdx] === "right" ||
170
+ (alignment?.[colIdx] !== "left" &&
171
+ rows.every(row => !row[colIdx] || /^-?\d+(?:[.,]\d+)*%?$/.test(row[colIdx])));
172
+ return numeric ? pad + text : text + pad;
173
+ });
174
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
175
+ }
176
+
177
+ if (rowIndex < rows.length - 1) {
178
+ lines.push(separatorLine);
179
+ }
180
+ }
181
+
182
+ // Render bottom border
183
+ const bottomBorderCells = columnWidths.map(w => h.repeat(w));
184
+ lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
185
+
186
+ return lines;
187
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export * from "./components/select-list";
31
31
  export * from "./components/settings-list";
32
32
  export * from "./components/spacer";
33
33
  export * from "./components/tab-bar";
34
+ export * from "./components/table";
34
35
  export * from "./components/text";
35
36
  export * from "./components/truncated-text";
36
37
  // Editor component interface (for custom editors)