From 80d59d5c4ae45280bbe40fba91a158b1733df9e2 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 31 May 2018 12:30:59 +0200 Subject: [PATCH] [Console] Add Cursor class to control the cursor in the terminal --- src/Symfony/Component/Console/CHANGELOG.md | 1 + src/Symfony/Component/Console/Cursor.php | 137 ++++++++++++ .../Component/Console/Helper/ProgressBar.php | 10 +- .../Console/Helper/QuestionHelper.php | 15 +- .../Component/Console/Tests/CursorTest.php | 208 ++++++++++++++++++ .../Console/Tests/Helper/ProgressBarTest.php | 4 +- 6 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 src/Symfony/Component/Console/Cursor.php create mode 100644 src/Symfony/Component/Console/Tests/CursorTest.php diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 326a38505503..788bf4279a40 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * `Command::setHidden()` is final since Symfony 5.1 * Add `SingleCommandApplication` + * Add `Cursor` class 5.0.0 ----- diff --git a/src/Symfony/Component/Console/Cursor.php b/src/Symfony/Component/Console/Cursor.php new file mode 100644 index 000000000000..03fd5e0672b7 --- /dev/null +++ b/src/Symfony/Component/Console/Cursor.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @author Pierre du Plessis + */ +class Cursor +{ + private $output; + + private $input; + + public function __construct(OutputInterface $output, $input = STDIN) + { + $this->output = $output; + $this->input = $input; + } + + public function moveUp(int $lines = 1) + { + $this->output->write(sprintf("\x1b[%dA", $lines)); + } + + public function moveDown(int $lines = 1) + { + $this->output->write(sprintf("\x1b[%dB", $lines)); + } + + public function moveRight(int $columns = 1) + { + $this->output->write(sprintf("\x1b[%dC", $columns)); + } + + public function moveLeft(int $columns = 1) + { + $this->output->write(sprintf("\x1b[%dD", $columns)); + } + + public function moveToColumn(int $column) + { + $this->output->write(sprintf("\x1b[%dG", $column)); + } + + public function moveToPosition(int $column, int $row) + { + $this->output->write(sprintf("\x1b[%d;%dH", $row + 1, $column)); + } + + public function savePosition() + { + $this->output->write("\x1b7"); + } + + public function restorePosition() + { + $this->output->write("\x1b8"); + } + + public function hide() + { + $this->output->write("\x1b[?25l"); + } + + public function show() + { + $this->output->write("\x1b[?25h\x1b[?0c"); + } + + /** + * Clears all the output from the current line. + */ + public function clearLine(bool $fromCurrentPosition = false) + { + if (true === $fromCurrentPosition) { + $this->output->write("\x1b[K"); + } else { + $this->output->write("\x1b[2K"); + } + } + + /** + * Clears all the output from the cursors' current position to the end of the screen. + */ + public function clearOutput() + { + $this->output->write("\x1b[0J", false); + } + + /** + * Clears the entire screen. + */ + public function clearScreen() + { + $this->output->write("\x1b[2J", false); + } + + /** + * Returns the current cursor position as x,y coordinates. + */ + public function getCurrentPosition(): array + { + static $isTtySupported; + + if (null === $isTtySupported && \function_exists('proc_open')) { + $isTtySupported = (bool) @proc_open('echo 1 >/dev/null', [['file', '/dev/tty', 'r'], ['file', '/dev/tty', 'w'], ['file', '/dev/tty', 'w']], $pipes); + } + + if (!$isTtySupported) { + return [1, 1]; + } + + $sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + @fwrite($this->input, "\033[6n"); + + $code = trim(fread($this->input, 1024)); + + shell_exec(sprintf('stty %s', $sttyMode)); + + sscanf($code, "\033[%d;%dR", $row, $col); + + return [$col, $row]; + } +} diff --git a/src/Symfony/Component/Console/Helper/ProgressBar.php b/src/Symfony/Component/Console/Helper/ProgressBar.php index 83c7b7dd3bbc..715bfef211b2 100644 --- a/src/Symfony/Component/Console/Helper/ProgressBar.php +++ b/src/Symfony/Component/Console/Helper/ProgressBar.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Helper; +use Symfony\Component\Console\Cursor; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\ConsoleSectionOutput; @@ -47,6 +48,7 @@ final class ProgressBar private $overwrite = true; private $terminal; private $previousMessage; + private $cursor; private static $formatters; private static $formats; @@ -78,6 +80,7 @@ public function __construct(OutputInterface $output, int $max = 0, float $minSec } $this->startTime = time(); + $this->cursor = new Cursor($output); } /** @@ -462,13 +465,12 @@ private function overwrite(string $message): void $lines = floor(Helper::strlen($message) / $this->terminal->getWidth()) + $this->formatLineCount + 1; $this->output->clear($lines); } else { - // Erase previous lines if ($this->formatLineCount > 0) { - $message = str_repeat("\x1B[1A\x1B[2K", $this->formatLineCount).$message; + $this->cursor->moveUp($this->formatLineCount); } - // Move the cursor to the beginning of the line and erase the line - $message = "\x0D\x1B[2K$message"; + $this->cursor->moveToColumn(1); + $this->cursor->clearLine(); } } } elseif ($this->step > 0) { diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index a8aeb5807b59..797076a8bfaf 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Helper; +use Symfony\Component\Console\Cursor; use Symfony\Component\Console\Exception\MissingInputException; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -235,6 +236,8 @@ protected function writeError(OutputInterface $output, \Exception $error) */ private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string { + $cursor = new Cursor($output, $inputStream); + $fullChoice = ''; $ret = ''; @@ -262,8 +265,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu } elseif ("\177" === $c) { // Backspace Character if (0 === $numMatches && 0 !== $i) { --$i; - // Move cursor backwards - $output->write(sprintf("\033[%dD", s($fullChoice)->slice(-1)->width(false))); + $cursor->moveLeft(s($fullChoice)->slice(-1)->width(false)); $fullChoice = self::substr($fullChoice, 0, $i); } @@ -351,17 +353,14 @@ function ($match) use ($ret) { } } - // Erase characters from cursor to end of line - $output->write("\033[K"); + $cursor->clearLine(true); if ($numMatches > 0 && -1 !== $ofs) { - // Save cursor position - $output->write("\0337"); + $cursor->savePosition(); // Write highlighted text, complete the partially entered response $charactersEntered = \strlen(trim($this->mostRecentlyEnteredValue($fullChoice))); $output->write(''.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $charactersEntered)).''); - // Restore cursor position - $output->write("\0338"); + $cursor->restorePosition(); } } diff --git a/src/Symfony/Component/Console/Tests/CursorTest.php b/src/Symfony/Component/Console/Tests/CursorTest.php new file mode 100644 index 000000000000..08e84fa2cdd5 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/CursorTest.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Cursor; +use Symfony\Component\Console\Output\StreamOutput; + +class CursorTest extends TestCase +{ + protected $stream; + + protected function setUp(): void + { + $this->stream = fopen('php://memory', 'r+'); + } + + protected function tearDown(): void + { + fclose($this->stream); + $this->stream = null; + } + + public function testMoveUpOneLine() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveUp(); + + $this->assertEquals("\x1b[1A", $this->getOutputContent($output)); + } + + public function testMoveUpMultipleLines() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveUp(12); + + $this->assertEquals("\x1b[12A", $this->getOutputContent($output)); + } + + public function testMoveDownOneLine() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveDown(); + + $this->assertEquals("\x1b[1B", $this->getOutputContent($output)); + } + + public function testMoveDownMultipleLines() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveDown(12); + + $this->assertEquals("\x1b[12B", $this->getOutputContent($output)); + } + + public function testMoveLeftOneLine() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveLeft(); + + $this->assertEquals("\x1b[1D", $this->getOutputContent($output)); + } + + public function testMoveLeftMultipleLines() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveLeft(12); + + $this->assertEquals("\x1b[12D", $this->getOutputContent($output)); + } + + public function testMoveRightOneLine() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveRight(); + + $this->assertEquals("\x1b[1C", $this->getOutputContent($output)); + } + + public function testMoveRightMultipleLines() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveRight(12); + + $this->assertEquals("\x1b[12C", $this->getOutputContent($output)); + } + + public function testMoveToColumn() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveToColumn(6); + + $this->assertEquals("\x1b[6G", $this->getOutputContent($output)); + } + + public function testMoveToPosition() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveToPosition(18, 16); + + $this->assertEquals("\x1b[17;18H", $this->getOutputContent($output)); + } + + public function testClearLine() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->clearLine(); + + $this->assertEquals("\x1b[2K", $this->getOutputContent($output)); + } + + public function testSavePosition() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->savePosition(); + + $this->assertEquals("\x1b7", $this->getOutputContent($output)); + } + + public function testHide() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->hide(); + + $this->assertEquals("\x1b[?25l", $this->getOutputContent($output)); + } + + public function testShow() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->show(); + + $this->assertEquals("\x1b[?25h\x1b[?0c", $this->getOutputContent($output)); + } + + public function testRestorePosition() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->restorePosition(); + + $this->assertEquals("\x1b8", $this->getOutputContent($output)); + } + + public function testClearOutput() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->clearOutput(); + + $this->assertEquals("\x1b[0J", $this->getOutputContent($output)); + } + + public function testGetCurrentPosition() + { + $cursor = new Cursor($output = $this->getOutputStream()); + + $cursor->moveToPosition(10, 10); + $position = $cursor->getCurrentPosition(); + + $this->assertEquals("\x1b[11;10H", $this->getOutputContent($output)); + + $isTtySupported = (bool) @proc_open('echo 1 >/dev/null', [['file', '/dev/tty', 'r'], ['file', '/dev/tty', 'w'], ['file', '/dev/tty', 'w']], $pipes); + + if ($isTtySupported) { + // When tty is supported, we can't validate the exact cursor position since it depends where the cursor is when the test runs. + // Instead we just make sure that it doesn't return 1,1 + $this->assertNotEquals([1, 1], $position); + } else { + $this->assertEquals([1, 1], $position); + } + } + + protected function getOutputContent(StreamOutput $output) + { + rewind($output->getStream()); + + return str_replace(PHP_EOL, "\n", stream_get_contents($output->getStream())); + } + + protected function getOutputStream(): StreamOutput + { + return new StreamOutput($this->stream, StreamOutput::VERBOSITY_NORMAL); + } +} diff --git a/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php b/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php index b9b63c7df0c4..099f6aedf700 100644 --- a/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/ProgressBarTest.php @@ -759,7 +759,7 @@ public function testMultilineFormat() $this->assertEquals( ">---------------------------\nfoobar". $this->generateOutput("=========>------------------\nfoobar"). - "\x0D\x1B[2K\x1B[1A\x1B[2K". + "\x1B[1A\x1B[1G\x1B[2K". $this->generateOutput("============================\nfoobar"), stream_get_contents($output->getStream()) ); @@ -915,7 +915,7 @@ protected function generateOutput($expected) { $count = substr_count($expected, "\n"); - return "\x0D\x1B[2K".($count ? str_repeat("\x1B[1A\x1B[2K", $count) : '').$expected; + return ($count ? sprintf("\x1B[%dA\x1B[1G\x1b[2K", $count) : "\x1B[1G\x1B[2K").$expected; } public function testBarWidthWithMultilineFormat()