8000 [Console] Modify console output and print multiple modifyable sections by pierredup · Pull Request #24363 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Console] Modify console output and print multiple modifyable sections #24363

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

Closed
wants to merge 3 commits into from
Closed
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
1 change: 1 addition & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
-----

* added option to run suggested command if command is not found and only 1 alternative is available
* added option to modify console output and print multiple modifiable sections

4.0.0
-----
Expand Down
20 changes: 13 additions & 7 deletions src/Symfony/Component/Console/Helper/ProgressBar.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\Console\Helper;

use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Terminal;
Expand Down Expand Up @@ -375,15 +376,20 @@ private function overwrite(string $message): void
{
if ($this->overwrite) {
if (!$this->firstRun) {
// Move the cursor to the beginning of the line
$this->output->write("\x0D");
if ($this->output instanceof ConsoleSectionOutput) {
$lines = floor(Helper::strlen($message) / $this->terminal->getWidth()) + $this->formatLineCount + 1;
$this->output->clear($lines);
} else {
// Move the cursor to the beginning of the line
$this->output->write("\x0D");

// Erase the line
$this->output->write("\x1B[2K");
// Erase the line
$this->output->write("\x1B[2K");

// Erase previous lines
if ($this->formatLineCount > 0) {
$this->output->write(str_repeat("\x1B[1A\x1B[2K", $this->formatLineCount));
// Erase previous lines
if ($this->formatLineCount > 0) {
$this->output->write(str_repeat("\x1B[1A\x1B[2K", $this->formatLineCount));
}
}
}
} elseif ($this->step > 0) {
Expand Down
37 changes: 37 additions & 0 deletions src/Symfony/Component/Console/Helper/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@

namespace Symfony\Component\Console\Helper;

use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\RuntimeException;

/**
* Provides helpers to display a table.
Expand Down Expand Up @@ -70,6 +72,8 @@ class Table

private static $styles;

private $rendered = false;

public function __construct(OutputInterface $output)
{
$this->output = $output;
Expand Down Expand Up @@ -252,6 +256,25 @@ public function addRow($row)
return $this;
}

/**
* Adds a row to the table, and re-renders the table.
*/
public function appendRow($row): self
{
if (!$this->output instanceof ConsoleSectionOutput) {
throw new RuntimeException(sprintf('Output should be an instance of "%s" when calling "%s".', ConsoleSectionOutput::class, __METHOD__));
}

if ($this->rendered) {
$this->output->clear($this->calculateRowCount());
}

$this->addRow($row);
$this->render();

return $this;
}

public function setRow($column, array $row)
{
$this->rows[$column] = $row;
Expand Down Expand Up @@ -311,6 +334,7 @@ public function render()
$this->renderRowSeparator();

$this->cleanup();
$this->rendered = true;
}

/**
Expand Down Expand Up @@ -445,6 +469,19 @@ private function buildTableRows($rows)
});
}

private function calculateRowCount(): int
{
$numberOfRows = count(iterator_to_array($this->buildTableRows(array_merge($this->headers, array(new TableSeparator()), $this->rows))));

if ($this->headers) {
++$numberOfRows; // Add row for header separator
}

++$numberOfRows; // Add row for footer separator

return $numberOfRows;
}

/**
* fill rows that contains rowspan > 1.
*
Expand Down
9 changes: 9 additions & 0 deletions src/Symfony/Component/Console/Output/ConsoleOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
class ConsoleOutput extends StreamOutput implements ConsoleOutputInterface
{
private $stderr;
private $consoleSectionOutputs = array();

/**
* @param int $verbosity The verbosity level (one of the VERBOSITY constants in OutputInterface)
Expand All @@ -48,6 +49,14 @@ public function __construct(int $verbosity = self::VERBOSITY_NORMAL, bool $decor
}
}

/**
* Creates a new output section.
*/
public function section(): ConsoleSectionOutput
{
return new ConsoleSectionOutput($this->getStream(), $this->consoleSectionOutputs, $this->getVerbosity(), $this->isDecorated(), $this->getFormatter());
}

/**
* {@inheritdoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@

/**
* ConsoleOutputInterface is the interface implemented by ConsoleOutput class.
* This adds information about stderr output stream.
* This adds information about stderr and section output stream.
*
* @author Dariusz Górecki <darek.krk@gmail.com>
*
* @method ConsoleSectionOutput section() Creates a new output section
*/
interface ConsoleOutputInterface extends OutputInterface
{
Expand Down
133 changes: 133 additions & 0 deletions src/Symfony/Component/Console/Output/ConsoleSectionOutput.php < A93C clipboard-copy data-copy-feedback="Copied!" aria-label="Copy" value="src/Symfony/Component/Console/Output/ConsoleSectionOutput.php" data-view-component="true" class="Link--onHover color-fg-muted ml-2 mr-2">
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Output;

use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Terminal;

/**
* @author Pierre du Plessis <pdples@gmail.com>
* @author Gabriel Ostrolucký <gabriel.ostrolucky@gmail.com>
*/
class ConsoleSectionOutput extends StreamOutput
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this not be an output, taking the output as constructor arg instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be advantage of that when tradeoff is incompatible API? I would like to continue depending on OutputInterface with no changes. Client doesn't need to be aware that its output is going to appear in some section.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree with @ostrolucky here
let say I want to make app with 2 sections (top, bottom), and then I sent given sections as an input for some subcommands. They don't need to know if they run on full output or just section of it.

Copy link
Member
@keradus keradus Oct 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it makes sense to use this class totally without ConsoleOutput ?
especially when having the connection between them via &$sections

if not, I would make this class internal and expose only interface of it

Copy link
Contributor
@ostrolucky ostrolucky Oct 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine to construct this class completely without ConsoleOutput. Coupling it to ConsoleOutput more strictly have zero advantages

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this class should ever be used outside of ConsoleOutput, as it is an implementation detail of the console. I agree this class can be marked as internal

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, using this feature as an end user involves to use the public api of this class, marking it as internal without providing an interface would be weird. I'm not sure we want to introduce an interface for that, so letting it as is (maybe @final) with some documentation is probably fine.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I have shown in different comment, there are situations when its a must to manipulate with order of sections manually, which can be done only when having access to array container. section() method provides only the most basic operation (creating new section at the bottom of the screen)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the thing. manually creating a section without having it anchored to full output class is weird and imo shall not be allowed.
Currently Output is aware about sections and sections one about another. so we have big part which could contain small parts.
Even if it technically possible to create standalone section instance, what is the usage of that?

Thus section concrete class shall IMO be final, created only by output class itself, so interface to provide info how to use it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anything weird about that. ConsoleOutput currently serves only as a simple stupid helper for creating new output sections at the bottom of the screen. I'm out of luck if I need to create another new section at the top of existing sections. Anyway I could currently do this even if that class is final, because as I said ConsoleOutput serves as a stupid helper only and I can bypass it, final or not. So I don't see what does it solve.

I would need to ask symfony community to stop making final classes though, you cannot anticipate what will user space need to do. @final annotation is enough to mark extensions as not covered by BC policy. I wouldn't even do that though, this class has lean API and high potential to be further extended.

Marking it @internal is out of the question as @chalasr explained.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree about all final-about statements. But I guess I'm just defensive.

No, he didn't ;) he said it would be weird when done without providing an interface

If you construct section out of the box without ConsoleOutput helping here, you need to manually interact with other sections or have that &$sections. Yet, it's private in both, SectionOutput and ConsoleOutput. It will be way cleaner if all will be done via a helper, if you need to inject a section (put it not at the end) after some of them were already created, adjust the helper to allows that.
I'm big critic of passing reference around different instances and manually managing them, so if we need to have them, let us make it internal implementation detail, not sth that user need to deal if he try to not use helper.

{
private $content = array();
private $lines = 0;
private $sections;
private $terminal;

/**
* @param resource $stream
* @param ConsoleSectionOutput[] $sections
*/
public function __construct($stream, array &$sections, int $verbosity, bool $decorated, OutputFormatterInterface $formatter)
{
parent::__construct($stream, $verbosity, $decorated, $formatter);
array_unshift($sections, $this);
$this->sections = &$sections;
$this->terminal = new Terminal();
}

/**
* Clears previous output for this section.
*
* @param int $lines Number of lines to clear. If null, then the entire output of this section is cleared
*/
public function clear(int $lines = null)
{
if (empty($this->content) || !$this->isDecorated()) {
return;
}

if ($lines) {
\array_splice($this->content, -($lines * 2)); // Multiply lines by 2 to cater for each new line added between content
} else {
$lines = $this->lines;
$this->content = array();
}

$this->lines -= $lines;

parent::doWrite($this->popStreamContentUntilCurrentSection($lines), false);
}

/**
* Overwrites the previous output with a new message.
*
* @param array|string $message
*/
public function overwrite($message)
{
$this->clear();
$this->writeln($message);
}

public function getContent(): string
{
return implode('', $this->content);
}

/**
* {@inheritdoc}
*/
protected function doWrite($message, $newline)
{
if (!$this->isDe EED3 corated()) {
return parent::doWrite($message, $newline);
}

$erasedContent = $this->popStreamContentUntilCurrentSection();

foreach (explode(PHP_EOL, $message) as $lineContent) {
$this->lines += ceil($this->getDisplayLength($lineContent) / $this->terminal->getWidth()) ?: 1;
$this->content[] = $lineContent;
$this->content[] = PHP_EOL;
}

parent::doWrite($message, true);
parent::doWrite($erasedContent, false);
}

/**
* At initial stage, cursor is at the end of stream output. This method makes cursor crawl upwards until it hits
* current section. Then it erases content it crawled through. Optionally, it erases part of current section too.
*/
private function popStreamContentUntilCurrentSection(int $numberOfLinesToClearFromCurrentSection = 0): string
{
$numberOfLinesToClear = $numberOfLinesToClearFromCurrentSection;
$erasedContent = array();

foreach ($this->sections as $section) {
if ($section === $this) {
break;
}

$numberOfLinesToClear += $section->lines;
$erasedContent[] = $section->getContent();
}

if ($numberOfLinesToClear > 0) {
// move cursor up n lines
parent::doWrite(sprintf("\x1b[%dA", $numberOfLinesToClear), false);
// erase to end of screen
parent::doWrite("\x1b[0J", false);
}

return implode('', array_reverse($erasedContent));
}

private function getDisplayLength(string $text): string
{
return Helper::strlenWithoutDecoration($this->getFormatter(), str_replace("\t", ' ', $text));
}
}
84 changes: 84 additions & 0 deletions src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
namespace Symfony\Component\Console\Tests\Helper;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\StreamOutput;

/**
Expand Down Expand Up @@ -310,6 +312,88 @@ public function testOverwriteWithShorterLine()
);
}

public function testOverwriteWithSectionOutput()
{
$sections = array();
$stream = $this->getOutputStream(true);
$output = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter());

$bar = new ProgressBar($output, 50);
$bar->start();
$bar->display();
$bar->advance();
$bar->advance();
1CF5
rewind($output->getStream());
$this->assertEquals(
' 0/50 [>---------------------------] 0%'.PHP_EOL.
"\x1b[1A\x1b[0J".' 0/50 [>---------------------------] 0%'.PHP_EOL.
"\x1b[1A\x1b[0J".' 1/50 [>---------------------------] 2%'.PHP_EOL.
"\x1b[1A\x1b[0J".' 2/50 [=>--------------------------] 4%'.PHP_EOL,
stream_get_contents($output->getStream())
);
}

public function testOverwriteMultipleProgressBarsWithSectionOutputs()
{
$sections = array();
$stream = $this->getOutputStream(true);
$output1 = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter());
$output2 = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter());

$progress = new ProgressBar($output1, 50);
$progress2 = new ProgressBar($output2, 50);

$progress->start();
$progress2->start();

$progress2->advance();
$progress->advance();

rewind($stream->getStream());

$this->assertEquals(
' 0/50 [>---------------------------] 0%'.PHP_EOL.
' 0/50 [>---------------------------] 0%'.PHP_EOL.
"\x1b[1A\x1b[0J".' 1/50 [>---------------------------] 2%'.PHP_EOL.
"\x1b[2A\x1b[0J".' 1/50 [>---------------------------] 2%'.PHP_EOL.
"\x1b[1A\x1b[0J".' 1/50 [>---------------------------] 2%'.PHP_EOL.
' 1/50 [>---------------------------] 2%'.PHP_EOL,
stream_get_contents($stream->getStream())
);
}

public function testMultipleSectionsWithCustomFormat()
{
$sections = array();
$stream = $this->getOutputStream(true);
$output1 = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter());
$output2 = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter());

ProgressBar::setFormatDefinition('test', '%current%/%max% [%bar%] %percent:3s%% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.');

$progress = new ProgressBar($output1, 50);
$progress2 = new ProgressBar($output2, 50);
$progress2->setFormat('test');

$progress->start();
$progress2->start();

$progress->advance();
$progress2->advance();

rewind($stream->getStream());

$this->assertEquals(' 0/50 [>---------------------------] 0%'.PHP_EOL.
' 0/50 [>] 0% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.'.PHP_EOL.
"\x1b[4A\x1b[0J".' 0/50 [>] 0% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.'.PHP_EOL.
"\x1b[3A\x1b[0J".' 1/50 [>---------------------------] 2%'.PHP_EOL.
' 0/50 [>] 0% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.'.PHP_EOL.
"\x1b[3A\x1b[0J".' 1/50 [>] 2% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.'.PHP_EOL,
stream_get_contents($stream->getStream())
);
}

public function testStartWithMax()
{
$bar = new ProgressBar($output = $this->getOutputStream());
Expand Down
Loading
0