Skip to content

Commit

Permalink
feature #24363 [Console] Modify console output and print multiple mod…
Browse files Browse the repository at this point in the history
…ifyable sections (pierredup)

This PR was squashed before being merged into the 4.1-dev branch (closes #24363).

Discussion
----------

[Console] Modify console output and print multiple modifyable sections

| Q             | A
| ------------- | ---
| Branch?       | 4.1
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | TBD
| Fixed tickets |
| License       | MIT
| Doc PR        | symfony/symfony-docs#9304

Add support to create different output sections for the console output.
Each section is it's own 'stream' of output, where the output can be modified (even if there are other output after it). This allows you to modify previous output in the console, either by appending new lines, modifying previous lines or clearing the output. Modifying a sections output doesn't affect the output after that or in other sections.

Some examples of what can be done:

**Overwriting content in a previous section:**

Code:

```php
$section1 = $output->section();
$section2 = $output->section();

$section1->writeln("<comment>Doing something</comment>\n");
usleep(500000);
$section2->writeln('<info>Result of first operation</info>');
usleep(500000);

$section1->overwrite("<comment>Doing something else</comment>\n");
usleep(500000);
$section2->writeln('<info>Result of second operation</info>');
usleep(500000);

$section1->overwrite("<comment>Finishing</comment>\n");
usleep(500000);
$section2->writeln('<info>Last Result</info>');
```

Result:
![overwrite-append](https://user-images.githubusercontent.com/144858/30975030-769f2c46-a471-11e7-819f-c3698b43f0af.gif)

**Multiple Progress Bars:**

Code:

```php
$section1 = $output->section();
$section2 = $output->section();

$progress = new ProgressBar($section1);
$progress2 = new ProgressBar($section2);

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

$c = 0;
while (++$c < 100) {
    $progress->advance();

    if ($c % 2 === 0) {
        $progress2->advance(4);
    }

    usleep(500000);
}
```

Result:
![multiple-progress](https://user-images.githubusercontent.com/144858/30975119-b63222be-a471-11e7-89aa-a555cdf3d2e0.gif)

**Modifying content of a table & updating a progress bar:**

Code:

```php
$section1 = $output->section();
$section2 = $output->section();

$progress = new ProgressBar($section1);
$table = new Table($section2);

$table->addRow(['Row 1']);
$table->render();

$progress->start(5);

$c = 0;
while (++$c < 5) {
    $table->appendRow(['Row '.($c + 1)]);

    $progress->advance();

    usleep(500000);
}

$progress->finish();
$section1->clear();
```

Result:
![progress-table](https://user-images.githubusercontent.com/144858/30975176-e332499c-a471-11e7-9d4f-f58b464a53c2.gif)

**Example with Symfony Installer:***

Before:
![sf-installer-old](https://user-images.githubusercontent.com/144858/30975291-40f22106-a472-11e7-8836-bc39139c2d30.gif)

After:
![sf-installer](https://user-images.githubusercontent.com/144858/30975302-4a00acf4-a472-11e7-83ba-88ea9d0f0f3f.gif)

TODO:
- [x] Add unit tests

Commits
-------

9ec51a1 [Console] Modify console output and print multiple modifyable sections
  • Loading branch information
fabpot committed Mar 20, 2018
2 parents 1fffb85 + 9ec51a1 commit a5dbc68
Show file tree
Hide file tree
Showing 9 changed files with 531 additions and 8 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
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
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 @@ -376,15 +377,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
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 @@ -75,6 +77,8 @@ class Table

private static $styles;

private $rendered = false;

public function __construct(OutputInterface $output)
{
$this->output = $output;
Expand Down Expand Up @@ -257,6 +261,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 @@ -316,6 +339,7 @@ public function render()
$this->renderRowSeparator(self::SEPARATOR_BOTTOM);

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

/**
Expand Down Expand Up @@ -460,6 +484,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
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
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
@@ -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
{
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->isDecorated()) {
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
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();

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

0 comments on commit a5dbc68

Please sign in to comment.