[Console] Add Cursor class to control the cursor in the terminal

This commit is contained in:
Pierre du Plessis 2018-05-31 12:30:59 +02:00 committed by Fabien Potencier
parent 4dabd00ecd
commit 80d59d5c4a
6 changed files with 361 additions and 14 deletions

View File

@ -6,6 +6,7 @@ CHANGELOG
* `Command::setHidden()` is final since Symfony 5.1
* Add `SingleCommandApplication`
* Add `Cursor` class
5.0.0
-----

View 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];
}
}

View File

@ -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) {

View File

@ -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();
}
}

View 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);
}
}

View File

@ -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()