[Console] Add Cursor class to control the cursor in the terminal
This commit is contained in:
parent
4dabd00ecd
commit
80d59d5c4a
@ -6,6 +6,7 @@ CHANGELOG
|
||||
|
||||
* `Command::setHidden()` is final since Symfony 5.1
|
||||
* Add `SingleCommandApplication`
|
||||
* Add `Cursor` class
|
||||
|
||||
5.0.0
|
||||
-----
|
||||
|
137
src/Symfony/Component/Console/Cursor.php
Normal file
137
src/Symfony/Component/Console/Cursor.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?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;
|
||||
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @author Pierre du Plessis <pdples@gmail.com>
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
@ -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 @@ final class ProgressBar
|
||||
}
|
||||
|
||||
$this->startTime = time();
|
||||
$this->cursor = new Cursor($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -462,13 +465,12 @@ final class ProgressBar
|
||||
$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) {
|
||||
|
@ -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 @@ class QuestionHelper extends Helper
|
||||
*/
|
||||
private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string
|
||||
{
|
||||
$cursor = new Cursor($output, $inputStream);
|
||||
|
||||
$fullChoice = '';
|
||||
$ret = '';
|
||||
|
||||
@ -262,8 +265,7 @@ class QuestionHelper extends Helper
|
||||
} 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 @@ class QuestionHelper extends Helper
|
||||
}
|
||||
}
|
||||
|
||||
// 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('<hl>'.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $charactersEntered)).'</hl>');
|
||||
// Restore cursor position
|
||||
$output->write("\0338");
|
||||
$cursor->restorePosition();
|
||||
}
|
||||
}
|
||||
|
||||
|
208
src/Symfony/Component/Console/Tests/CursorTest.php
Normal file
208
src/Symfony/Component/Console/Tests/CursorTest.php
Normal file
@ -0,0 +1,208 @@
|
||||
<?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\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);
|
||||
}
|
||||
}
|
@ -759,7 +759,7 @@ class ProgressBarTest extends TestCase
|
||||
$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 @@ class ProgressBarTest extends TestCase
|
||||
{
|
||||
$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()
|
||||
|
Reference in New Issue
Block a user