From e74661706127cf531186642c9272b8dddbf52f6b Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:26:13 +0100 Subject: [PATCH] fix(cli): align table columns by display width --- .changeset/table-display-width.md | 5 +++++ bun.lock | 1 + package.json | 1 + src/core/displayWidth.test.ts | 22 ++++++++++++++++++++++ src/core/displayWidth.ts | 6 ++++++ src/core/renderTable.test.ts | 22 ++++++++++++++++++++++ src/core/renderTable.ts | 8 +++++--- 7 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 .changeset/table-display-width.md create mode 100644 src/core/displayWidth.test.ts create mode 100644 src/core/displayWidth.ts diff --git a/.changeset/table-display-width.md b/.changeset/table-display-width.md new file mode 100644 index 000000000..8bc9edfea --- /dev/null +++ b/.changeset/table-display-width.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": patch +--- + +Align table columns by terminal display width for Unicode and styled values. diff --git a/bun.lock b/bun.lock index cfe8348e1..ae3d2d2ba 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@qawolf/testkit": "1.2.1", "commander": "14.0.3", "env-paths": "4.0.0", + "fast-string-width": "3.0.2", "picomatch": "4.0.4", "pino": "10.3.1", "playwright": "1.62.0", diff --git a/package.json b/package.json index ce159c291..bd703bbe6 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@qawolf/testkit": "1.2.1", "commander": "14.0.3", "env-paths": "4.0.0", + "fast-string-width": "3.0.2", "picomatch": "4.0.4", "pino": "10.3.1", "playwright": "1.62.0", diff --git a/src/core/displayWidth.test.ts b/src/core/displayWidth.test.ts new file mode 100644 index 000000000..890a5f5f2 --- /dev/null +++ b/src/core/displayWidth.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; + +import { displayWidth, padColumns } from "./displayWidth.js"; + +describe("display columns", () => { + it.each([ + ["plain", 5], + ["登录", 4], + ["café", 4], + ["👩🏽‍💻", 2], + ["🇬🇧", 2], + ["☕", 2], + ["✈️", 2], + ["\x1b[2m登录\x1b[22m", 4], + ])("measures %s as %i columns", (text, width) => { + expect(displayWidth(text)).toBe(width); + }); + + it("pads by display columns", () => { + expect(padColumns("登录", 6)).toBe("登录 "); + }); +}); diff --git a/src/core/displayWidth.ts b/src/core/displayWidth.ts new file mode 100644 index 000000000..c25580e4d --- /dev/null +++ b/src/core/displayWidth.ts @@ -0,0 +1,6 @@ +import stringWidth from "fast-string-width"; + +export const displayWidth = (text: string): number => stringWidth(text); + +export const padColumns = (text: string, width: number): string => + `${text}${" ".repeat(Math.max(0, width - displayWidth(text)))}`; diff --git a/src/core/renderTable.test.ts b/src/core/renderTable.test.ts index 6386e088d..20c287bea 100644 --- a/src/core/renderTable.test.ts +++ b/src/core/renderTable.test.ts @@ -10,6 +10,28 @@ const columns: readonly TableColumn[] = [ ]; describe("renderTable", () => { + it("aligns columns containing wide and combining characters", () => { + const table = renderTable({ + boldHeader: false, + columns, + rows: [ + { family: "wide", id: "登录" }, + { family: "accent", id: "e\u0301" }, + { family: "emoji", id: "👩‍💻" }, + ], + }); + + expect(table).toBe( + [ + "id family", + "登录 wide", + "e\u0301 accent", + "👩‍💻 emoji", + "", + ].join("\n"), + ); + }); + it("pads every column to its widest cell and trims the last one", () => { const table = renderTable({ boldHeader: false, diff --git a/src/core/renderTable.ts b/src/core/renderTable.ts index d56fdb541..1810a6a26 100644 --- a/src/core/renderTable.ts +++ b/src/core/renderTable.ts @@ -1,3 +1,5 @@ +import { displayWidth, padColumns } from "./displayWidth.js"; + export type TableColumn = { readonly header: string; readonly value: (row: Row) => string; @@ -15,14 +17,14 @@ export function renderTable(options: { const measured = columns.map((column) => ({ column, width: Math.max( - column.header.length, - ...rows.map((row) => column.value(row).length), + displayWidth(column.header), + ...rows.map((row) => displayWidth(column.value(row))), ), })); const renderRow = (cell: (column: TableColumn) => string): string => measured - .map(({ column, width }) => cell(column).padEnd(width)) + .map(({ column, width }) => padColumns(cell(column), width)) .join(" ") .trimEnd();