diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 326a385055035..788bf4279a40c 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 0000000000000..03fd5e0672b7c --- /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 83c7b7dd3bbc5..715bfef211b20 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 a8aeb5807b599..797076a8bfaf6 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 0000000000000..08e84fa2cdd55 --- /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 b9b63c7df0c41..099f6aedf7005 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() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy