@lewishowles/cli-style 0.12.1 → 0.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.0 - 2026-09-05
6
+
7
+ ### Added
8
+
9
+ - Added Python `table()` and `row_group()` wrapper functions to the Python adapter, matching the existing shell/JS renderers.
10
+ - `table()` columns can now set `maxWidth` and `overflow` (`'truncate'`, the default, or `'wrap'`) to cap a column's width and control how long values render within it.
11
+
5
12
  ## 0.12.1 - 2026-08-20
6
13
 
7
14
  ### Fixed
@@ -159,6 +159,50 @@ def row(
159
159
  return render("row", data, **kwargs)
160
160
 
161
161
 
162
+ # Render an aligned table from Python values.
163
+ #
164
+ # @param {list[dict[str, Any]]} columns
165
+ # Table column definitions, including per-column overflow settings.
166
+ # @param {list[dict[str, Any]]} rows
167
+ # Table row values.
168
+ # @param {bool|None} unicode
169
+ # Optional Unicode border preference.
170
+ # @param {int|None} width
171
+ # Optional table width.
172
+ def table(
173
+ columns: list[dict[str, Any]],
174
+ rows: list[dict[str, Any]],
175
+ *,
176
+ unicode: bool | None = None,
177
+ width: int | None = None,
178
+ **kwargs: Any,
179
+ ) -> str:
180
+ return render("table", {
181
+ "columns": columns,
182
+ "rows": rows,
183
+ "unicode": unicode,
184
+ "width": width,
185
+ }, **kwargs)
186
+
187
+
188
+ # Render a group of labelled value rows from Python values.
189
+ #
190
+ # @param {list[dict[str, Any]]} rows
191
+ # Labelled values to render together.
192
+ # @param {int|None} label_width
193
+ # Optional minimum label width.
194
+ def row_group(
195
+ rows: list[dict[str, Any]],
196
+ *,
197
+ label_width: int | None = None,
198
+ **kwargs: Any,
199
+ ) -> str:
200
+ return render("row-group", {
201
+ "rows": rows,
202
+ "labelWidth": label_width,
203
+ }, **kwargs)
204
+
205
+
162
206
  # Render an inline span from Python values.
163
207
  #
164
208
  # @param {str} value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/cli-style",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -764,6 +764,12 @@ function renderPrimitives(options) {
764
764
  "Tables",
765
765
  renderTableExample(options),
766
766
  "",
767
+ "Truncated tables",
768
+ renderOverflowTableExample(options, "truncate"),
769
+ "",
770
+ "Wrapped tables",
771
+ renderOverflowTableExample(options, "wrap"),
772
+ "",
767
773
  "Narrow tables",
768
774
  renderTableExample({
769
775
  ...options,
@@ -900,6 +906,40 @@ function renderTableExample(options) {
900
906
  });
901
907
  }
902
908
 
909
+ /**
910
+ * Render a table with a long-detail column capped by maxWidth.
911
+ *
912
+ * @param {object} options
913
+ * Rendering options.
914
+ * @param {string} overflow
915
+ * Overflow mode ('truncate' or 'wrap') for the capped column.
916
+ * @returns {string}
917
+ * Table example.
918
+ */
919
+ function renderOverflowTableExample(options, overflow) {
920
+ return table({
921
+ ...options,
922
+ columns: [
923
+ {
924
+ key: "source",
925
+ label: "Source",
926
+ },
927
+ {
928
+ key: "detail",
929
+ label: "Detail",
930
+ maxWidth: 28,
931
+ overflow,
932
+ },
933
+ ],
934
+ rows: [
935
+ {
936
+ detail: "packages/cli-style/src/primitives/table.js",
937
+ source: "Table",
938
+ },
939
+ ],
940
+ });
941
+ }
942
+
903
943
  /**
904
944
  * Render a chart that exposes positive, warning, and negative colours.
905
945
  *
@@ -1,9 +1,7 @@
1
1
  import { foreground } from "../formatters/ansi.js";
2
+ import { defaultWidth, minimumWidth, normaliseWidth } from "../formatters/width.js";
2
3
  import { tableColours } from "../theme/colours.js";
3
4
 
4
- // Tables assume a standard terminal width when no capability is provided.
5
- const defaultWidth = 80;
6
-
7
5
  // Columns use a compact but readable gap.
8
6
  const columnSeparator = " ";
9
7
 
@@ -15,7 +13,9 @@ const columnSeparator = " ";
15
13
  * @param {boolean} options.colour
16
14
  * Whether ANSI colour should be applied.
17
15
  * @param {object[]} options.columns
18
- * Column keys and labels.
16
+ * Column keys and labels. A column may set maxWidth to cap its width,
17
+ * with overflow ('truncate', the default, or 'wrap') controlling how
18
+ * values beyond that width render.
19
19
  * @param {object[]} options.rows
20
20
  * Table records.
21
21
  * @param {boolean} options.unicode
@@ -39,7 +39,18 @@ export function table(options = {}) {
39
39
  widths.reduce((total, width) => total + width, 0) +
40
40
  columnSeparator.length * (columns.length - 1);
41
41
 
42
- const availableWidth = Number.isFinite(options.width) ? options.width : defaultWidth;
42
+ const requestedWidth = Number.isFinite(options.width) ? options.width : undefined;
43
+
44
+ // Let a requested width smaller than minimumWidth pass through unclamped.
45
+ const minimumTableWidth =
46
+ requestedWidth === undefined
47
+ ? minimumWidth
48
+ : Math.max(1, Math.min(requestedWidth, minimumWidth));
49
+
50
+ const availableWidth = normaliseWidth(requestedWidth, {
51
+ defaultWidth,
52
+ minWidth: minimumTableWidth,
53
+ });
43
54
 
44
55
  if (naturalWidth > availableWidth) {
45
56
  return renderBlocks(columns, rows, options);
@@ -99,9 +110,65 @@ function normaliseRows(rows) {
99
110
  * Column widths.
100
111
  */
101
112
  function columnWidths(columns, rows) {
102
- return columns.map((column) =>
103
- Math.max(column.label.length, ...rows.map((row) => cellValue(row[column.key]).length)),
104
- );
113
+ return columns.map((column) => {
114
+ const naturalWidth = Math.max(
115
+ column.label.length,
116
+ ...rows.map((row) => cellValue(row[column.key]).length),
117
+ );
118
+
119
+ const maxWidth = columnMaxWidth(column);
120
+
121
+ return maxWidth === null ? naturalWidth : Math.min(naturalWidth, maxWidth);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Return a column's configured width cap, if any.
127
+ *
128
+ * @param {object} column
129
+ * Table column.
130
+ * @returns {number|null}
131
+ * Normalised maxWidth, or null when the column has none.
132
+ */
133
+ function columnMaxWidth(column) {
134
+ if (!Number.isFinite(column.maxWidth) || column.maxWidth < 1) {
135
+ return null;
136
+ }
137
+
138
+ return normaliseWidth(column.maxWidth, {
139
+ defaultWidth: 1,
140
+ minWidth: 1,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Split a cell's value into the lines it renders as within a fixed-width column.
146
+ *
147
+ * @param {string} value
148
+ * Cell value.
149
+ * @param {object} column
150
+ * Table column, read for its overflow mode.
151
+ * @param {number} width
152
+ * Column width.
153
+ * @returns {string[]}
154
+ * Lines the cell renders as; truncated to one line with a trailing
155
+ * ellipsis unless the column's overflow is 'wrap'.
156
+ */
157
+ function columnLines(value, column, width) {
158
+ if (value.length <= width) {
159
+ return [value];
160
+ }
161
+
162
+ if (column.overflow === "wrap") {
163
+ return Array.from(
164
+ {
165
+ length: Math.ceil(value.length / width),
166
+ },
167
+ (_, index) => value.slice(index * width, (index + 1) * width),
168
+ );
169
+ }
170
+
171
+ return [`${value.slice(0, width - 1)}…`];
105
172
  }
106
173
 
107
174
  /**
@@ -120,9 +187,11 @@ function columnWidths(columns, rows) {
120
187
  */
121
188
  function renderTable(columns, rows, widths, options) {
122
189
  const header = columns
123
- .map((column, index) =>
124
- renderHeader(padColumn(column.label, widths[index], index, columns.length), options),
125
- )
190
+ .map((column, index) => {
191
+ const headerLine = columnLines(column.label, { overflow: "truncate" }, widths[index])[0];
192
+
193
+ return renderHeader(padColumn(headerLine, widths[index], index, columns.length), options);
194
+ })
126
195
  .join(columnSeparator);
127
196
 
128
197
  const ruleCharacter = options.unicode === false ? "-" : "─";
@@ -131,17 +200,29 @@ function renderTable(columns, rows, widths, options) {
131
200
  const renderedRule =
132
201
  options.colour === true ? foreground(rule, tableColours.border, options) : rule;
133
202
 
134
- const body = rows.map((row) =>
135
- columns
136
- .map((column, index) =>
137
- renderCell(
138
- padColumn(cellValue(row[column.key]), widths[index], index, columns.length),
139
- index,
140
- options,
141
- ),
142
- )
143
- .join(columnSeparator),
144
- );
203
+ const body = rows.flatMap((row) => {
204
+ const lines = columns.map((column, index) =>
205
+ columnLines(cellValue(row[column.key]), column, widths[index]),
206
+ );
207
+
208
+ const lineCount = Math.max(...lines.map((column) => column.length));
209
+
210
+ return Array.from(
211
+ {
212
+ length: lineCount,
213
+ },
214
+ (_, lineIndex) =>
215
+ columns
216
+ .map((column, index) =>
217
+ renderCell(
218
+ padColumn(lines[index][lineIndex] ?? "", widths[index], index, columns.length),
219
+ index,
220
+ options,
221
+ ),
222
+ )
223
+ .join(columnSeparator),
224
+ );
225
+ });
145
226
 
146
227
  return [header, renderedRule, ...body].join("\n");
147
228
  }