laravel / prompts

Beautiful and user-friendly forms for your command-line PHP applications.
https://laravel.com/docs/prompts
MIT License
533 stars 94 forks source link

Add a `table` renderer #68

Closed joetannenbaum closed 1 year ago

joetannenbaum commented 1 year ago

This PR adds a table helper that renders a table matching the style of the rest of the package components.

I'm not sure if adding more non-interactive output is on the roadmap for the package, but I've had many use cases for this and it's nice that the output is cohesive.

It's a simple wrapper for the existing Symfony console table component:

table(
    ['Name', 'Twitter'],
    [
        ['Taylor Otwell', '@taylorotwell'],
        ['Dries Vints', '@driesvints'],
        ['James Brooks', '@jbrooksuk'],
        ['Nuno Maduro', '@enunomaduro'],
        ['Mior Muhammad Zaki', '@crynobone'],
        ['Jess Archer', '@jessarchercodes'],
        ['Guus Leeuw', '@phpguus'],
        ['Tim MacDonald', '@timacdonald87'],
        ['Joe Dixon', '@_joedixon'],
    ],
);

In context it looks like this (I played with both cyan and dim for the table headers and thought dim looked better in the end):

CleanShot 2023-09-13 at 20 43 10@2x
jessarcher commented 1 year ago

Hey @joetannenbaum, thanks for this!

I think this is a nice addition and makes sense to provide a nice way to create tables consistent with the spacing and theme of Prompts.

There was a bug when using colspans because Symfony doesn't strip the invisible formatting characters from the border format before calculating widths. I've tweaked how the border color is applied, and it seems to work well:

Before After
image image

I've also removed the extra right padding from the last column and changed the border color from "dim" to "gray" to match the border color of the other boxes (these didn't look different with the terminal theme in your screenshot).

I've also fixed some weirdness when no headers were passed:

Before After
image image

And I've also made it so that when only the first argument is provided, they will become the rows.

joetannenbaum commented 1 year ago

Thanks @jessarcher! I feel like I created more work for you than solved something with this one, I'll keep an eye out for more edge cases and alternate scenarios in my next PR.

Love this package, I'll keep thinking of ways to contribute!

jessarcher commented 1 year ago

All good @joetannenbaum! It was fun :)

I also played around with setting a minimum width on the table to match the other boxes:

image

The code is gnarly, though - I had to calculate the width of each column and then divide the remaining space evenly between them and assign any remainder to the first column. It gets especially tricky with colspans where the width gets divided amongst the columns it spans. Might stash that for now and come back to it if it stays in my head.

joetannenbaum commented 1 year ago

Ooooooh I love the way that looks though! Great idea. Might tinker with it myself and see if I can get anywhere.

jessarcher commented 1 year ago

Here's the code I wrote if you're interested, as I'm not going to PR it. I'm sure it could be simplified, but I'm still unsure if it's worth the complexity and potential breaking scenarios I haven't considered (like table separates, row spans, etc.)

It also has to do some weird stuff to align with how Symfony determines the col widths when there are col spans. It would be much nicer to handle this in Symfony rather than here, or at least publically expose the methods they're using so we don't need to duplicate it with potential inconsistencies and future breakages that may occur if they change things and don't consider it a breaking change because it's not part of the public API.

diff --git a/src/Themes/Default/TableRenderer.php b/src/Themes/Default/TableRenderer.php
index 185f450..623f9d0 100644
--- a/src/Themes/Default/TableRenderer.php
+++ b/src/Themes/Default/TableRenderer.php
@@ -5,6 +5,7 @@
 use Laravel\Prompts\Output\BufferedConsoleOutput;
 use Laravel\Prompts\Table;
 use Symfony\Component\Console\Helper\Table as SymfonyTable;
+use Symfony\Component\Console\Helper\TableCell;
 use Symfony\Component\Console\Helper\TableStyle;

 class TableRenderer extends Renderer
@@ -29,6 +30,7 @@ public function __invoke(Table $table): string
         $buffered = new BufferedConsoleOutput();

         (new SymfonyTable($buffered))
+            ->setColumnWidths($this->minWidths($table))
             ->setHeaders($table->headers)
             ->setRows($table->rows)
             ->setStyle($tableStyle)
@@ -39,4 +41,45 @@ public function __invoke(Table $table): string

         return $this;
     }
+
+    protected function minWidths(Table $table): array
+    {
+        $rows = collect([
+            ...(! is_array($table->headers[0]) ? [$table->headers] : $table->headers),
+            ...$table->rows,
+        ]);
+
+        $rowColWidths = $rows->map(fn ($row) => collect($row)->reduce(function (array $carry, $cell) {
+            return [
+                ...$carry,
+                ...match(true) {
+                    $cell instanceof TableCell && mb_strwidth($cell) => array_pad(array_map(mb_strwidth(...), mb_str_split($cell, ceil(mb_strwidth($cell) / $cell->getColspan()))), $cell->getColspan(), 0),
+                    $cell instanceof TableCell => array_fill(0, $cell->getColspan(), 0),
+                    default => [mb_strwidth($cell)],
+                },
+            ];
+        }, []));
+
+        $colWidths = array_map(max(...), ...$rowColWidths);
+
+        $colCount = count($colWidths);
+
+        $overallWidth = array_sum($colWidths) + ($colCount * 3) + 2;
+
+        $minWidth = min(65, $table->terminal()->cols() - 1);
+
+        $spaceRemaining = $minWidth - $overallWidth;
+
+        if ($spaceRemaining < 1) {
+            return $colWidths;
+        }
+
+        $divided = floor($spaceRemaining / $colCount);
+        $remainder = $spaceRemaining % $colCount;
+
+        $minWidths = collect($colWidths)->map(fn ($width) => $width + $divided)->all();
+        $minWidths[0] += $remainder;
+
+        return $minWidths;
+    }
 }
jessarcher commented 1 year ago

Oh and speaking of Symfony, if you're interested in trying a PR there, I think it would make sense if they used their Helper::removeDecoration method before calculating the column separator width in the Table::getColumnSeparatorWidth method, as they do in a few other places.

That would allow us to use your original approach for changing the border color which was a lot nicer. We'd need to increase the minimum required Symfony version though, which probably isn't ideal - at least for now.