Skip to content

Commit 8a5af44

Browse files
as wide as the terminal
1 parent c303a63 commit 8a5af44

2 files changed

Lines changed: 218 additions & 31 deletions

File tree

src/unity-logging.ts

Lines changed: 159 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ interface PendingActionSummary {
4242
description: string;
4343
}
4444

45-
interface ActionTableSnapshot {
45+
export interface ActionTableSnapshot {
4646
completed: CompletedActionSummary[];
4747
pending: PendingActionSummary[];
4848
totalDurationMs: number;
@@ -54,7 +54,9 @@ interface FormattedTableOutput {
5454
lineCount: number;
5555
}
5656

57-
const MAX_DESCRIPTION_COLUMN_WIDTH = 64;
57+
const MAX_ERROR_DETAIL_COLUMN_WIDTH = 64;
58+
const MIN_DESCRIPTION_COLUMN_WIDTH = 16;
59+
const DEFAULT_TERMINAL_WIDTH = 120;
5860
const extendedPictographicRegex = /\p{Extended_Pictographic}/u;
5961

6062
class ActionTelemetryAccumulator {
@@ -212,19 +214,64 @@ function formatDuration(ms: number): string {
212214
}
213215

214216
function truncate(value: string, maxLength: number): string {
215-
if (value.length <= maxLength) {
217+
return truncateDisplay(value, maxLength);
218+
}
219+
220+
function truncateDisplay(value: string, maxWidth: number): string {
221+
if (maxWidth <= 0) {
222+
return '';
223+
}
224+
225+
if (stringDisplayWidth(value) <= maxWidth) {
216226
return value;
217227
}
218228

219-
if (maxLength <= 1) {
220-
return value.slice(0, maxLength);
229+
if (maxWidth <= 3) {
230+
let width = 0;
231+
let result = '';
232+
for (const symbol of [...value]) {
233+
const codePoint = symbol.codePointAt(0);
234+
if (codePoint === undefined) {
235+
continue;
236+
}
237+
238+
const charWidth = charDisplayWidth(codePoint);
239+
if (width + charWidth > maxWidth) {
240+
break;
241+
}
242+
width += charWidth;
243+
result += symbol;
244+
if (width >= maxWidth) {
245+
break;
246+
}
247+
}
248+
return result;
221249
}
222250

223-
if (maxLength <= 3) {
224-
return value.slice(0, maxLength);
251+
const ellipsis = '...';
252+
const ellipsisWidth = stringDisplayWidth(ellipsis);
253+
const targetWidth = Math.max(1, maxWidth - ellipsisWidth);
254+
let width = 0;
255+
let result = '';
256+
for (const symbol of [...value]) {
257+
const codePoint = symbol.codePointAt(0);
258+
if (codePoint === undefined) {
259+
continue;
260+
}
261+
262+
const charWidth = charDisplayWidth(codePoint);
263+
if (width + charWidth > targetWidth) {
264+
break;
265+
}
266+
width += charWidth;
267+
result += symbol;
268+
}
269+
270+
if (!result) {
271+
return ellipsis;
225272
}
226273

227-
return `${value.slice(0, maxLength - 3)}...`;
274+
return `${result}${ellipsis}`;
228275
}
229276

230277
export function stringDisplayWidth(value: string): number {
@@ -315,22 +362,78 @@ function isFullWidthCodePoint(codePoint: number): boolean {
315362
}
316363

317364
function padDisplay(value: string, width: number, alignment: 'left' | 'right' | 'center' = 'left'): string {
318-
const valueWidth = stringDisplayWidth(value);
319-
if (valueWidth >= width) {
320-
return value;
365+
if (width <= 0) {
366+
return '';
367+
}
368+
369+
let text = value;
370+
let valueWidth = stringDisplayWidth(text);
371+
if (valueWidth > width) {
372+
text = truncateDisplay(text, width);
373+
valueWidth = stringDisplayWidth(text);
374+
}
375+
376+
if (valueWidth === width) {
377+
return text;
321378
}
322379

323380
const padding = width - valueWidth;
324381
if (alignment === 'right') {
325-
return `${' '.repeat(padding)}${value}`;
382+
return `${' '.repeat(padding)}${text}`;
326383
}
327384

328385
if (alignment === 'center') {
329386
const left = Math.floor(padding / 2);
330-
return `${' '.repeat(left)}${value}${' '.repeat(padding - left)}`;
387+
return `${' '.repeat(left)}${text}${' '.repeat(padding - left)}`;
331388
}
332389

333-
return `${value}${' '.repeat(padding)}`;
390+
return `${text}${' '.repeat(padding)}`;
391+
}
392+
393+
function computeTablePadding(columnCount: number): number {
394+
return columnCount * 3 + 1;
395+
}
396+
397+
function computeTableWidth(columnWidths: Array<number | undefined>): number {
398+
let sum = 0;
399+
for (const width of columnWidths) {
400+
sum += width ?? 0;
401+
}
402+
return sum + computeTablePadding(columnWidths.length);
403+
}
404+
405+
function adjustDescriptionColumnWidth(columnWidths: Array<number | undefined>, descriptionColumnIndex: number, descriptionHeaderWidth: number, maxWidth?: number): Array<number | undefined> {
406+
if (maxWidth === undefined || !Number.isFinite(maxWidth) || maxWidth <= 0) {
407+
return columnWidths;
408+
}
409+
410+
const targetWidth = Math.floor(maxWidth);
411+
const totalWidth = computeTableWidth(columnWidths);
412+
const paddingWidth = computeTablePadding(columnWidths.length);
413+
414+
let sumWithoutDescription = 0;
415+
columnWidths.forEach((width, index) => {
416+
if (index === descriptionColumnIndex) {
417+
return;
418+
}
419+
sumWithoutDescription += width ?? 0;
420+
});
421+
const minDescriptionWidth = Math.max(descriptionHeaderWidth, MIN_DESCRIPTION_COLUMN_WIDTH);
422+
const currentDescriptionWidth = columnWidths[descriptionColumnIndex] ?? minDescriptionWidth;
423+
const availableWidthForDescription = targetWidth - paddingWidth - sumWithoutDescription;
424+
425+
if (totalWidth <= targetWidth) {
426+
columnWidths[descriptionColumnIndex] = Math.max(currentDescriptionWidth, availableWidthForDescription);
427+
return columnWidths;
428+
}
429+
430+
if (availableWidthForDescription >= minDescriptionWidth) {
431+
columnWidths[descriptionColumnIndex] = Math.max(minDescriptionWidth, Math.min(currentDescriptionWidth, availableWidthForDescription));
432+
return columnWidths;
433+
}
434+
435+
columnWidths[descriptionColumnIndex] = minDescriptionWidth;
436+
return columnWidths;
334437
}
335438

336439
function buildBorderLine(columnWidths: number[], left: string, middle: string, right: string): string {
@@ -343,7 +446,7 @@ function buildBorderLine(columnWidths: number[], left: string, middle: string, r
343446
return result;
344447
}
345448

346-
function formatActionTimelineTable(snapshot: ActionTableSnapshot): FormattedTableOutput | undefined {
449+
export function formatActionTimelineTable(snapshot: ActionTableSnapshot, options?: { maxWidth?: number }): FormattedTableOutput | undefined {
347450
const showErrorsColumn = snapshot.totalErrorCount > 0;
348451

349452
interface TableRow {
@@ -358,7 +461,7 @@ function formatActionTimelineTable(snapshot: ActionTableSnapshot): FormattedTabl
358461
snapshot.pending.forEach(action => {
359462
const row: TableRow = {
360463
status: '⏳',
361-
description: truncate(action.description || '', MAX_DESCRIPTION_COLUMN_WIDTH),
464+
description: action.description ?? '',
362465
durationText: '...',
363466
};
364467
if (showErrorsColumn) {
@@ -370,7 +473,7 @@ function formatActionTimelineTable(snapshot: ActionTableSnapshot): FormattedTabl
370473
snapshot.completed.forEach(action => {
371474
const row: TableRow = {
372475
status: action.errors.length > 0 ? '❌' : '✅',
373-
description: truncate(action.description || '', MAX_DESCRIPTION_COLUMN_WIDTH),
476+
description: action.description ?? '',
374477
durationText: formatDuration(action.durationMs),
375478
};
376479
if (showErrorsColumn) {
@@ -397,17 +500,29 @@ function formatActionTimelineTable(snapshot: ActionTableSnapshot): FormattedTabl
397500
const durationHeader = 'Duration';
398501
const errorsHeader = '# of Errors';
399502

400-
const statusWidth = Math.max(stringDisplayWidth(statusHeader), ...rows.map(row => stringDisplayWidth(row.status)), stringDisplayWidth(totalsRow.status));
401-
const descriptionWidth = Math.max(stringDisplayWidth(descriptionHeader), ...rows.map(row => stringDisplayWidth(row.description)), stringDisplayWidth(totalsRow.description));
402-
const durationWidth = Math.max(stringDisplayWidth(durationHeader), ...rows.map(row => stringDisplayWidth(row.durationText)), stringDisplayWidth(totalsRow.durationText));
403-
const errorsWidth = showErrorsColumn ? Math.max(stringDisplayWidth(errorsHeader), ...rows.map(row => stringDisplayWidth(row.errorsText ?? '')), stringDisplayWidth(totalsRow.errorsText ?? '')) : 0;
503+
let statusWidth = Math.max(stringDisplayWidth(statusHeader), ...rows.map(row => stringDisplayWidth(row.status)), stringDisplayWidth(totalsRow.status));
504+
let descriptionWidth = Math.max(stringDisplayWidth(descriptionHeader), ...rows.map(row => stringDisplayWidth(row.description)), stringDisplayWidth(totalsRow.description));
505+
let durationWidth = Math.max(stringDisplayWidth(durationHeader), ...rows.map(row => stringDisplayWidth(row.durationText)), stringDisplayWidth(totalsRow.durationText));
506+
let errorsWidth = showErrorsColumn ? Math.max(stringDisplayWidth(errorsHeader), ...rows.map(row => stringDisplayWidth(row.errorsText ?? '')), stringDisplayWidth(totalsRow.errorsText ?? '')) : 0;
404507

405-
const padStatus = (value: string): string => padDisplay(value, statusWidth, 'center');
508+
let columns: Array<number | undefined> = showErrorsColumn
509+
? [statusWidth, descriptionWidth, durationWidth, errorsWidth]
510+
: [statusWidth, descriptionWidth, durationWidth];
511+
512+
columns = adjustDescriptionColumnWidth(columns, 1, stringDisplayWidth(descriptionHeader), options?.maxWidth);
513+
statusWidth = columns[0] ?? statusWidth;
514+
descriptionWidth = columns[1] ?? descriptionWidth;
515+
durationWidth = columns[2] ?? durationWidth;
516+
if (showErrorsColumn) {
517+
errorsWidth = columns[3] ?? errorsWidth;
518+
}
406519

407-
const columns = showErrorsColumn
520+
const resolvedColumns = showErrorsColumn
408521
? [statusWidth, descriptionWidth, durationWidth, errorsWidth]
409522
: [statusWidth, descriptionWidth, durationWidth];
410523

524+
const padStatus = (value: string): string => padDisplay(value, statusWidth, 'center');
525+
411526
const formatRow = (row: TableRow): string => {
412527
let line = `│ ${padStatus(row.status)}${padDisplay(row.description, descriptionWidth)}${padDisplay(row.durationText, durationWidth, 'right')} │`;
413528
if (showErrorsColumn) {
@@ -416,13 +531,13 @@ function formatActionTimelineTable(snapshot: ActionTableSnapshot): FormattedTabl
416531
return line;
417532
};
418533

419-
const topBorder = buildBorderLine(columns, '┌', '┬', '┐');
534+
const topBorder = buildBorderLine(resolvedColumns, '┌', '┬', '┐');
420535
const headerRow = showErrorsColumn
421536
? `│ ${padStatus(statusHeader)}${padDisplay(descriptionHeader, descriptionWidth)}${padDisplay(durationHeader, durationWidth, 'right')}${padDisplay(errorsHeader, errorsWidth, 'right')} │`
422537
: `│ ${padStatus(statusHeader)}${padDisplay(descriptionHeader, descriptionWidth)}${padDisplay(durationHeader, durationWidth, 'right')} │`;
423-
const headerDivider = buildBorderLine(columns, '├', '┼', '┤');
424-
const totalsDivider = buildBorderLine(columns, '├', '┼', '┤');
425-
const bottomBorder = buildBorderLine(columns, '└', '┴', '┘');
538+
const headerDivider = buildBorderLine(resolvedColumns, '├', '┼', '┤');
539+
const totalsDivider = buildBorderLine(resolvedColumns, '├', '┼', '┤');
540+
const bottomBorder = buildBorderLine(resolvedColumns, '└', '┴', '┘');
426541

427542
let output = 'Unity Build Timeline\n';
428543
output += `${topBorder}\n`;
@@ -441,11 +556,11 @@ function formatActionTimelineTable(snapshot: ActionTableSnapshot): FormattedTabl
441556
const errorRows: Array<{ description: string; detail: string }> = [];
442557
snapshot.completed.forEach(action => {
443558
if (action.errors.length === 0) { return; }
444-
const description = truncate(action.description || '', MAX_DESCRIPTION_COLUMN_WIDTH);
559+
const description = truncate(action.description || '', MAX_ERROR_DETAIL_COLUMN_WIDTH);
445560
action.errors.forEach(err => {
446561
errorRows.push({
447562
description,
448-
detail: truncate(err, MAX_DESCRIPTION_COLUMN_WIDTH),
563+
detail: truncate(err, MAX_ERROR_DETAIL_COLUMN_WIDTH),
449564
});
450565
});
451566
});
@@ -512,7 +627,7 @@ class ActionTableRenderer {
512627
return;
513628
}
514629

515-
const formatted = formatActionTimelineTable(snapshot);
630+
const formatted = formatActionTimelineTable(snapshot, { maxWidth: this.getMaxWidth() });
516631
if (!formatted) {
517632
return;
518633
}
@@ -537,6 +652,20 @@ class ActionTableRenderer {
537652
process.stdout.write('\u001b[J');
538653
this.lastRenderLineCount = 0;
539654
}
655+
656+
private getMaxWidth(): number {
657+
const stdoutColumns = typeof process.stdout.columns === 'number' ? process.stdout.columns : undefined;
658+
if (stdoutColumns && stdoutColumns > 0) {
659+
return stdoutColumns;
660+
}
661+
662+
const envColumns = Number(process.env.COLUMNS);
663+
if (Number.isFinite(envColumns) && envColumns > 0) {
664+
return envColumns;
665+
}
666+
667+
return DEFAULT_TERMINAL_WIDTH;
668+
}
540669
}
541670

542671
function toNumeric(value: unknown): number | undefined {

tests/unity-logging.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { stringDisplayWidth } from '../src/unity-logging';
1+
import { type ActionTableSnapshot, formatActionTimelineTable, stringDisplayWidth } from '../src/unity-logging';
22

33
describe('stringDisplayWidth', () => {
44
it('treats ASCII characters as single width', () => {
@@ -15,3 +15,61 @@ describe('stringDisplayWidth', () => {
1515
expect(stringDisplayWidth('✔️')).toBe(2);
1616
});
1717
});
18+
19+
describe('formatActionTimelineTable', () => {
20+
const snapshot: ActionTableSnapshot = {
21+
completed: [
22+
{
23+
name: 'Build player',
24+
description: 'This is a very long description for building scenes and processing assets used to verify column sizing logic.',
25+
durationMs: 1500,
26+
errors: [],
27+
},
28+
],
29+
pending: [],
30+
totalDurationMs: 1500,
31+
totalErrorCount: 0,
32+
};
33+
34+
const collectTableLines = (text?: string): string[] => {
35+
if (!text) {
36+
return [];
37+
}
38+
return text.split('\n').filter(line => line.startsWith('┌') || line.startsWith('├') || line.startsWith('└') || line.startsWith('│'));
39+
};
40+
41+
const extractDescriptionCellWidth = (row: string): number => {
42+
const segments = row.split('│');
43+
const descriptionSegment = segments[2] ?? '';
44+
return Math.max(0, stringDisplayWidth(descriptionSegment) - 2);
45+
};
46+
47+
it('expands the description column to fill the terminal width when space allows', () => {
48+
const maxWidth = 200;
49+
const formatted = formatActionTimelineTable(snapshot, { maxWidth });
50+
expect(formatted).toBeDefined();
51+
const lines = collectTableLines(formatted?.text);
52+
expect(lines.length).toBeGreaterThan(0);
53+
lines.forEach(line => {
54+
expect(stringDisplayWidth(line)).toBe(maxWidth);
55+
});
56+
57+
const fullRow = lines.find(line => line.includes('This is'));
58+
expect(fullRow).toBeDefined();
59+
expect(fullRow).not.toContain('...');
60+
});
61+
62+
it('uses the minimum description width when the terminal is too narrow', () => {
63+
const maxWidth = 38; // Intentionally smaller than the minimum viable table width
64+
const formatted = formatActionTimelineTable(snapshot, { maxWidth });
65+
expect(formatted).toBeDefined();
66+
const lines = collectTableLines(formatted?.text);
67+
const buildRow = lines.find(line => line.includes('This is'));
68+
expect(buildRow).toBeDefined();
69+
70+
const descriptionWidth = extractDescriptionCellWidth(buildRow!);
71+
const expectedMinWidth = Math.max(stringDisplayWidth('Description'), 16);
72+
expect(descriptionWidth).toBe(expectedMinWidth);
73+
expect(buildRow).toContain('...');
74+
});
75+
});

0 commit comments

Comments
 (0)