diff --git a/src/Install/Cli/DisplayHelper.php b/src/Install/Cli/DisplayHelper.php index 6dde87b..78457ed 100644 --- a/src/Install/Cli/DisplayHelper.php +++ b/src/Install/Cli/DisplayHelper.php @@ -4,183 +4,184 @@ namespace Laravel\Boost\Install\Cli; +use InvalidArgumentException; + class DisplayHelper { + private const UNICODE_TOP_LEFT = '╭'; + private const UNICODE_TOP_RIGHT = '╮'; + private const UNICODE_BOTTOM_LEFT = '╰'; + private const UNICODE_BOTTOM_RIGHT = '╯'; + private const UNICODE_HORIZONTAL = '─'; + private const UNICODE_VERTICAL = '│'; + private const UNICODE_CROSS = '┼'; + private const UNICODE_TOP_T = '┬'; + private const UNICODE_BOTTOM_T = '┴'; + private const UNICODE_LEFT_T = '├'; + private const UNICODE_RIGHT_T = '┤'; + + private const BORDER_TOP = 'top'; + private const BORDER_MIDDLE = 'middle'; + private const BORDER_BOTTOM = 'bottom'; + + private const CELL_PADDING = 2; + private const GRID_CELL_PADDING = 4; + private const ANSI_BOLD = "\e[1m"; + private const ANSI_RESET = "\e[0m"; + private const SPACE = ' '; + /** * @param array> $data */ - public static function datatable(array $data, int $cols = 80): void + public static function datatable(array $data, int $maxWidth = 80): void { - if (empty($data)) { + if (! $data) { return; } - // Calculate column widths - $columnWidths = []; - foreach ($data as $row) { - $colIndex = 0; - foreach ($row as $cell) { - $length = mb_strlen((string) $cell); - if (! isset($columnWidths[$colIndex]) || $length > $columnWidths[$colIndex]) { - $columnWidths[$colIndex] = $length; - } - $colIndex++; - } - } + $columnWidths = self::calculateColumnWidths($data); + $columnWidths = array_map(fn ($width) => $width + self::CELL_PADDING, $columnWidths); - // Add padding - $columnWidths = array_map(fn ($width) => $width + 2, $columnWidths); - - // Unicode box drawing characters - $topLeft = '╭'; - $topRight = '╮'; - $bottomLeft = '╰'; - $bottomRight = '╯'; - $horizontal = '─'; - $vertical = '│'; - $cross = '┼'; - $topT = '┬'; - $bottomT = '┴'; - $leftT = '├'; - $rightT = '┤'; - - // Draw top border - $topBorder = $topLeft; - foreach ($columnWidths as $index => $width) { - $topBorder .= str_repeat($horizontal, $width); - if ($index < count($columnWidths) - 1) { - $topBorder .= $topT; - } - } - $topBorder .= $topRight; - echo $topBorder.PHP_EOL; + [$leftChar, $rightChar, $joinChar] = self::getBorderChars(self::BORDER_TOP); + echo self::buildBorder($columnWidths, $leftChar, $rightChar, $joinChar).PHP_EOL; - // Draw rows $rowCount = 0; foreach ($data as $row) { - $line = $vertical; - $colIndex = 0; - foreach ($row as $cell) { - $cellStr = ($colIndex === 0) ? "\e[1m".$cell."\e[0m" : $cell; - $padding = $columnWidths[$colIndex] - mb_strlen($cell); - $line .= ' '.$cellStr.str_repeat(' ', $padding - 1).$vertical; - $colIndex++; - } - echo $line.PHP_EOL; + echo self::buildDataRow($row, $columnWidths).PHP_EOL; - // Draw separator between rows (except after last row) if ($rowCount < count($data) - 1) { - $separator = $leftT; - foreach ($columnWidths as $index => $width) { - $separator .= str_repeat($horizontal, $width); - if ($index < count($columnWidths) - 1) { - $separator .= $cross; - } - } - $separator .= $rightT; - echo $separator.PHP_EOL; + [$leftChar, $rightChar, $joinChar] = self::getBorderChars(self::BORDER_MIDDLE); + echo self::buildBorder($columnWidths, $leftChar, $rightChar, $joinChar).PHP_EOL; } $rowCount++; } - // Draw bottom border - $bottomBorder = $bottomLeft; - foreach ($columnWidths as $index => $width) { - $bottomBorder .= str_repeat($horizontal, $width); - if ($index < count($columnWidths) - 1) { - $bottomBorder .= $bottomT; - } - } - $bottomBorder .= $bottomRight; - echo $bottomBorder.PHP_EOL; + [$leftChar, $rightChar, $joinChar] = self::getBorderChars(self::BORDER_BOTTOM); + echo self::buildBorder($columnWidths, $leftChar, $rightChar, $joinChar).PHP_EOL; } /** * @param array $items */ - public static function grid(array $items, int $cols = 80): void + public static function grid(array $items, int $maxWidth = 80): void { if (empty($items)) { return; } - $cols -= 2; - // Calculate the longest item length + $maxWidth -= 2; // account for grid margins $maxItemLength = max(array_map('mb_strlen', $items)); - - // Add padding (2 spaces on each side + 1 for border) - $cellWidth = $maxItemLength + 4; - - // Calculate how many cells can fit per row - $cellsPerRow = max(1, (int) floor(($cols - 1) / ($cellWidth + 1))); - - // Unicode box drawing characters - $topLeft = '╭'; - $topRight = '╮'; - $bottomLeft = '╰'; - $bottomRight = '╯'; - $horizontal = '─'; - $vertical = '│'; - $cross = '┼'; - $topT = '┬'; - $bottomT = '┴'; - $leftT = '├'; - $rightT = '┤'; - - // Group items into rows + $cellWidth = $maxItemLength + self::GRID_CELL_PADDING; + $cellsPerRow = max(1, (int) floor(($maxWidth - 1) / ($cellWidth + 1))); $rows = array_chunk($items, $cellsPerRow); - // Draw top border - $topBorder = $topLeft; - for ($i = 0; $i < $cellsPerRow; $i++) { - $topBorder .= str_repeat($horizontal, $cellWidth); - if ($i < $cellsPerRow - 1) { - $topBorder .= $topT; - } - } - $topBorder .= $topRight; - echo ' '.$topBorder.PHP_EOL; + $cellWidths = array_fill(0, $cellsPerRow, $cellWidth); + + [$leftChar, $rightChar, $joinChar] = self::getBorderChars(self::BORDER_TOP); + echo self::SPACE.self::buildBorder($cellWidths, $leftChar, $rightChar, $joinChar).PHP_EOL; - // Draw rows $rowCount = 0; foreach ($rows as $row) { - $line = $vertical; - for ($i = 0; $i < $cellsPerRow; $i++) { - if (isset($row[$i])) { - $item = $row[$i]; - $padding = $cellWidth - mb_strlen($item) - 2; - $line .= ' '.$item.str_repeat(' ', $padding + 1).$vertical; - } else { - // Empty cell - $line .= str_repeat(' ', $cellWidth).$vertical; - } - } - echo ' '.$line.PHP_EOL; + echo self::SPACE.self::buildGridRow($row, $cellWidth, $cellsPerRow).PHP_EOL; - // Draw separator between rows (except after last row) if ($rowCount < count($rows) - 1) { - $separator = $leftT; - for ($i = 0; $i < $cellsPerRow; $i++) { - $separator .= str_repeat($horizontal, $cellWidth); - if ($i < $cellsPerRow - 1) { - $separator .= $cross; - } - } - $separator .= $rightT; - echo ' '.$separator.PHP_EOL; + [$leftChar, $rightChar, $joinChar] = self::getBorderChars(self::BORDER_MIDDLE); + echo self::SPACE.self::buildBorder($cellWidths, $leftChar, $rightChar, $joinChar).PHP_EOL; } $rowCount++; } - // Draw bottom border - $bottomBorder = $bottomLeft; - for ($i = 0; $i < $cellsPerRow; $i++) { - $bottomBorder .= str_repeat($horizontal, $cellWidth); - if ($i < $cellsPerRow - 1) { - $bottomBorder .= $bottomT; + [$leftChar, $rightChar, $joinChar] = self::getBorderChars(self::BORDER_BOTTOM); + echo self::SPACE.self::buildBorder($cellWidths, $leftChar, $rightChar, $joinChar).PHP_EOL; + } + + private static function getBorderChars(string $type): array + { + return match($type) { + self::BORDER_TOP => [self::UNICODE_TOP_LEFT, self::UNICODE_TOP_RIGHT, self::UNICODE_TOP_T], + self::BORDER_MIDDLE => [self::UNICODE_LEFT_T, self::UNICODE_RIGHT_T, self::UNICODE_CROSS], + self::BORDER_BOTTOM => [self::UNICODE_BOTTOM_LEFT, self::UNICODE_BOTTOM_RIGHT, self::UNICODE_BOTTOM_T], + default => throw new InvalidArgumentException('Border type should be valid'), + }; + } + + /** + * @param array> $data + * @return array + */ + private static function calculateColumnWidths(array $data): array + { + $columnWidths = []; + foreach ($data as $row) { + foreach ($row as $colIndex => $cell) { + $length = mb_strlen((string) $cell); + $columnWidths[$colIndex] = max($columnWidths[$colIndex] ?? 0, $length); + } + } + + return $columnWidths; + } + + /** + * @param array $widths + */ + private static function buildBorder(array $widths, string $leftChar, string $rightChar, string $joinChar): string + { + $border = $leftChar; + foreach ($widths as $index => $width) { + $border .= str_repeat(self::UNICODE_HORIZONTAL, $width); + if ($index < count($widths) - 1) { + $border .= $joinChar; } } - $bottomBorder .= $bottomRight; - echo ' '.$bottomBorder.PHP_EOL; + $border .= $rightChar; + + return $border; + } + + /** + * @param array $row + * @param array $columnWidths + */ + private static function buildDataRow(array $row, array $columnWidths): string + { + $line = self::UNICODE_VERTICAL; + $colIndex = 0; + foreach ($row as $cell) { + $cellStr = ($colIndex === 0) ? self::ANSI_BOLD.$cell.self::ANSI_RESET : $cell; + $padding = $columnWidths[$colIndex] - mb_strlen((string) $cell); + $line .= self::SPACE.$cellStr.str_repeat(self::SPACE, $padding - 1).self::UNICODE_VERTICAL; + $colIndex++; + } + + return $line; + } + + /** + * @param array $row + */ + private static function buildGridRow(array $row, int $cellWidth, int $cellsPerRow): string + { + $line = self::UNICODE_VERTICAL; + + $cells = array_map( + fn ($index) => self::formatGridCell($row[$index] ?? '', $cellWidth), + range(0, $cellsPerRow - 1) + ); + + $line .= implode(self::UNICODE_VERTICAL, $cells).self::UNICODE_VERTICAL; + + return $line; + } + + private static function formatGridCell(string $item, int $cellWidth): string + { + if (! $item) { + return str_repeat(self::SPACE, $cellWidth); + } + + $padding = $cellWidth - mb_strlen($item) - 2; + + return self::SPACE.$item.str_repeat(self::SPACE, $padding + 1); } } diff --git a/tests/Unit/Install/Cli/DisplayHelperTest.php b/tests/Unit/Install/Cli/DisplayHelperTest.php new file mode 100644 index 0000000..b877126 --- /dev/null +++ b/tests/Unit/Install/Cli/DisplayHelperTest.php @@ -0,0 +1,172 @@ +toBe(''); + }); + + it('displays a simple single row table', function () { + ob_start(); + DisplayHelper::datatable([ + ['Name', 'Age'], + ]); + $output = ob_get_clean(); + + expect($output)->toContain('Name') + ->and($output)->toContain('Age') + ->and($output)->toContain('╭') + ->and($output)->toContain('╮') + ->and($output)->toContain('╰') + ->and($output)->toContain('╯'); + }); + + it('displays a multi-row table', function () { + ob_start(); + DisplayHelper::datatable([ + ['Name', 'Age', 'City'], + ['John', '25', 'New York'], + ['Jane', '30', 'London'], + ]); + $output = ob_get_clean(); + + expect($output)->toContain('Name') + ->and($output)->toContain('John') + ->and($output)->toContain('Jane') + ->and($output)->toContain('├') + ->and($output)->toContain('┤') + ->and($output)->toContain('┼'); + }); + + it('handles different data types in cells', function () { + ob_start(); + DisplayHelper::datatable([ + ['String', 'Number', 'Boolean'], + ['text', '123', 'true'], + ['another', '456', 'false'], + ]); + $output = ob_get_clean(); + + expect($output)->toContain('text') + ->and($output)->toContain('123') + ->and($output)->toContain('true') + ->and($output)->toContain('another') + ->and($output)->toContain('456'); + }); + + it('applies bold formatting to first column', function () { + ob_start(); + DisplayHelper::datatable([ + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ]); + $output = ob_get_clean(); + + expect($output)->toContain("\e[1mHeader1\e[0m") + ->and($output)->toContain("\e[1mValue1\e[0m") + ->and($output)->not->toContain("\e[1mHeader2\e[0m"); + }); + + it('handles unicode characters properly', function () { + ob_start(); + DisplayHelper::datatable([ + ['名前', 'Émile'], + ['測試', 'café'], + ]); + $output = ob_get_clean(); + + expect($output)->toContain('名前') + ->and($output)->toContain('Émile') + ->and($output)->toContain('測試') + ->and($output)->toContain('café'); + }); + }); + + describe('grid test', function () { + it('returns early for empty items', function () { + ob_start(); + DisplayHelper::grid([]); + $output = ob_get_clean(); + + expect($output)->toBe(''); + }); + + it('displays single item grid', function () { + ob_start(); + DisplayHelper::grid(['Item1']); + $output = ob_get_clean(); + + expect($output)->toContain('Item1') + ->and($output)->toContain('╭') + ->and($output)->toContain('╮') + ->and($output)->toContain('╰') + ->and($output)->toContain('╯'); + }); + + it('displays multiple items in grid', function () { + ob_start(); + DisplayHelper::grid(['Item1', 'Item2', 'Item3', 'Item4']); + $output = ob_get_clean(); + + expect($output)->toContain('Item1') + ->and($output)->toContain('Item2') + ->and($output)->toContain('Item3') + ->and($output)->toContain('Item4'); + }); + + it('handles items of different lengths', function () { + ob_start(); + DisplayHelper::grid(['Short', 'Very Long Item Name', 'Med']); + $output = ob_get_clean(); + + expect($output)->toContain('Short') + ->and($output)->toContain('Very Long Item Name') + ->and($output)->toContain('Med'); + }); + + it('respects column width parameter', function () { + ob_start(); + DisplayHelper::grid(['Item1', 'Item2'], 40); + $output = ob_get_clean(); + + expect($output)->toContain('Item1') + ->and($output)->toContain('Item2'); + }); + + it('handles unicode characters in grid', function () { + ob_start(); + DisplayHelper::grid(['測試', 'café', '🚀']); + $output = ob_get_clean(); + + expect($output)->toContain('測試') + ->and($output)->toContain('café') + ->and($output)->toContain('🚀'); + }); + + it('fills empty cells when items do not fill complete rows', function () { + ob_start(); + DisplayHelper::grid(['Item1', 'Item2', 'Item3']); + $output = ob_get_clean(); + + $lines = explode("\n", $output); + $dataLine = ''; + foreach ($lines as $line) { + if (str_contains($line, 'Item1')) { + $dataLine = $line; + break; + } + } + + expect($dataLine)->toContain('│') + ->and(substr_count($dataLine, '│'))->toBeGreaterThan(2); + }); + }); +});