-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[WIP] [Console] Pretty word wrapping #30590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
25eae26
39934bc
3726148
0fcd924
f45ea2d
383c728
56013db
8550d43
f4f914b
4f7847a
5ca38fb
a4a64a4
a818772
149778f
90582f4
e669dd1
260b1d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,18 +12,22 @@ | |
namespace Symfony\Component\Console\Formatter; | ||
|
||
use Symfony\Component\Console\Exception\InvalidArgumentException; | ||
use Symfony\Component\Console\Helper\Helper; | ||
use Symfony\Component\Console\Helper\WordWrapperHelper; | ||
|
||
/** | ||
* Formatter class for console output. | ||
* | ||
* @author Konstantin Kudryashov <[email protected]> | ||
* @author Roland Franssen <[email protected]> | ||
* @author Krisztián Ferenczi <[email protected]> | ||
*/ | ||
class OutputFormatter implements WrappableOutputFormatterInterface | ||
{ | ||
private $decorated; | ||
private $styles = []; | ||
private $styleStack; | ||
private $defaultWrapCutOption = WordWrapperHelper::CUT_LONG_WORDS | WordWrapperHelper::CUT_FILL_UP_MISSING; | ||
|
||
/** | ||
* Escapes "<" special char in given text. | ||
|
@@ -127,23 +131,36 @@ public function getStyle($name) | |
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
* @return int | ||
*/ | ||
public function format($message) | ||
public function getDefaultWrapCutOption(): int | ||
{ | ||
return $this->formatAndWrap((string) $message, 0); | ||
return $this->defaultWrapCutOption; | ||
} | ||
|
||
/** | ||
* @param int $defaultWrapCutOption | ||
* | ||
* @return $this | ||
* | ||
* @see WordWrapperHelper | ||
*/ | ||
public function setDefaultWrapCutOption(int $defaultWrapCutOption) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same, where do we use it? I tend to prefer having a |
||
{ | ||
$this->defaultWrapCutOption = $defaultWrapCutOption; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function formatAndWrap(string $message, int $width) | ||
public function format($message) | ||
{ | ||
$message = (string) $message; | ||
$offset = 0; | ||
$output = ''; | ||
$tagRegex = '[a-z][a-z0-9,_=;-]*+'; | ||
$currentLineLength = 0; | ||
preg_match_all("#<(($tagRegex) | /($tagRegex)?)>#ix", $message, $matches, PREG_OFFSET_CAPTURE); | ||
preg_match_all(Helper::getFormatTagRegexPattern(), $message, $matches, PREG_OFFSET_CAPTURE); | ||
foreach ($matches[0] as $i => $match) { | ||
$pos = $match[1]; | ||
$text = $match[0]; | ||
|
@@ -153,7 +170,7 @@ public function formatAndWrap(string $message, int $width) | |
} | ||
|
||
// add the text up to the next tag | ||
$output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset), $output, $width, $currentLineLength); | ||
$output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset)); | ||
$offset = $pos + \strlen($text); | ||
|
||
// opening tag? | ||
|
@@ -166,16 +183,16 @@ public function formatAndWrap(string $message, int $width) | |
if (!$open && !$tag) { | ||
// </> | ||
$this->styleStack->pop(); | ||
} elseif (false === $style = $this->createStyleFromString($tag)) { | ||
$output .= $this->applyCurrentStyle($text, $output, $width, $currentLineLength); | ||
} elseif (false === $style = $this->createStyleFromString(strtolower($tag))) { | ||
$output .= $this->applyCurrentStyle($text); | ||
} elseif ($open) { | ||
$this->styleStack->push($style); | ||
} else { | ||
$this->styleStack->pop($style); | ||
} | ||
} | ||
|
||
$output .= $this->applyCurrentStyle(substr($message, $offset), $output, $width, $currentLineLength); | ||
$output .= $this->applyCurrentStyle(substr($message, $offset)); | ||
|
||
if (false !== strpos($output, "\0")) { | ||
return strtr($output, ["\0" => '\\', '\\<' => '<']); | ||
|
@@ -184,6 +201,22 @@ public function formatAndWrap(string $message, int $width) | |
return str_replace('\\<', '<', $output); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function formatAndWrap(string $message, int $width) | ||
{ | ||
return $this->format($this->wordwrap($message, $width)); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function wordwrap(string $message, int $width, int $cutOption = null): string | ||
{ | ||
return WordWrapperHelper::wrap($message, $width, null === $cutOption ? $this->defaultWrapCutOption : $cutOption); | ||
} | ||
|
||
/** | ||
* @return OutputFormatterStyleStack | ||
*/ | ||
|
@@ -195,6 +228,8 @@ public function getStyleStack() | |
/** | ||
* Tries to create new style instance from string. | ||
* | ||
* @param string $string | ||
* | ||
* @return OutputFormatterStyle|false False if string is not format string | ||
*/ | ||
private function createStyleFromString(string $string) | ||
|
@@ -233,46 +268,17 @@ private function createStyleFromString(string $string) | |
/** | ||
* Applies current style from stack to text, if must be applied. | ||
*/ | ||
private function applyCurrentStyle(string $text, string $current, int $width, int &$currentLineLength): string | ||
private function applyCurrentStyle(string $text): string | ||
{ | ||
if ('' === $text) { | ||
return ''; | ||
} | ||
|
||
if (!$width) { | ||
return $this->isDecorated() ? $this->styleStack->getCurrent()->apply($text) : $text; | ||
} | ||
|
||
if (!$currentLineLength && '' !== $current) { | ||
$text = ltrim($text); | ||
} | ||
|
||
if ($currentLineLength) { | ||
$prefix = substr($text, 0, $i = $width - $currentLineLength)."\n"; | ||
$text = substr($text, $i); | ||
} else { | ||
$prefix = ''; | ||
} | ||
|
||
preg_match('~(\\n)$~', $text, $matches); | ||
$text = $prefix.preg_replace('~([^\\n]{'.$width.'})\\ *~', "\$1\n", $text); | ||
$text = rtrim($text, "\n").($matches[1] ?? ''); | ||
|
||
if (!$currentLineLength && '' !== $current && "\n" !== substr($current, -1)) { | ||
$text = "\n".$text; | ||
} | ||
|
||
$lines = explode("\n", $text); | ||
if ($width === $currentLineLength = \strlen(end($lines))) { | ||
$currentLineLength = 0; | ||
} | ||
|
||
if ($this->isDecorated()) { | ||
if ($this->isDecorated() && \strlen($text) > 0) { | ||
$lines = explode("\n", $text); | ||
foreach ($lines as $i => $line) { | ||
$lines[$i] = $this->styleStack->getCurrent()->apply($line); | ||
} | ||
|
||
return implode("\n", $lines); | ||
} | ||
|
||
return implode("\n", $lines); | ||
return $text; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,11 +15,23 @@ | |
* Formatter interface for console output that supports word wrapping. | ||
* | ||
* @author Roland Franssen <[email protected]> | ||
* @author Krisztián Ferenczi <[email protected]> | ||
*/ | ||
interface WrappableOutputFormatterInterface extends OutputFormatterInterface | ||
{ | ||
/** | ||
* Formats a message according to the given styles, wrapping at `$width` (0 means no wrapping). | ||
*/ | ||
public function formatAndWrap(string $message, int $width); | ||
|
||
/** | ||
* Separate word wrapping method. | ||
* | ||
* @param string $message | ||
* @param int $width | ||
* @param int|null $cutOption | ||
* | ||
* @return string | ||
*/ | ||
public function wordwrap(string $message, int $width, int $cutOption = null): string; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is new feature isnt it? To wrap a raw string with tags (unformatted still) ... Do we really need it? As such it's a BC break, and it's the exact same reason we added a new New features target master and thus would imply another interface. I think we should avoid that and solve the issue in Which takes me to my 2nd point; this PR will parse the tags twice IIUC. My main motivation for "formatAndWrap" was to be able to handle it at once. I think we should patch it instead, relying on a helper if really needed for clarity. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking about it also. I saw 3 ways:
Maybe there is a 4. option: I remove // Without wrapping
$output->getFormatter()->format($text);
// With wrapping
$output->getFormatter()->format(
PrettyWordWrappingHelper::wordwrap($text, 120)
); It would be clearer, but it supersede your
Yes, it is true. This is a side effect, which I think is necessary. Formatting (styling?) and wrapping can enough complex separately, that is why I think we should handle it separately. It causes more maintenance and extendable code. And this will be used in command line, the performance isn't so important. What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. option 5) Your "With wrapping" example to me equals option 6) deprecate In general i think the wordwrap helper should be an implementation detail of the output formatter. But we need some consensus first to move forward. Given a lot of work is done here already, perhaps we should finish it :) The bug is real.
You know best :) so far i thought it seemed reasonably possible ... but it's also still flawed currently 😅 I need to look closer at this new approach to see how it differs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ... I'm thinking about it ... |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -82,6 +82,8 @@ class Table | |
*/ | ||
private $columnWidths = []; | ||
private $columnMaxWidths = []; | ||
private $columnWordWrapCutOption = []; | ||
private $defaultColumnWordWrapCutOption = WordWrapperHelper::CUT_LONG_WORDS; | ||
|
||
private static $styles; | ||
|
||
|
@@ -228,13 +230,72 @@ public function setColumnWidths(array $widths) | |
* | ||
* @return $this | ||
*/ | ||
public function setColumnMaxWidth(int $columnIndex, int $width): self | ||
public function setColumnMaxWidth(int $columnIndex, int $width, int $cutOption = null): self | ||
{ | ||
if (!$this->output->getFormatter() instanceof WrappableOutputFormatterInterface) { | ||
throw new \LogicException(sprintf('Setting a maximum column width is only supported when using a "%s" formatter, got "%s".', WrappableOutputFormatterInterface::class, \get_class($this->output->getFormatter()))); | ||
} | ||
|
||
$this->columnMaxWidths[$columnIndex] = $width; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i would use |
||
$this->columnWordWrapCutOption[$columnIndex] = $cutOption; | ||
|
||
return $this; | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure we need all this extra API, i would keep it out at first. |
||
/** | ||
* Sets the cut options of a column. | ||
* | ||
* @param int $columnIndex | ||
* @param int $cutOptions | ||
* | ||
* @return $this | ||
*/ | ||
public function setColumnWordWrapCutOption(int $columnIndex, int $cutOptions): self | ||
{ | ||
$this->columnWordWrapCutOption[$columnIndex] = $cutOptions; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @param int $columnIndex | ||
* | ||
* @return int | ||
*/ | ||
public function getColumnWordWrapCutOption(int $columnIndex): int | ||
{ | ||
if (!\array_key_exists($columnIndex, $this->columnWordWrapCutOption) | ||
|| null === $this->columnWordWrapCutOption[$columnIndex] | ||
) { | ||
return $this->defaultColumnWordWrapCutOption; | ||
} | ||
|
||
return $this->columnWordWrapCutOption[$columnIndex]; | ||
} | ||
|
||
/** | ||
* @param int $defaultColumnWordWrapCutOption | ||
* | ||
* @return $this | ||
*/ | ||
public function setDefaultColumnWordWrapCutOption(int $defaultColumnWordWrapCutOption): self | ||
{ | ||
$this->defaultColumnWordWrapCutOption = $defaultColumnWordWrapCutOption; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @param array $columnWordWrapCutOptions | ||
* | ||
* @return $this | ||
*/ | ||
public function setColumnsWordWrapCutOptions(array $columnWordWrapCutOptions): self | ||
{ | ||
$this->columnWordWrapCutOption = []; | ||
foreach ($columnWordWrapCutOptions as $columnIndex => $columnOption) { | ||
$this->setColumnWordWrapCutOption($columnIndex, $columnOption); | ||
} | ||
|
||
return $this; | ||
} | ||
|
@@ -523,7 +584,11 @@ private function buildTableRows($rows) | |
// Remove any new line breaks and replace it with a new line | ||
foreach ($rows[$rowKey] as $column => $cell) { | ||
if (isset($this->columnMaxWidths[$column]) && Helper::strlenWithoutDecoration($formatter, $cell) > $this->columnMaxWidths[$column]) { | ||
$cell = $formatter->formatAndWrap($cell, $this->columnMaxWidths[$column]); | ||
$cell = $formatter->format($formatter->wordwrap( | ||
$cell, | ||
$this->columnMaxWidths[$column], | ||
$this->getColumnWordWrapCutOption($column) | ||
)); | ||
} | ||
if (!strstr($cell, "\n")) { | ||
continue; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not really needed is it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A protected property that you can only write? It looks a little bit bad concept. A property what you can only read, it could be justified, but a property without reading option, not really.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
im skeptical about the setter too :)
setters/getters can live independently IMHO, it's not needed for every property by definition or so. If we dont have reason to add a getter/settter i would avoid it (it introduces extra state).
In this case i'd prefer passing the cut options to formatAndWrap(), i dont see a real reason to control this default behavior.