bktide
Version:
Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users
183 lines • 6.86 kB
JavaScript
/**
* Responsive table rendering with smart column handling
*/
import { termWidth, stripAnsi, truncate } from './width.js';
/**
* Calculate optimal column widths based on content and terminal width
*/
function calculateOptimalWidths(rows, columns, availableWidth) {
const numColumns = columns.length;
const visibleColumns = new Array(numColumns).fill(true);
// Calculate content widths (max width needed for each column)
const contentWidths = columns.map((col, i) => {
const maxContentWidth = Math.max(stripAnsi(col.header).length, ...rows.map(row => stripAnsi(row[i] ?? '').length));
return {
min: col.minWidth ?? 3,
max: col.maxWidth ?? maxContentWidth,
content: maxContentWidth,
priority: col.priority ?? 0
};
});
// Start with ideal widths (content width capped by max)
let widths = contentWidths.map(cw => Math.min(cw.content, cw.max));
let totalWidth = widths.reduce((a, b) => a + b, 0);
// If we fit, return as-is
if (totalWidth <= availableWidth) {
return { widths, visibleColumns };
}
// Try to shrink columns proportionally
const shrinkable = contentWidths.map((cw, i) => widths[i] - cw.min);
const totalShrinkable = shrinkable.reduce((a, b) => a + b, 0);
if (totalShrinkable > 0) {
const needToShrink = totalWidth - availableWidth;
if (needToShrink <= totalShrinkable) {
// We can fit by shrinking
const shrinkRatio = needToShrink / totalShrinkable;
widths = widths.map((w, i) => {
const shrinkAmount = Math.floor(shrinkable[i] * shrinkRatio);
return w - shrinkAmount;
});
return { widths, visibleColumns };
}
}
// Still doesn't fit - hide low priority columns
const sortedByPriority = columns
.map((col, i) => ({ col, index: i, priority: col.priority ?? 0 }))
.sort((a, b) => a.priority - b.priority);
// Start with minimum widths
widths = contentWidths.map(cw => cw.min);
totalWidth = widths.reduce((a, b) => a + b, 0);
// Hide columns starting with lowest priority until we fit
for (const { index } of sortedByPriority) {
if (totalWidth <= availableWidth)
break;
visibleColumns[index] = false;
totalWidth -= widths[index];
widths[index] = 0;
}
// Expand remaining columns if we have extra space
const visibleIndices = visibleColumns
.map((v, i) => v ? i : -1)
.filter(i => i >= 0);
if (visibleIndices.length > 0 && totalWidth < availableWidth) {
const extraSpace = availableWidth - totalWidth;
const spacePerColumn = Math.floor(extraSpace / visibleIndices.length);
for (const i of visibleIndices) {
widths[i] = Math.min(widths[i] + spacePerColumn, contentWidths[i].max);
}
}
return { widths, visibleColumns };
}
/**
* Align text within a given width
*/
function alignText(text, width, align = 'left') {
const visibleLength = stripAnsi(text).length;
if (visibleLength >= width) {
return text; // No room for alignment
}
const padding = width - visibleLength;
switch (align) {
case 'right':
return ' '.repeat(padding) + text;
case 'center':
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
default: // left
return text + ' '.repeat(padding);
}
}
/**
* Format a single cell with proper width and alignment
*/
function formatCell(content, width, config) {
if (width === 0)
return ''; // Hidden column
const visibleLength = stripAnsi(content).length;
// Truncate or pad as needed
let formatted;
if (visibleLength > width) {
if (config.truncate !== false) { // Default to truncate
formatted = truncate(content, width);
}
else {
// For wrapping, just take first line for now
formatted = content.substring(0, width);
}
}
else {
formatted = content;
}
// Apply alignment
return alignText(formatted, width, config.align);
}
/**
* Render a responsive table that adapts to terminal width
*/
export function renderResponsiveTable(headers, rows, options) {
const separator = options?.separator ?? ' ';
const width = options?.width ?? termWidth();
// Create default column configs if not provided
const columns = options?.columns ?? headers.map(h => ({
header: h,
priority: 0,
minWidth: 3,
truncate: true
}));
// Combine headers with data for width calculation
const allRows = [headers, ...rows];
// Account for separators
const numSeparators = columns.length - 1;
const availableWidth = Math.max(columns.length * 3, width - (separator.length * numSeparators));
// Calculate optimal widths
const { widths, visibleColumns } = options?.forceAllColumns
? { widths: columns.map((_, i) => Math.max(...allRows.map(r => stripAnsi(r[i] ?? '').length))), visibleColumns: new Array(columns.length).fill(true) }
: calculateOptimalWidths(rows, columns, availableWidth);
// Filter to visible columns
const visibleIndices = visibleColumns
.map((v, i) => v ? i : -1)
.filter(i => i >= 0);
if (visibleIndices.length === 0) {
// Emergency: show at least one column
visibleIndices.push(0);
visibleColumns[0] = true;
widths[0] = availableWidth;
}
// Format output
const lines = [];
// Format all rows
for (const row of allRows) {
const formattedCells = visibleIndices.map(i => formatCell(row[i] ?? '', widths[i], columns[i]));
lines.push(formattedCells.join(separator).trimEnd());
}
return lines.join('\n');
}
/**
* Responsive list rendering for narrow terminals
*/
export function renderResponsiveList(items, options) {
const width = options?.width ?? termWidth();
const labelWidth = options?.labelWidth ?? Math.min(20, Math.floor(width * 0.3));
const lines = [];
for (const item of items) {
const label = truncate(item.label, labelWidth).padEnd(labelWidth);
const valueWidth = Math.max(10, width - labelWidth - 2);
const value = truncate(item.value, valueWidth);
lines.push(`${label} ${value}`);
}
return lines.join('\n');
}
/**
* Detect if terminal is narrow
*/
export function isNarrowTerminal(threshold = 80) {
return termWidth() < threshold;
}
/**
* Detect if terminal is very narrow (mobile-like)
*/
export function isMobileTerminal(threshold = 50) {
return termWidth() < threshold;
}
//# sourceMappingURL=responsive-table.js.map