Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions examples/table-wrapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env php
<?php
/**
* Table Wrapping Mode Examples
*
* This example demonstrates the table cell wrapping feature.
* You can control how long content is wrapped in table cells.
*/

if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require_once __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require_once __DIR__ . '/../../../autoload.php';
} else {
throw new Exception('Unable to locate autoloader; please run "composer install"');
}

cli\line();
cli\line('%G===%n %CTable Wrapping Mode Examples%n %G===%n');
cli\line();

// Test data similar to the issue - long plugin names
$headers = array('name', 'version', 'update_version', 'status');
$data = array(
array('advanced-custom-fields', '6.2.7', '', 'active'),
array('advanced-query-loop', '2.1.1', '', 'active'),
array('all-in-one-wp-migration', '7.81', '', 'inactive'),
array('all-in-one-wp-migration-multisite-extension', '4.34', '', 'inactive'),
array('short', '1.0', '', 'active'),
);

// Example 1: Default wrapping (character boundaries)
cli\line('%Y## Example 1: Default Wrapping (Character Boundaries)%n');
cli\line('The default behavior wraps text at character boundaries when it');
cli\line('exceeds the column width. This can split words in awkward places.');
cli\line();
$table = new \cli\Table();
$table->setHeaders($headers);
$table->setRows($data);
$renderer = new \cli\table\Ascii();
$renderer->setConstraintWidth(70); // Simulate narrower terminal
$table->setRenderer($renderer);
$table->display();
cli\line();

// Example 2: Word-wrap mode (wrap at word boundaries)
cli\line('%Y## Example 2: Word-Wrap Mode (Wrap at Word Boundaries)%n');
cli\line('Word-wrap mode keeps words together by wrapping at spaces and hyphens.');
cli\line('This makes it easier to read and copy/paste long values.');
cli\line();
$table = new \cli\Table();
$table->setHeaders($headers);
$table->setRows($data);
$renderer = new \cli\table\Ascii();
$renderer->setConstraintWidth(70); // Simulate narrower terminal
$table->setRenderer($renderer);
$table->setWrappingMode('word-wrap');
$table->display();
cli\line();

// Example 3: Truncate mode (truncate with ellipsis)
cli\line('%Y## Example 3: Truncate Mode (Truncate with Ellipsis)%n');
cli\line('Truncate mode cuts off long content and adds "..." to indicate truncation.');
cli\line('This is useful when you want a compact display and don\'t need full values.');
cli\line();
$table = new \cli\Table();
$table->setHeaders($headers);
$table->setRows($data);
$renderer = new \cli\table\Ascii();
$renderer->setConstraintWidth(70); // Simulate narrower terminal
$table->setRenderer($renderer);
$table->setWrappingMode('truncate');
$table->display();
cli\line();

// Example 4: Usage instructions
cli\line('%Y## Wrapping Mode Options%n');
cli\line();
cli\line('You can use the following wrapping modes:');
cli\line(' %G*%n %Cwrap%n - Default: wrap at character boundaries');
cli\line(' %G*%n %Cword-wrap%n - Wrap at word boundaries (spaces/hyphens)');
cli\line(' %G*%n %Ctruncate%n - Truncate with ellipsis (...)');
cli\line();
cli\line('Example usage:');
cli\line(' %c$table->setWrappingMode(\'word-wrap\');%n');
cli\line();

cli\line('%GDone!%n');
cli\line();
13 changes: 13 additions & 0 deletions lib/cli/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,19 @@ public function setAsciiPreColorized( $pre_colorized ) {
}
}

/**
* Set the wrapping mode for table cells.
*
* @param string $mode One of: 'wrap' (default - wrap at character boundaries),
* 'word-wrap' (wrap at word boundaries), or 'truncate' (truncate with ellipsis).
* @see cli\Ascii::setWrappingMode()
*/
public function setWrappingMode( $mode ) {
if ( $this->_renderer instanceof Ascii ) {
$this->_renderer->setWrappingMode( $mode );
}
}

/**
* Is a column in an Ascii table pre-colorized?
*
Expand Down
166 changes: 151 additions & 15 deletions lib/cli/table/Ascii.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@
* The ASCII renderer renders tables with ASCII borders.
*/
class Ascii extends Renderer {
/**
* Valid wrapping modes.
*/
private const VALID_WRAPPING_MODES = array( 'wrap', 'word-wrap', 'truncate' );

/**
* Ellipsis character(s) used for truncation.
*/
private const ELLIPSIS = '...';

/**
* Width of the ellipsis in characters.
*/
private const ELLIPSIS_WIDTH = 3;

protected $_characters = array(
'corner' => '+',
'line' => '-',
Expand All @@ -28,6 +43,7 @@ class Ascii extends Renderer {
protected $_border = null;
protected $_constraintWidth = null;
protected $_pre_colorized = false;
protected $_wrapping_mode = 'wrap'; // 'wrap', 'word-wrap', or 'truncate'

/**
* Set the widths of each column in the table.
Expand Down Expand Up @@ -96,6 +112,19 @@ public function setConstraintWidth( $constraintWidth ) {
$this->_constraintWidth = $constraintWidth;
}

/**
* Set the wrapping mode for table cells.
*
* @param string $mode One of: 'wrap' (default - wrap at character boundaries),
* 'word-wrap' (wrap at word boundaries), or 'truncate' (truncate with ellipsis).
*/
public function setWrappingMode( $mode ) {
if ( ! in_array( $mode, self::VALID_WRAPPING_MODES, true ) ) {
throw new \InvalidArgumentException( "Invalid wrapping mode '$mode'. Must be one of: " . implode( ', ', self::VALID_WRAPPING_MODES ) );
}
$this->_wrapping_mode = $mode;
}

/**
* Set the characters used for rendering the Ascii table.
*
Expand Down Expand Up @@ -148,21 +177,8 @@ public function row( array $row ) {

$wrapped_lines = [];
foreach ( $split_lines as $line ) {
// Use the new color-aware wrapping for pre-colorized content
if ( self::isPreColorized( $col ) && Colors::width( $line, true, $encoding ) > $col_width ) {
$line_wrapped = Colors::wrapPreColorized( $line, $col_width, $encoding );
$wrapped_lines = array_merge( $wrapped_lines, $line_wrapped );
} else {
// For non-colorized content, use the original logic
do {
$wrapped_value = \cli\safe_substr( $line, 0, $col_width, true /*is_width*/, $encoding );
$val_width = Colors::width( $wrapped_value, self::isPreColorized( $col ), $encoding );
if ( $val_width ) {
$wrapped_lines[] = $wrapped_value;
$line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding );
}
} while ( $line );
}
$line_wrapped = $this->wrapText( $line, $col_width, $encoding, self::isPreColorized( $col ) );
$wrapped_lines = array_merge( $wrapped_lines, $line_wrapped );
}

$row[ $col ] = array_shift( $wrapped_lines );
Expand Down Expand Up @@ -235,6 +251,126 @@ public function setPreColorized( $pre_colorized ) {
$this->_pre_colorized = $pre_colorized;
}

/**
* Wrap text based on the configured wrapping mode.
*
* @param string $text The text to wrap.
* @param int $width The maximum width.
* @param string|bool $encoding The text encoding.
* @param bool $is_precolorized Whether the text is pre-colorized.
* @return array Array of wrapped lines.
*/
protected function wrapText( $text, $width, $encoding, $is_precolorized ) {
if ( ! $width ) {
return array( $text );
}

$text_width = Colors::width( $text, $is_precolorized, $encoding );

// If text fits, no wrapping needed
if ( $text_width <= $width ) {
return array( $text );
}

// Handle truncate mode
if ( 'truncate' === $this->_wrapping_mode ) {
if ( $width <= self::ELLIPSIS_WIDTH ) {
// Not enough space for ellipsis, just truncate
return array( \cli\safe_substr( $text, 0, $width, true /*is_width*/, $encoding ) );
}

// Truncate and add ellipsis
$truncated = \cli\safe_substr( $text, 0, $width - self::ELLIPSIS_WIDTH, true /*is_width*/, $encoding );
return array( $truncated . self::ELLIPSIS );
}

// Handle word-wrap mode
if ( 'word-wrap' === $this->_wrapping_mode ) {
return $this->wordWrap( $text, $width, $encoding, $is_precolorized );
}

// Default: character-boundary wrapping
$wrapped_lines = array();
$line = $text;

// Use the new color-aware wrapping for pre-colorized content
if ( $is_precolorized ) {
$wrapped_lines = Colors::wrapPreColorized( $line, $width, $encoding );
} else {
// For non-colorized content, use character-boundary wrapping
do {
$wrapped_value = \cli\safe_substr( $line, 0, $width, true /*is_width*/, $encoding );
$val_width = Colors::width( $wrapped_value, $is_precolorized, $encoding );
if ( $val_width ) {
$wrapped_lines[] = $wrapped_value;
$line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding );
}
} while ( $line );
}

return $wrapped_lines;
}

/**
* Wrap text at word boundaries.
*
* @param string $text The text to wrap.
* @param int $width The maximum width.
* @param string|bool $encoding The text encoding.
* @param bool $is_precolorized Whether the text is pre-colorized.
* @return array Array of wrapped lines.
*/
protected function wordWrap( $text, $width, $encoding, $is_precolorized ) {
$wrapped_lines = array();
$current_line = '';
$current_line_width = 0;

// Split by spaces and hyphens while keeping the delimiters
$words = preg_split( '/(\s+|-)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );

foreach ( $words as $word ) {
$word_width = Colors::width( $word, $is_precolorized, $encoding );

// If this word alone exceeds the width, we need to split it
if ( $word_width > $width ) {
// Flush current line if not empty
if ( $current_line !== '' ) {
$wrapped_lines[] = $current_line;
$current_line = '';
$current_line_width = 0;
}

// Split the long word at character boundaries
$remaining_word = $word;
while ( $remaining_word ) {
$chunk = \cli\safe_substr( $remaining_word, 0, $width, true /*is_width*/, $encoding );
$wrapped_lines[] = $chunk;
$remaining_word = \cli\safe_substr( $remaining_word, \cli\safe_strlen( $chunk, $encoding ), null /*length*/, false /*is_width*/, $encoding );
}
continue;
}

// Check if adding this word would exceed the width
if ( $current_line !== '' && $current_line_width + $word_width > $width ) {
// Start a new line
$wrapped_lines[] = $current_line;
$current_line = $word;
$current_line_width = $word_width;
} else {
// Add to current line
$current_line .= $word;
$current_line_width += $word_width;
}
}

// Add any remaining content
if ( $current_line !== '' ) {
$wrapped_lines[] = $current_line;
}

return $wrapped_lines ?: array( '' );
}

/**
* Is a column pre-colorized?
*
Expand Down
73 changes: 73 additions & 0 deletions tests/Test_Table_Ascii.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,79 @@ public function testWrappedColorizedText() {
$this->assertOutFileEqualsWith($output);
}

/**
* Test word-wrapping mode keeps words together.
*/
public function testWordWrappingMode() {
$headers = array('name', 'status');
$rows = array(
array('all-in-one-wp-migration-multisite-extension', 'inactive'),
);

// With word-wrap, the hyphenated words should wrap at hyphens
$output = <<<'OUT'
+----------------------+----------+
| name | status |
+----------------------+----------+
| all-in-one-wp- | inactive |
| migration-multisite- | |
| extension | |
+----------------------+----------+

OUT;

$this->_instance->setHeaders($headers);
$this->_instance->setRows($rows);
$renderer = new Ascii([20, 8]);
$renderer->setConstraintWidth(36);
$this->_instance->setRenderer($renderer);
$this->_instance->setWrappingMode('word-wrap');
$this->_instance->display();
$this->assertOutFileEqualsWith($output);
}

/**
* Test truncate mode with ellipsis.
*/
public function testTruncateMode() {
$headers = array('name', 'status');
$rows = array(
array('all-in-one-wp-migration-multisite-extension', 'inactive'),
array('short', 'active'),
);

// With truncate, long names should be truncated with ellipsis
$output = <<<'OUT'
+----------------------+----------+
| name | status |
+----------------------+----------+
| all-in-one-wp-mig... | inactive |
| short | active |
+----------------------+----------+

OUT;

$this->_instance->setHeaders($headers);
$this->_instance->setRows($rows);
$renderer = new Ascii([20, 8]);
$renderer->setConstraintWidth(36);
$this->_instance->setRenderer($renderer);
$this->_instance->setWrappingMode('truncate');
$this->_instance->display();
$this->assertOutFileEqualsWith($output);
}

/**
* Test that wrapping mode setter validates input.
*/
public function testWrappingModeValidation() {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Invalid wrapping mode 'invalid'");

$renderer = new Ascii();
$renderer->setWrappingMode('invalid');
}

/**
* Checks that spacing and borders are handled correctly in table
*/
Expand Down