@lewishowles/cli-style 0.12.0 → 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,19 @@
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
+
12
+ ## 0.12.1 - 2026-08-20
13
+
14
+ ### Fixed
15
+
16
+ - Fixed `row()` (and `rowGroup()`) wrapping long values one character per line when a long label left very little room for the value. Values now print unwrapped when the available width is too small to wrap usefully; an explicit `wrapWidth` still overrides the fallback.
17
+
5
18
  ## 0.12.0 - 2026-08-20
6
19
 
7
20
  ### Added
@@ -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.0",
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,
@@ -831,6 +837,10 @@ function renderPrimitives(options) {
831
837
  wrap: false,
832
838
  },
833
839
  ),
840
+ row("Long label ".repeat(10), "pending (chk_example)", {
841
+ ...options,
842
+ width: 80,
843
+ }),
834
844
  "",
835
845
  "Row groups",
836
846
  rowGroup({
@@ -896,6 +906,40 @@ function renderTableExample(options) {
896
906
  });
897
907
  }
898
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
+
899
943
  /**
900
944
  * Render a chart that exposes positive, warning, and negative colours.
901
945
  *
@@ -11,6 +11,9 @@ const defaultSeparator = " ";
11
11
  // Keeps a wrapped value within a typical narrow terminal column.
12
12
  const defaultWrapWidth = 64;
13
13
 
14
+ // Avoid character-per-line wrapping when labels leave no useful value width.
15
+ const minimumWrapWidth = 10;
16
+
14
17
  /**
15
18
  * Render a labelled value row.
16
19
  *
@@ -33,7 +36,9 @@ const defaultWrapWidth = 64;
33
36
  * @param {string} options.valueColour
34
37
  * Optional colour token for the value.
35
38
  * @param {number} options.width
36
- * Total row width used to calculate the available value width.
39
+ * Total row width used to calculate the available value width. When a
40
+ * long label leaves less than the minimum wrap width, the value renders
41
+ * unwrapped on one line instead of wrapping.
37
42
  * @param {boolean} options.wrap
38
43
  * Set to false to keep the value on one line instead of wrapping it.
39
44
  * @param {number} options.wrapWidth
@@ -54,16 +59,23 @@ export function row(label, value = "", options = {}) {
54
59
 
55
60
  const renderedValue = renderValue(value, options);
56
61
  const separator = options.separator ?? defaultSeparator;
62
+ const unwrappedRow = `${renderedLabel}${separator}${renderedValue}`;
57
63
 
58
64
  if (options.wrap === false) {
59
- return `${renderedLabel}${separator}${renderedValue}`;
65
+ return unwrappedRow;
66
+ }
67
+
68
+ const availableWrapWidth = Number.isFinite(options.width)
69
+ ? options.width - labelWidth - separator.length
70
+ : defaultWrapWidth;
71
+
72
+ const hasExplicitWrapWidth = options.wrapWidth !== undefined && options.wrapWidth !== null;
73
+
74
+ if (!hasExplicitWrapWidth && availableWrapWidth < minimumWrapWidth) {
75
+ return unwrappedRow;
60
76
  }
61
77
 
62
- const wrapWidth =
63
- options.wrapWidth ??
64
- (Number.isFinite(options.width)
65
- ? Math.max(1, options.width - labelWidth - separator.length)
66
- : defaultWrapWidth);
78
+ const wrapWidth = options.wrapWidth ?? availableWrapWidth;
67
79
 
68
80
  const valueLines = wrapText(value, wrapWidth);
69
81
  const continuationIndent = " ".repeat(labelWidth + separator.length);
@@ -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
  }