@@ -172,4 +172,282 @@ public function error($msg) {
172172 die ();
173173 }
174174
175+ /**
176+ * @return bool true when STDOUT is not redirected to a file
177+ */
178+ public function isStdOutATty () {
179+ return defined ('STDOUT ' ) && function_exists ('posix_isatty ' ) && posix_isatty (constant ('STDOUT ' ));
180+ }
181+
182+ /**
183+ * Return the width of the string $text (not counting ANSI escape codes).
184+ *
185+ * @param string $text
186+ * @return int
187+ */
188+ public function stringWidth ($ text ) {
189+ return mb_strwidth (preg_replace ("/ \033\[.*?m/ " , '' , $ text ), 'utf8 ' );
190+ }
191+
192+ /**
193+ * Formats text so that it is not longer than $width.
194+ *
195+ * ANSI escape codes do not count to the line length and when a text needs to be wrapped, the currently active
196+ * ANSI escape codes get reset before inserting the newline character. This is needed to wrap text in multiple columns.
197+ *
198+ * @param string $text unwrapped text. May already contain newlines that get preserved.
199+ * @param int $width maximum line width
200+ * @return string
201+ */
202+ public function wrapText ($ text , $ width = 80 ) {
203+ $ characters = [];
204+ $ cur_width = 0 ;
205+ $ in_ansi = false ;
206+ $ prev_ansi = '' ;
207+ $ current_ansi = '' ;
208+ $ reset_ansi = "\033[0m " ;
209+ $ input = preg_split ('//u ' , $ text , -1 , PREG_SPLIT_NO_EMPTY );
210+ foreach ($ input as $ index => $ char ) {
211+ if ($ char == "\n" ) {
212+ $ prev_ansi = '' ;
213+ $ cur_width = 0 ;
214+ if ($ current_ansi ) {
215+ $ characters [] = $ reset_ansi . "\n" . $ current_ansi ;
216+ } else {
217+ $ characters [] = "\n" ;
218+ }
219+ } elseif ($ in_ansi || $ char == "\033" ) {
220+ $ characters [] = $ char ;
221+ if ($ char == "\033" ) {
222+ $ prev_ansi = $ prev_ansi . $ current_ansi ;
223+ $ current_ansi = '' ;
224+ }
225+ $ current_ansi .= $ char ;
226+ $ in_ansi = $ char != 'm ' ;
227+ if (!$ in_ansi ) {
228+ if ($ current_ansi == $ reset_ansi ) {
229+ $ current_ansi = '' ;
230+ $ prev_ansi = '' ;
231+ } else {
232+ $ current_ansi = $ prev_ansi . $ current_ansi ;
233+ }
234+ }
235+ } elseif ($ char == ' ' ) {
236+ // look ahead $width characters and search for a newline or another space
237+ $ prev_ansi = '' ;
238+ $ lookahead_length = 0 ;
239+ $ has_better_break = false ;
240+ $ lookup_reached_end = false ;
241+ $ lookahead_in_ansi = false ;
242+ for ($ i = $ index + 1 ; $ i < count ($ input ); $ i ++) {
243+ $ next_char = $ input [$ i ];
244+ $ lookup_reached_end = $ i == count ($ input ) - 1 ;
245+ if ($ next_char == "\n" ) {
246+ $ has_better_break = $ cur_width + $ lookahead_length < $ width ;
247+ break ;
248+ } elseif ($ lookahead_in_ansi || $ next_char == "\033" ) {
249+ $ lookahead_in_ansi = $ char != 'm ' ;
250+ } elseif ($ next_char == ' ' && $ cur_width + $ lookahead_length < $ width ) {
251+ $ has_better_break = true ;
252+ break ;
253+ } else {
254+ $ lookahead_length += mb_strwidth ($ next_char , 'utf8 ' );
255+ }
256+ if ($ cur_width + $ lookahead_length > $ width ) {
257+ break ;
258+ }
259+ }
260+ if (!$ lookup_reached_end && !$ has_better_break ) {
261+ $ cur_width = 0 ;
262+ if ($ current_ansi ) {
263+ $ characters [] = $ reset_ansi . "\n" . $ current_ansi ;
264+ } else {
265+ $ characters [] = "\n" ;
266+ }
267+ } else {
268+ $ characters [] = $ char ;
269+ $ cur_width += 1 ;
270+ }
271+ } else {
272+ $ prev_ansi = '' ;
273+ $ char_width = mb_strwidth ($ char , 'utf8 ' );
274+ if ($ cur_width + $ char_width > $ width ) {
275+ $ cur_width = 0 ;
276+ if ($ current_ansi ) {
277+ $ characters [] = $ reset_ansi . "\n" . $ current_ansi ;
278+ } else {
279+ $ characters [] = "\n" ;
280+ }
281+ }
282+ $ characters [] = $ char ;
283+ $ cur_width += $ char_width ;
284+ }
285+ }
286+ return join ('' , $ characters );
287+ }
288+
289+ /**
290+ * Divides $value up into $count parts. Returns an array with $count integers.
291+ * The sum of all returned integers is always $value (Even when rounding happens).
292+ *
293+ * @param int $value
294+ * @param int $count
295+ * @return int[]
296+ */
297+ private function getDiscreetDistribution ($ value , $ count ) {
298+ $ per_item = intval (floor ($ value / $ count ));
299+ $ distribution = array_fill (0 , $ count , $ per_item );
300+ // add 1 to the columns until the column sum is equal to $value
301+ $ sum = $ per_item * $ count ;
302+ for ($ i = 0 ; $ i < $ count && $ sum < $ value ; $ i ++) {
303+ $ distribution [$ i ] += 1 ;
304+ $ sum += 1 ;
305+ }
306+ return $ distribution ;
307+ }
308+
309+ /**
310+ * Outputs an array of arrays as table to STDOUT. All cells should already be strings. You can use ANSI escape code sequences in the cells.
311+ *
312+ * Simple table:
313+ * <code>
314+ * $this->outputTable([['one', 'two'], ['1', '2']]);
315+ * </code>
316+ *
317+ * You can define minimum column lengths:
318+ * <code>
319+ * $this->outputTable([['one', 'two'], ['1', '2']], ['min_lengths' => [10, 10]]);
320+ * </code>
321+ *
322+ * The table tries to size itself to fit in the terminal window width. As default all column width 5 or wider will shrink,
323+ * but you can also specify which column(s) should shrink:
324+ * <code>
325+ * $this->outputTable([
326+ * ['variable', 'fixed', 'variable'],
327+ * [str_repeat('this is a long column ', 40), '2', str_repeat('another long column ', 60)]
328+ * ], ['variable_columns' => [0,2]]);
329+ * </code>
330+ *
331+ * Small tables do not fill the available terminal window width by default.
332+ * You can change this with the `expand` $option:
333+ * <code>
334+ * $this->outputTable([['one', 'two'], ['1', '2']], ['expand' => true]);
335+ * </code>
336+ *
337+ * @param array $table array of arrays (rows -> columns) to display as table.
338+ * @param array{min_lengths: array|null, variable_columns: array|string|int|null, expand: bool}|null $options optional formatting options
339+ * @return void
340+ */
341+ public function outputTable ($ table , $ options = null ) {
342+ if (empty ($ table )) {
343+ $ this ->swriteln ('No data to display ' );
344+ return ;
345+ }
346+ if (!is_array ($ options )) {
347+ $ options = [];
348+ }
349+ if (!is_array ($ options ['min_lengths ' ])) {
350+ $ options ['min_lengths ' ] = [];
351+ }
352+
353+ // process input $table
354+ $ columns = [];
355+ $ rows = [];
356+ $ num_columns = false ;
357+ foreach ($ table as $ row ) {
358+ $ c = count ($ row );
359+ if (!$ c || $ num_columns !== false && $ c != $ num_columns ) {
360+ $ this ->error ("every input row of outputTable input needs to the same size (not null) " );
361+ }
362+ $ num_columns = $ c ;
363+ $ r = [];
364+ $ index = 0 ;
365+ foreach ($ row as $ value ) {
366+ $ value_as_string = (string )$ value ;
367+ $ max = 0 ;
368+ foreach (explode ("\n" , $ value_as_string ) as $ line ) {
369+ $ len = $ this ->stringWidth ($ line );
370+ $ max = max ($ max , max ($ options ['min_lengths ' ][$ index ] ?: 0 , $ len ));
371+ }
372+ if (!isset ($ columns [$ index ])) {
373+ $ columns [$ index ] = ['length ' => 0 , 'first ' => $ index == 0 , 'last ' => $ index == $ num_columns - 1 ];
374+ }
375+ if ($ columns [$ index ]['length ' ] < $ max ) {
376+ $ columns [$ index ]['length ' ] = $ max ;
377+ }
378+ $ r [$ index ] = $ value_as_string ;
379+ $ index += 1 ;
380+ }
381+ $ rows [] = $ r ;
382+ }
383+
384+ // fit table to terminal
385+ if ($ this ->isStdOutATty ()) {
386+ if (!isset ($ options ['variable_columns ' ]) || $ options ['variable_columns ' ] == 'all ' ) {
387+ $ options ['variable_columns ' ] = range (0 , $ num_columns - 1 );
388+ } elseif (!is_array ($ options ['variable_columns ' ])) {
389+ $ options ['variable_columns ' ] = explode (', ' , (string )$ options ['variable_columns ' ]);
390+ }
391+ $ minimum_variable_column_width = 5 ;
392+ $ shrink_variable_columns = array_values (array_filter ($ options ['variable_columns ' ], function ($ index ) use ($ minimum_variable_column_width , $ columns , $ num_columns ) {
393+ return $ index < $ num_columns && $ columns [$ index ]['length ' ] >= $ minimum_variable_column_width ;
394+ }));
395+ $ expand_variable_columns = array_values (array_filter ($ options ['variable_columns ' ], function ($ index ) use ($ num_columns ) {
396+ return $ index < $ num_columns ;
397+ }));
398+ $ terminal_width = intval (trim (exec ("tput cols " ) ?: '' )) ?: 80 ;
399+ $ table_width = array_reduce ($ columns , function ($ sum , $ column ) {
400+ return $ sum + $ column ['length ' ] + 3 ;
401+ }, 1 );
402+ if (count ($ shrink_variable_columns ) > 0 && $ table_width > $ terminal_width ) {
403+ $ diff = $ table_width - $ terminal_width ;
404+ $ diff_per_column = $ this ->getDiscreetDistribution ($ diff , count ($ shrink_variable_columns ));
405+ foreach ($ shrink_variable_columns as $ i => $ index ) {
406+ $ new_length = $ columns [$ index ]['length ' ] - $ diff_per_column [$ i ];
407+ $ columns [$ index ]['length ' ] = max ($ minimum_variable_column_width , $ new_length );
408+ }
409+ } elseif (count ($ expand_variable_columns ) > 0 && $ options ['expand ' ] && $ table_width < $ terminal_width ) {
410+ $ diff = $ terminal_width - $ table_width ;
411+ $ diff_per_column = $ this ->getDiscreetDistribution ($ diff , count ($ expand_variable_columns ));
412+ foreach ($ expand_variable_columns as $ i => $ index ) {
413+ $ new_length = $ columns [$ index ]['length ' ] + $ diff_per_column [$ i ];
414+ $ columns [$ index ]['length ' ] = $ new_length ;
415+ }
416+ }
417+ }
418+
419+ // output table
420+ $ separator = function ($ first_start , $ mid_start , $ mid , $ mid_end , $ last_end ) use ($ columns ) {
421+ $ this ->swriteln (array_reduce ($ columns , function ($ line , $ column ) use ($ first_start , $ mid_start , $ mid , $ mid_end , $ last_end ) {
422+ return $ line . ($ column ['first ' ] ? $ first_start : $ mid_start ) . str_repeat ($ mid , $ column ['length ' ]) . ($ column ['last ' ] ? $ last_end : $ mid_end );
423+ }, '' ));
424+ };
425+ foreach ($ rows as $ row_index => $ row ) {
426+ if ($ row_index == 0 ) {
427+ $ separator ('╔═ ' , '╤═ ' , '═ ' , '═ ' , '═╗ ' );
428+ }
429+ // first pass -> re-wrap lines and get max number of lines of this row
430+ $ height = 1 ;
431+ $ lines = [];
432+ foreach ($ columns as $ index => $ column ) {
433+ $ lines [$ index ] = explode ("\n" , $ this ->wrapText ($ row [$ index ], $ column ['length ' ]));
434+ $ height = max ($ height , count ($ lines [$ index ]));
435+ }
436+ // second pass -> output row, line by line
437+ for ($ inner = 0 ; $ inner < $ height ; $ inner ++) {
438+ $ line = "" ;
439+ foreach ($ columns as $ index => $ column ) {
440+ $ value = $ lines [$ index ][$ inner ] ?: '' ;
441+ $ value .= str_repeat (' ' , $ column ['length ' ] - $ this ->stringWidth ($ value ));
442+ $ line .= ($ column ['first ' ] ? '║ ' : '│ ' ) . $ value . ($ column ['last ' ] ? ' ║ ' : ' ' );
443+ }
444+ $ this ->swriteln ($ line );
445+ }
446+ if ($ row_index < count ($ rows ) - 1 ) {
447+ $ separator ('╟─ ' , '┼─ ' , '─ ' , '─ ' , '─╢ ' );
448+ } else {
449+ $ separator ('╚═ ' , '╧═ ' , '═ ' , '═ ' , '═╝ ' );
450+ }
451+ }
452+ }
175453}
0 commit comments