From dbe7be835bb8c547e48db8c63d2c65308d752612 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 6 Jan 2025 13:33:39 -0700 Subject: [PATCH] feat: support fixed column widths (#71) * feat: support fixed column widths * chore: add example for fixed table width * fix: ignore padding when column width is fixed --- examples/fixed-width-columns.ts | 26 +++++++++++++++ examples/fixed-width-table.ts | 43 ++++++++++++++++++++++++ src/table.tsx | 42 +++++------------------ src/types.ts | 32 +++++++++++++++--- src/utils.ts | 59 ++++++++++++++++++++++++++++++--- 5 files changed, 160 insertions(+), 42 deletions(-) create mode 100644 examples/fixed-width-columns.ts create mode 100644 examples/fixed-width-table.ts diff --git a/examples/fixed-width-columns.ts b/examples/fixed-width-columns.ts new file mode 100644 index 0000000..7115f21 --- /dev/null +++ b/examples/fixed-width-columns.ts @@ -0,0 +1,26 @@ +import {printTable} from '../src/index.js' + +const data = [ + { + actual: 'This is a long string of the actual test results. '.repeat(50), + expected: 'This is a long string of the expected test results. '.repeat(10), + result: 'Passed', + test: 'Topic', + }, +] + +printTable({ + columns: [ + {key: 'test', width: '10%'}, + {key: 'result', width: '10%'}, + {key: 'expected', width: '40%'}, + {key: 'actual', width: '40%'}, + ], + data, + headerOptions: { + formatter: 'capitalCase', + }, + overflow: 'wrap', + title: 'Fixed-Width Columns', + titleOptions: {bold: true}, +}) diff --git a/examples/fixed-width-table.ts b/examples/fixed-width-table.ts new file mode 100644 index 0000000..3927624 --- /dev/null +++ b/examples/fixed-width-table.ts @@ -0,0 +1,43 @@ +import ansis from 'ansis' +import terminalLink from 'terminal-link' + +import {TableOptions, printTable} from '../src/index.js' + +const data = [ + { + age: 20, + employed: ansis.bold('true'), + id: terminalLink('36329', 'https://example.com/36329'), + name: 'Alice', + }, + { + age: 21, + employed: ansis.bold('true'), + id: terminalLink('49032', 'https://example.com/49032'), + name: ansis.dim('Bob'), + }, + { + age: 22, + employed: ansis.bold('false'), + id: terminalLink('51786', 'https://example.com/51786'), + name: 'Charlie', + }, +] + +const basic: TableOptions<(typeof data)[number]> = { + borderStyle: 'all', + columns: ['id', {key: 'name', name: 'First Name'}, 'age', 'employed'], + data, + headerOptions: { + bold: true, + color: '#905de8', + formatter: 'sentenceCase', + }, + sort: { + id: 'desc', + }, + verticalAlignment: 'center', + width: '100%', +} + +printTable(basic) diff --git a/src/table.tsx b/src/table.tsx index 4b880bd..4887546 100644 --- a/src/table.tsx +++ b/src/table.tsx @@ -25,6 +25,7 @@ import { } from './types.js' import { allKeysInCollection, + determineConfiguredWidth, determineWidthOfWrappedText, getColumns, getHeadings, @@ -34,44 +35,14 @@ import { sortData, } from './utils.js' -/** - * Determines the configured width based on the provided width value. - * If no width is provided, it returns the width of the current terminal. - * If the provided width is a percentage, it calculates the width based on the percentage of the terminal width. - * If the provided width is a number, it returns the provided width. - * If the calculated width is greater than the terminal width, it returns the terminal width. - * - * @param providedWidth - The width value provided. - * @returns The determined configured width. - */ -function determineConfiguredWidth( - providedWidth: number | Percentage | undefined, - columns = process.stdout.columns, -): number { - if (!providedWidth) return columns - - const num = - typeof providedWidth === 'string' && providedWidth.endsWith('%') - ? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns) - : typeof providedWidth === 'string' - ? Number.parseInt(providedWidth, 10) - : providedWidth - - if (num > columns) { - return columns - } - - return num -} - /** * Determine the width to use for the table. * * This allows us to use the minimum width required to display the table if the configured width is too small. */ -function determineWidthToUse(columns: Column[], configuredWidth: number): number { +function determineWidthToUse(columns: Column[], maxWidth: number, width: number | undefined): number { const tableWidth = columns.map((c) => c.width).reduce((a, b) => a + b, 0) + columns.length + 1 - return tableWidth < configuredWidth ? configuredWidth : tableWidth + return width ?? (tableWidth < maxWidth ? maxWidth : tableWidth) } function determineTruncatePosition(overflow: Overflow): 'start' | 'middle' | 'end' { @@ -210,6 +181,7 @@ function setup>(props: TableOptions) { sort, title, verticalAlignment = 'top', + width, } = props const headerOptions = noStyle ? {} : ({bold: true, color: 'blue', ...props.headerOptions} satisfies HeaderOptions) @@ -219,13 +191,15 @@ function setup>(props: TableOptions) { const titleOptions = noStyle ? {} : props.titleOptions const processedData = maybeStripAnsi(sortData(filter ? data.filter((row) => filter(row)) : data, sort), noStyle) + const tableWidth = width ? determineConfiguredWidth(width) : undefined const config: Config = { borderStyle, columns: props.columns ?? allKeysInCollection(data), data: processedData, headerOptions, horizontalAlignment, - maxWidth: determineConfiguredWidth(maxWidth), + maxWidth: tableWidth ?? determineConfiguredWidth(maxWidth), + width: tableWidth, overflow, padding, verticalAlignment, @@ -317,7 +291,7 @@ export function Table>(props: TableOptions) } = setup(props) return ( - + {title && {title}} {headerComponent({columns, data: {}, key: 'header'})} {headingComponent({columns, data: headings, key: 'heading'})} diff --git a/src/types.ts b/src/types.ts index c5624de..49f55c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export type CellProps = React.PropsWithChildren<{readonly column: number}> export type HorizontalAlignment = 'left' | 'right' | 'center' export type VerticalAlignment = 'top' | 'center' | 'bottom' +export type Percentage = `${number}%` export type ColumnProps = { /** @@ -27,11 +28,13 @@ export type ColumnProps = { * Vertical alignment of cell content. Overrides the vertical alignment set in the table. */ verticalAlignment?: VerticalAlignment + /** + * Set the width of the column. If not provided, it will default to the width of the content. + */ + width?: Percentage | number } export type AllColumnProps = {[K in keyof T]: ColumnProps}[keyof T] -export type Percentage = `${number}%` - type TextOptions = { color?: SupportedColor backgroundColor?: SupportedColor @@ -107,15 +110,35 @@ export type TableOptions> = { */ padding?: number /** - * Width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%'). + * Maximum width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%'). + * + * By default, the table will only take up as much space as it needs to fit the content. If it extends beyond the maximum width, + * it will wrap or truncate the content based on the `overflow` option. In other words, this property allows you to set the width + * at which wrapping or truncation occurs. * - * If not provided, it will default to the width of the terminal (determined by `process.stdout.columns`). + * If not provided, the maximum width will default to the terminal width. * * If you provide a number or percentage that is larger than the terminal width, it will default to the terminal width. * * If you provide a number or percentage that is too small to fit the table, it will default to the minimum width of the table. */ maxWidth?: Percentage | number + /** + * Exact width of the table. Can be a number (e.g. 80) or a percentage (e.g. '80%'). + * + * By default, the table will only take up as much space as it needs to fit the content. If you set the `width` option, the table will + * always take up that amount of space, regardless of the content. If the content is too large, it will wrap or truncate based on the + * `overflow` option. If it's too small, it will add empty space evenly across the columns. + * + * Setting this property will override the `maxWidth` option. + * + * If not provided, it will default to the natural width of the table. + * + * If you provide a number or percentage that is larger than the terminal width, it will default to the terminal width. + * + * If you provide a number or percentage that is too small to fit the table, it will default to the minimum width of the table. + */ + width?: Percentage | number /** * Overflow behavior for cells. Defaults to 'truncate'. */ @@ -197,6 +220,7 @@ export type Config = { borderStyle: BorderStyle horizontalAlignment: HorizontalAlignment verticalAlignment: VerticalAlignment + width: number | undefined } export type RowConfig = { diff --git a/src/utils.ts b/src/utils.ts index 746be64..21aed81 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,7 @@ import {orderBy} from 'natural-orderby' import {env} from 'node:process' import stripAnsi from 'strip-ansi' -import {Column, ColumnProps, Config, Sort} from './types.js' +import {Column, ColumnProps, Config, Percentage, Sort} from './types.js' /** * Intersperses a list of elements with another element. @@ -51,8 +51,38 @@ export function determineWidthOfWrappedText(text: string): number { return lines.reduce((max, line) => Math.max(max, line.length), 0) } +/** + * Determines the configured width based on the provided width value. + * If no width is provided, it returns the width of the current terminal. + * If the provided width is a percentage, it calculates the width based on the percentage of the terminal width. + * If the provided width is a number, it returns the provided width. + * If the calculated width is greater than the terminal width, it returns the terminal width. + * + * @param providedWidth - The width value provided. + * @returns The determined configured width. + */ +export function determineConfiguredWidth( + providedWidth: number | Percentage | undefined, + columns = process.stdout.columns, +): number { + if (!providedWidth) return columns + + const num = + typeof providedWidth === 'string' && providedWidth.endsWith('%') + ? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns) + : typeof providedWidth === 'string' + ? Number.parseInt(providedWidth, 10) + : providedWidth + + if (num > columns) { + return columns + } + + return num +} + export function getColumns>(config: Config, headings: Partial): Column[] { - const {columns, horizontalAlignment, maxWidth, overflow, verticalAlignment} = config + const {columns, horizontalAlignment, maxWidth, overflow, verticalAlignment, width} = config const widths: Column[] = columns.map((propsOrKey) => { const props: ColumnProps = typeof propsOrKey === 'object' ? propsOrKey : {key: propsOrKey} @@ -64,11 +94,17 @@ export function getColumns>(config: Config, const value = data[key] if (value === undefined || value === null) return 0 + // Some terminals don't play nicely with zero-width characters, so we replace them with spaces. + // https://github.com/sindresorhus/terminal-link/issues/18 + // https://github.com/Shopify/cli/pull/995 return determineWidthOfWrappedText(stripAnsi(String(value).replaceAll('​', ' '))) }) const header = String(headings[key]).length - const width = Math.max(...data, header) + padding * 2 + // If a column width is provided, use that. Otherwise, use the width of the largest cell in the column. + const columnWidth = props.width + ? determineConfiguredWidth(props.width, width ?? maxWidth) + : Math.max(...data, header) + padding * 2 return { column: key, @@ -77,7 +113,7 @@ export function getColumns>(config: Config, overflow: props.overflow ?? overflow, padding, verticalAlignment: props.verticalAlignment ?? verticalAlignment, - width, + width: columnWidth, } }) @@ -112,6 +148,21 @@ export function getColumns>(config: Config, seen.clear() // At most, reduce the width to the padding + 3 reduceColumnWidths((col) => col.padding * 2 + 3) + + // If the table width was provided AND it's greater than the calculated table width, expand the columns to fill the width + if (width && width > tableWidth) { + const extraWidth = width - tableWidth + const extraWidthPerColumn = Math.floor(extraWidth / widths.length) + + for (const w of widths) { + w.width += extraWidthPerColumn + // if it's the last column, add all the remaining width + if (w === widths.at(-1)) { + w.width += extraWidth - extraWidthPerColumn * widths.length + } + } + } + return widths }