Skip to content

Commit 3de9a76

Browse files
Copilotswissspidy
andauthored
Add configurable wrapping modes for table columns (#195)
* Initial plan * Add wrapping mode configuration for table columns - Add setWrappingMode() method to Ascii renderer and Table class - Support three modes: 'wrap' (default), 'word-wrap', and 'truncate' - word-wrap mode wraps at word boundaries (spaces/hyphens) - truncate mode truncates with ellipsis (...) - Add helper methods wrapText() and wordWrap() for wrapping logic - Add tests for new functionality Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Update table wrapping example with documentation - Add comprehensive examples for all three wrapping modes - Include explanations of when to use each mode - Add usage instructions in the example output - Make executable with proper shebang Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Refactor: Use class constant for valid wrapping modes and optimize width tracking - Define VALID_WRAPPING_MODES as a private class constant for better maintainability - Optimize wordWrap() by tracking width incrementally instead of recalculating Colors::width() on every iteration - Addresses code review feedback from @swissspidy Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Refactor: Add ellipsis constants and simplify pre-colorized check - Define ELLIPSIS and ELLIPSIS_WIDTH as class constants for better maintainability - Remove redundant width check in pre-colorized condition (already validated earlier) - Addresses code review feedback from @swissspidy Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
1 parent b6f32be commit 3de9a76

File tree

4 files changed

+326
-15
lines changed

4 files changed

+326
-15
lines changed

examples/table-wrapping.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env php
2+
<?php
3+
/**
4+
* Table Wrapping Mode Examples
5+
*
6+
* This example demonstrates the table cell wrapping feature.
7+
* You can control how long content is wrapped in table cells.
8+
*/
9+
10+
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
11+
require_once __DIR__ . '/../vendor/autoload.php';
12+
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
13+
require_once __DIR__ . '/../../../autoload.php';
14+
} else {
15+
throw new Exception('Unable to locate autoloader; please run "composer install"');
16+
}
17+
18+
cli\line();
19+
cli\line('%G===%n %CTable Wrapping Mode Examples%n %G===%n');
20+
cli\line();
21+
22+
// Test data similar to the issue - long plugin names
23+
$headers = array('name', 'version', 'update_version', 'status');
24+
$data = array(
25+
array('advanced-custom-fields', '6.2.7', '', 'active'),
26+
array('advanced-query-loop', '2.1.1', '', 'active'),
27+
array('all-in-one-wp-migration', '7.81', '', 'inactive'),
28+
array('all-in-one-wp-migration-multisite-extension', '4.34', '', 'inactive'),
29+
array('short', '1.0', '', 'active'),
30+
);
31+
32+
// Example 1: Default wrapping (character boundaries)
33+
cli\line('%Y## Example 1: Default Wrapping (Character Boundaries)%n');
34+
cli\line('The default behavior wraps text at character boundaries when it');
35+
cli\line('exceeds the column width. This can split words in awkward places.');
36+
cli\line();
37+
$table = new \cli\Table();
38+
$table->setHeaders($headers);
39+
$table->setRows($data);
40+
$renderer = new \cli\table\Ascii();
41+
$renderer->setConstraintWidth(70); // Simulate narrower terminal
42+
$table->setRenderer($renderer);
43+
$table->display();
44+
cli\line();
45+
46+
// Example 2: Word-wrap mode (wrap at word boundaries)
47+
cli\line('%Y## Example 2: Word-Wrap Mode (Wrap at Word Boundaries)%n');
48+
cli\line('Word-wrap mode keeps words together by wrapping at spaces and hyphens.');
49+
cli\line('This makes it easier to read and copy/paste long values.');
50+
cli\line();
51+
$table = new \cli\Table();
52+
$table->setHeaders($headers);
53+
$table->setRows($data);
54+
$renderer = new \cli\table\Ascii();
55+
$renderer->setConstraintWidth(70); // Simulate narrower terminal
56+
$table->setRenderer($renderer);
57+
$table->setWrappingMode('word-wrap');
58+
$table->display();
59+
cli\line();
60+
61+
// Example 3: Truncate mode (truncate with ellipsis)
62+
cli\line('%Y## Example 3: Truncate Mode (Truncate with Ellipsis)%n');
63+
cli\line('Truncate mode cuts off long content and adds "..." to indicate truncation.');
64+
cli\line('This is useful when you want a compact display and don\'t need full values.');
65+
cli\line();
66+
$table = new \cli\Table();
67+
$table->setHeaders($headers);
68+
$table->setRows($data);
69+
$renderer = new \cli\table\Ascii();
70+
$renderer->setConstraintWidth(70); // Simulate narrower terminal
71+
$table->setRenderer($renderer);
72+
$table->setWrappingMode('truncate');
73+
$table->display();
74+
cli\line();
75+
76+
// Example 4: Usage instructions
77+
cli\line('%Y## Wrapping Mode Options%n');
78+
cli\line();
79+
cli\line('You can use the following wrapping modes:');
80+
cli\line(' %G*%n %Cwrap%n - Default: wrap at character boundaries');
81+
cli\line(' %G*%n %Cword-wrap%n - Wrap at word boundaries (spaces/hyphens)');
82+
cli\line(' %G*%n %Ctruncate%n - Truncate with ellipsis (...)');
83+
cli\line();
84+
cli\line('Example usage:');
85+
cli\line(' %c$table->setWrappingMode(\'word-wrap\');%n');
86+
cli\line();
87+
88+
cli\line('%GDone!%n');
89+
cli\line();

lib/cli/Table.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,19 @@ public function setAsciiPreColorized( $pre_colorized ) {
320320
}
321321
}
322322

323+
/**
324+
* Set the wrapping mode for table cells.
325+
*
326+
* @param string $mode One of: 'wrap' (default - wrap at character boundaries),
327+
* 'word-wrap' (wrap at word boundaries), or 'truncate' (truncate with ellipsis).
328+
* @see cli\Ascii::setWrappingMode()
329+
*/
330+
public function setWrappingMode( $mode ) {
331+
if ( $this->_renderer instanceof Ascii ) {
332+
$this->_renderer->setWrappingMode( $mode );
333+
}
334+
}
335+
323336
/**
324337
* Is a column in an Ascii table pre-colorized?
325338
*

lib/cli/table/Ascii.php

Lines changed: 151 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@
1919
* The ASCII renderer renders tables with ASCII borders.
2020
*/
2121
class Ascii extends Renderer {
22+
/**
23+
* Valid wrapping modes.
24+
*/
25+
private const VALID_WRAPPING_MODES = array( 'wrap', 'word-wrap', 'truncate' );
26+
27+
/**
28+
* Ellipsis character(s) used for truncation.
29+
*/
30+
private const ELLIPSIS = '...';
31+
32+
/**
33+
* Width of the ellipsis in characters.
34+
*/
35+
private const ELLIPSIS_WIDTH = 3;
36+
2237
protected $_characters = array(
2338
'corner' => '+',
2439
'line' => '-',
@@ -28,6 +43,7 @@ class Ascii extends Renderer {
2843
protected $_border = null;
2944
protected $_constraintWidth = null;
3045
protected $_pre_colorized = false;
46+
protected $_wrapping_mode = 'wrap'; // 'wrap', 'word-wrap', or 'truncate'
3147

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

115+
/**
116+
* Set the wrapping mode for table cells.
117+
*
118+
* @param string $mode One of: 'wrap' (default - wrap at character boundaries),
119+
* 'word-wrap' (wrap at word boundaries), or 'truncate' (truncate with ellipsis).
120+
*/
121+
public function setWrappingMode( $mode ) {
122+
if ( ! in_array( $mode, self::VALID_WRAPPING_MODES, true ) ) {
123+
throw new \InvalidArgumentException( "Invalid wrapping mode '$mode'. Must be one of: " . implode( ', ', self::VALID_WRAPPING_MODES ) );
124+
}
125+
$this->_wrapping_mode = $mode;
126+
}
127+
99128
/**
100129
* Set the characters used for rendering the Ascii table.
101130
*
@@ -148,21 +177,8 @@ public function row( array $row ) {
148177

149178
$wrapped_lines = [];
150179
foreach ( $split_lines as $line ) {
151-
// Use the new color-aware wrapping for pre-colorized content
152-
if ( self::isPreColorized( $col ) && Colors::width( $line, true, $encoding ) > $col_width ) {
153-
$line_wrapped = Colors::wrapPreColorized( $line, $col_width, $encoding );
154-
$wrapped_lines = array_merge( $wrapped_lines, $line_wrapped );
155-
} else {
156-
// For non-colorized content, use the original logic
157-
do {
158-
$wrapped_value = \cli\safe_substr( $line, 0, $col_width, true /*is_width*/, $encoding );
159-
$val_width = Colors::width( $wrapped_value, self::isPreColorized( $col ), $encoding );
160-
if ( $val_width ) {
161-
$wrapped_lines[] = $wrapped_value;
162-
$line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding );
163-
}
164-
} while ( $line );
165-
}
180+
$line_wrapped = $this->wrapText( $line, $col_width, $encoding, self::isPreColorized( $col ) );
181+
$wrapped_lines = array_merge( $wrapped_lines, $line_wrapped );
166182
}
167183

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

254+
/**
255+
* Wrap text based on the configured wrapping mode.
256+
*
257+
* @param string $text The text to wrap.
258+
* @param int $width The maximum width.
259+
* @param string|bool $encoding The text encoding.
260+
* @param bool $is_precolorized Whether the text is pre-colorized.
261+
* @return array Array of wrapped lines.
262+
*/
263+
protected function wrapText( $text, $width, $encoding, $is_precolorized ) {
264+
if ( ! $width ) {
265+
return array( $text );
266+
}
267+
268+
$text_width = Colors::width( $text, $is_precolorized, $encoding );
269+
270+
// If text fits, no wrapping needed
271+
if ( $text_width <= $width ) {
272+
return array( $text );
273+
}
274+
275+
// Handle truncate mode
276+
if ( 'truncate' === $this->_wrapping_mode ) {
277+
if ( $width <= self::ELLIPSIS_WIDTH ) {
278+
// Not enough space for ellipsis, just truncate
279+
return array( \cli\safe_substr( $text, 0, $width, true /*is_width*/, $encoding ) );
280+
}
281+
282+
// Truncate and add ellipsis
283+
$truncated = \cli\safe_substr( $text, 0, $width - self::ELLIPSIS_WIDTH, true /*is_width*/, $encoding );
284+
return array( $truncated . self::ELLIPSIS );
285+
}
286+
287+
// Handle word-wrap mode
288+
if ( 'word-wrap' === $this->_wrapping_mode ) {
289+
return $this->wordWrap( $text, $width, $encoding, $is_precolorized );
290+
}
291+
292+
// Default: character-boundary wrapping
293+
$wrapped_lines = array();
294+
$line = $text;
295+
296+
// Use the new color-aware wrapping for pre-colorized content
297+
if ( $is_precolorized ) {
298+
$wrapped_lines = Colors::wrapPreColorized( $line, $width, $encoding );
299+
} else {
300+
// For non-colorized content, use character-boundary wrapping
301+
do {
302+
$wrapped_value = \cli\safe_substr( $line, 0, $width, true /*is_width*/, $encoding );
303+
$val_width = Colors::width( $wrapped_value, $is_precolorized, $encoding );
304+
if ( $val_width ) {
305+
$wrapped_lines[] = $wrapped_value;
306+
$line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding );
307+
}
308+
} while ( $line );
309+
}
310+
311+
return $wrapped_lines;
312+
}
313+
314+
/**
315+
* Wrap text at word boundaries.
316+
*
317+
* @param string $text The text to wrap.
318+
* @param int $width The maximum width.
319+
* @param string|bool $encoding The text encoding.
320+
* @param bool $is_precolorized Whether the text is pre-colorized.
321+
* @return array Array of wrapped lines.
322+
*/
323+
protected function wordWrap( $text, $width, $encoding, $is_precolorized ) {
324+
$wrapped_lines = array();
325+
$current_line = '';
326+
$current_line_width = 0;
327+
328+
// Split by spaces and hyphens while keeping the delimiters
329+
$words = preg_split( '/(\s+|-)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
330+
331+
foreach ( $words as $word ) {
332+
$word_width = Colors::width( $word, $is_precolorized, $encoding );
333+
334+
// If this word alone exceeds the width, we need to split it
335+
if ( $word_width > $width ) {
336+
// Flush current line if not empty
337+
if ( $current_line !== '' ) {
338+
$wrapped_lines[] = $current_line;
339+
$current_line = '';
340+
$current_line_width = 0;
341+
}
342+
343+
// Split the long word at character boundaries
344+
$remaining_word = $word;
345+
while ( $remaining_word ) {
346+
$chunk = \cli\safe_substr( $remaining_word, 0, $width, true /*is_width*/, $encoding );
347+
$wrapped_lines[] = $chunk;
348+
$remaining_word = \cli\safe_substr( $remaining_word, \cli\safe_strlen( $chunk, $encoding ), null /*length*/, false /*is_width*/, $encoding );
349+
}
350+
continue;
351+
}
352+
353+
// Check if adding this word would exceed the width
354+
if ( $current_line !== '' && $current_line_width + $word_width > $width ) {
355+
// Start a new line
356+
$wrapped_lines[] = $current_line;
357+
$current_line = $word;
358+
$current_line_width = $word_width;
359+
} else {
360+
// Add to current line
361+
$current_line .= $word;
362+
$current_line_width += $word_width;
363+
}
364+
}
365+
366+
// Add any remaining content
367+
if ( $current_line !== '' ) {
368+
$wrapped_lines[] = $current_line;
369+
}
370+
371+
return $wrapped_lines ?: array( '' );
372+
}
373+
238374
/**
239375
* Is a column pre-colorized?
240376
*

tests/Test_Table_Ascii.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,79 @@ public function testWrappedColorizedText() {
153153
$this->assertOutFileEqualsWith($output);
154154
}
155155

156+
/**
157+
* Test word-wrapping mode keeps words together.
158+
*/
159+
public function testWordWrappingMode() {
160+
$headers = array('name', 'status');
161+
$rows = array(
162+
array('all-in-one-wp-migration-multisite-extension', 'inactive'),
163+
);
164+
165+
// With word-wrap, the hyphenated words should wrap at hyphens
166+
$output = <<<'OUT'
167+
+----------------------+----------+
168+
| name | status |
169+
+----------------------+----------+
170+
| all-in-one-wp- | inactive |
171+
| migration-multisite- | |
172+
| extension | |
173+
+----------------------+----------+
174+
175+
OUT;
176+
177+
$this->_instance->setHeaders($headers);
178+
$this->_instance->setRows($rows);
179+
$renderer = new Ascii([20, 8]);
180+
$renderer->setConstraintWidth(36);
181+
$this->_instance->setRenderer($renderer);
182+
$this->_instance->setWrappingMode('word-wrap');
183+
$this->_instance->display();
184+
$this->assertOutFileEqualsWith($output);
185+
}
186+
187+
/**
188+
* Test truncate mode with ellipsis.
189+
*/
190+
public function testTruncateMode() {
191+
$headers = array('name', 'status');
192+
$rows = array(
193+
array('all-in-one-wp-migration-multisite-extension', 'inactive'),
194+
array('short', 'active'),
195+
);
196+
197+
// With truncate, long names should be truncated with ellipsis
198+
$output = <<<'OUT'
199+
+----------------------+----------+
200+
| name | status |
201+
+----------------------+----------+
202+
| all-in-one-wp-mig... | inactive |
203+
| short | active |
204+
+----------------------+----------+
205+
206+
OUT;
207+
208+
$this->_instance->setHeaders($headers);
209+
$this->_instance->setRows($rows);
210+
$renderer = new Ascii([20, 8]);
211+
$renderer->setConstraintWidth(36);
212+
$this->_instance->setRenderer($renderer);
213+
$this->_instance->setWrappingMode('truncate');
214+
$this->_instance->display();
215+
$this->assertOutFileEqualsWith($output);
216+
}
217+
218+
/**
219+
* Test that wrapping mode setter validates input.
220+
*/
221+
public function testWrappingModeValidation() {
222+
$this->expectException(\InvalidArgumentException::class);
223+
$this->expectExceptionMessage("Invalid wrapping mode 'invalid'");
224+
225+
$renderer = new Ascii();
226+
$renderer->setWrappingMode('invalid');
227+
}
228+
156229
/**
157230
* Checks that spacing and borders are handled correctly in table
158231
*/

0 commit comments

Comments
 (0)