From 2ad0290fcc4739ed1be6dd1665391558caa6ec6f Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 31 May 2018 12:30:59 +0200 Subject: [PATCH] Add Cursor class to control the cursor in the terminal --- src/Symfony/Component/Console/Cursor.php | 130 +++++++++++ .../Component/Console/Tests/CursorTest.php | 208 ++++++++++++++++++ 2 files changed, 338 insertions(+) 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/Cursor.php b/src/Symfony/Component/Console/Cursor.php new file mode 100644 index 0000000000000..37947cd0de22b --- /dev/null +++ b/src/Symfony/Component/Console/Cursor.php @@ -0,0 +1,130 @@ + + * + * 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; + + public function __construct(OutputInterface $output) + { + $this->output = $output; + } + + 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("\x1b[s"); + } + + public function restorePosition() + { + $this->output->write("\x1b[u"); + } + + public function hide() + { + $this->output->write("\x1b[?25l"); + } + + public function show() + { + $this->output->write("\x1b[?25h\x1b[?0c"); + } + + /** + * Clears all the output from the of the current line. + */ + public function clearLine() + { + $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(STDIN, "\033[6n"); + + $code = trim(fread(STDIN, 1024)); + + shell_exec(sprintf('stty %s', $sttyMode)); + + sscanf($code, "\033[%d;%dR", $row, $col); + + return [$col, $row]; + } +} diff --git a/src/Symfony/Component/Console/Tests/CursorTest.php b/src/Symfony/Component/Console/Tests/CursorTest.php new file mode 100644 index 0000000000000..3151575a9574e --- /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() + { + $this->stream = fopen('php://memory', 'r+'); + } + + protected function tearDown() + { + 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("\x1b[s", $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("\x1b[u", $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); + } +}