Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Console] Support a set of control keys and key combinations in QuestionHelper #48287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 183 additions & 4 deletions src/Symfony/Component/Console/Helper/QuestionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\StreamableInputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\StreamOutput;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Terminal;

use function Symfony\Component\String\s;
Expand All @@ -34,6 +37,25 @@
*/
class QuestionHelper extends Helper
{
private const KEY_ALT_B = "\033b";
private const KEY_ALT_F = "\033f";
private const KEY_ARROW_LEFT = "\033[D";
private const KEY_ARROW_RIGHT = "\033[C";
private const KEY_BACKSPACE = "\177";
private const KEY_CTRL_A = "\001";
private const KEY_CTRL_B = "\002";
private const KEY_CTRL_E = "\005";
private const KEY_CTRL_F = "\006";
private const KEY_CTRL_H = "\010";
private const KEY_CTRL_ARROW_LEFT = "\033[1;5D";
private const KEY_CTRL_ARROW_RIGHT = "\033[1;5C";
private const KEY_CTRL_SHIFT_ARROW_LEFT = "\033[1;6D";
private const KEY_CTRL_SHIFT_ARROW_RIGHT = "\033[1;6C";
private const KEY_DELETE = "\033[3~";
private const KEY_END = "\033[F";
private const KEY_ENTER = "\n";
private const KEY_HOME = "\033[H";

private static bool $stty = true;
private static bool $stdinIsInteractive;

Expand Down Expand Up @@ -122,7 +144,7 @@
stream_set_blocking($inputStream, true);
}

$ret = $this->readInput($inputStream, $question);
$ret = $this->readInput($inputStream, $question, $output);

if (!$isBlocked) {
stream_set_blocking($inputStream, false);
Expand Down Expand Up @@ -499,13 +521,12 @@
* @param resource $inputStream The handler resource
* @param Question $question The question being asked
*/
private function readInput($inputStream, Question $question): string|false
private function readInput($inputStream, Question $question, OutputInterface $output): string|false
{
if (!$question->isMultiline()) {
$cp = $this->setIOCodepage();
$ret = fgets($inputStream, 4096);

return $this->resetIOCodepage($cp, $ret);
return $this->resetIOCodepage($cp, $this->handleCliInput($inputStream, $output));
}

$multiLineStreamReader = $this->cloneInputStream($inputStream);
Expand Down Expand Up @@ -586,4 +607,162 @@

return $cloneStream;
}

/**
* @param resource $inputStream The handler resource
*/
private function handleCliInput($inputStream, OutputInterface $output): string|false
{
if (!Terminal::hasSttyAvailable() || '/' !== \DIRECTORY_SEPARATOR) {
return fgets($inputStream, 4096);
}

// Memory not supported for stream_select
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
// Check for stdout and stderr because helpers are using stderr by default
$isOutputSupported = $output instanceof StreamOutput ? \in_array(stream_get_meta_data($output->getStream())['uri'] ?? null, ['php://stdout', 'php://stderr', 'php://output']) :
($output instanceof SymfonyStyle && $output->getOutput() instanceof StreamOutput && \in_array(stream_get_meta_data($output->getOutput()->getStream())['uri'] ?? null, ['php://stdout', 'php://stderr', 'php://output']));

Check failure on line 624 in src/Symfony/Component/Console/Helper/QuestionHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedInterfaceMethod

src/Symfony/Component/Console/Helper/QuestionHelper.php:624:150: UndefinedInterfaceMethod: Method Symfony\Component\Console\Output\OutputInterface::getStream does not exist (see https://psalm.dev/181)

Check failure on line 624 in src/Symfony/Component/Console/Helper/QuestionHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedInterfaceMethod

src/Symfony/Component/Console/Helper/QuestionHelper.php:624:150: UndefinedInterfaceMethod: Method Symfony\Component\Console\Output\OutputInterface::getStream does not exist (see https://psalm.dev/181)
$sttyMode = shell_exec('stty -g');
// Disable icanon (so we can fread each keypress)
shell_exec('stty -icanon -echo');

if ($isOutputSupported) {
$originalOutput = $output;
// This is needed for the input handling, when a question is in a section because then the inout is handled after the section
// Verbosity level is set to normal to see the input because using quiet would not show in input
$output = new ConsoleOutput();
}

$cursor = new Cursor($output);
$startXPos = $cursor->getCurrentPosition()[0];
$pressedKey = false;
$ret = [];
$currentInputXPos = 0;

while (!feof($inputStream) && self::KEY_ENTER !== $pressedKey) {
$read = [$inputStream];
$write = $except = null;
while ($isStdin && 0 === @stream_select($read, $write, $except, 0, 100)) {
// Give signal handlers a chance to run
$read = [$inputStream];
}
$pressedKey = fread($inputStream, 1);

if ((false === $pressedKey || 0 === \ord($pressedKey)) && empty($ret)) {
// Reset stty so it behaves normally again
shell_exec('stty '.$sttyMode);

return false;
}

$unreadBytes = stream_get_meta_data($inputStream)['unread_bytes'];
if ("\033" === $pressedKey && 0 < $unreadBytes) {
$pressedKey .= fread($inputStream, 1);
if (91 === \ord($pressedKey[1]) && 1 < $unreadBytes) {
// Ctrl keys / key combinations need at least 3 chars
$pressedKey .= fread($inputStream, 1);
if (isset($pressedKey[2]) && 51 === \ord($pressedKey[2]) && 2 < $unreadBytes) {
// Del needs 4 chars
$pressedKey .= fread($inputStream, 1);
}
if (isset($pressedKey[2]) && 49 === \ord($pressedKey[2]) && 2 < $unreadBytes) {
// Ctrl + arrow left/right needs 6 chars
$pressedKey .= fread($inputStream, 3);
}
}
} elseif ("\303" === $pressedKey && 0 < $unreadBytes) {
// Special chars need 2 chars
$pressedKey .= fread($inputStream, 1);
}

switch (true) {
case self::KEY_ARROW_LEFT === $pressedKey && $currentInputXPos > 0:
case self::KEY_CTRL_B === $pressedKey && $currentInputXPos > 0:
$cursor->moveLeft();
--$currentInputXPos;
break;
case self::KEY_ARROW_RIGHT === $pressedKey && $currentInputXPos < \count($ret):
case self::KEY_CTRL_F === $pressedKey && $currentInputXPos < \count($ret):
$cursor->moveRight();
++$currentInputXPos;
break;
case self::KEY_CTRL_ARROW_LEFT === $pressedKey && $currentInputXPos > 0:
case self::KEY_ALT_B === $pressedKey && $currentInputXPos > 0:
case self::KEY_CTRL_SHIFT_ARROW_LEFT === $pressedKey && $currentInputXPos > 0:
do {
$cursor->moveLeft();
--$currentInputXPos;
} while ($currentInputXPos > 0 && (1 < \strlen($ret[$currentInputXPos - 1]) || preg_match('/\w/', $ret[$currentInputXPos - 1])));
break;
case self::KEY_CTRL_ARROW_RIGHT === $pressedKey && $currentInputXPos < \count($ret):
case self::KEY_ALT_F === $pressedKey && $currentInputXPos < \count($ret):
case self::KEY_CTRL_SHIFT_ARROW_RIGHT === $pressedKey && $currentInputXPos < \count($ret):
do {
$cursor->moveRight();
++$currentInputXPos;
} while ($currentInputXPos < \count($ret) && (1 < \strlen($ret[$currentInputXPos]) || preg_match('/\w/', $ret[$currentInputXPos])));
break;
case self::KEY_CTRL_H === $pressedKey && $currentInputXPos > 0:
case self::KEY_BACKSPACE === $pressedKey && $currentInputXPos > 0:
array_splice($ret, $currentInputXPos - 1, 1);
$cursor->moveToColumn($startXPos);
if ($isOutputSupported) {
$output->write(implode('', $ret));
}
$cursor->clearLineAfter()
->moveToColumn(($currentInputXPos + $startXPos) - 1);
--$currentInputXPos;
break;
case self::KEY_DELETE === $pressedKey && $currentInputXPos < \count($ret):
array_splice($ret, $currentInputXPos, 1);
$cursor->moveToColumn($startXPos);
if ($isOutputSupported) {
$output->write(implode('', $ret));
}
$cursor->clearLineAfter()
->moveToColumn($currentInputXPos + $startXPos);
break;
case self::KEY_HOME === $pressedKey:
case self::KEY_CTRL_A === $pressedKey:
$cursor->moveToColumn($startXPos);
$currentInputXPos = 0;
break;
case self::KEY_END === $pressedKey:
case self::KEY_CTRL_E === $pressedKey:
$cursor->moveToColumn($startXPos + \count($ret));
$currentInputXPos = \count($ret);
break;
case !preg_match('@[[:cntrl:]]@', $pressedKey):
if ($currentInputXPos >= 0 && $currentInputXPos < \count($ret)) {
array_splice($ret, $currentInputXPos, 0, $pressedKey);
$cursor->moveToColumn($startXPos);
if ($isOutputSupported) {
$output->write(implode('', $ret));
}
$cursor->clearLineAfter()
->moveToColumn($currentInputXPos + $startXPos + 1);
} else {
$ret[] = $pressedKey;
if ($isOutputSupported) {
$output->write($pressedKey);
}
}
++$currentInputXPos;
break;
default:
break;
}
}

if ($isOutputSupported) {
// Clear the output to write it to the original output
$cursor->moveToColumn($startXPos)->clearLineAfter();
$originalOutput->writeln(implode('', $ret));
}

// Reset stty so it behaves normally again
shell_exec('stty '.$sttyMode);

return implode('', $ret);
}
}
5 changes: 5 additions & 0 deletions src/Symfony/Component/Console/Style/SymfonyStyle.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public function __construct(
parent::__construct($output);
}

public function getOutput(): OutputInterface
{
return $this->output;
}

/**
* Formats a message as a block of text.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\StreamOutput;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Terminal;
use Symfony\Component\Console\Tester\ApplicationTester;

Expand All @@ -32,6 +34,25 @@
*/
class QuestionHelperTest extends AbstractQuestionHelperTestCase
{
private const KEY_ALT_B = "\033b";
private const KEY_ALT_F = "\033f";
private const KEY_ARROW_LEFT = "\033[D";
private const KEY_ARROW_RIGHT = "\033[C";
private const KEY_BACKSPACE = "\177";
private const KEY_CTRL_A = "\001";
private const KEY_CTRL_B = "\002";
private const KEY_CTRL_E = "\005";
private const KEY_CTRL_F = "\006";
private const KEY_CTRL_H = "\010";
private const KEY_CTRL_ARROW_LEFT = "\033[1;5D";
private const KEY_CTRL_ARROW_RIGHT = "\033[1;5C";
private const KEY_CTRL_SHIFT_ARROW_LEFT = "\033[1;6D";
private const KEY_CTRL_SHIFT_ARROW_RIGHT = "\033[1;6C";
private const KEY_DELETE = "\033[3~";
private const KEY_END = "\033[F";
private const KEY_ENTER = "\n";
private const KEY_HOME = "\033[H";

public function testAskChoice()
{
$questionHelper = new QuestionHelper();
Expand Down Expand Up @@ -172,6 +193,56 @@ public function testAsk()
$this->assertEquals('What time is it?', stream_get_contents($output->getStream()));
}

/**
* @dataProvider getAskInputWithControlsData
*/
public function testAskInputWithControls(string $input, string $expected)
{
if (!Terminal::hasSttyAvailable()) {
$this->markTestSkipped('`stty` is required to test autocomplete functionality');
}
$dialog = new QuestionHelper();

$inputStream = $this->getInputStream($input.self::KEY_ENTER);

$question = new Question('Question?');
$this->assertEquals($expected, $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question));
}

public function getAskInputWithControlsData()
{
return [
['test1234,;.:_-+*\'#\\()/@!äßñ', 'test1234,;.:_-+*\'#\\()/@!äßñ'],
['tet'.self::KEY_ARROW_LEFT.'s', 'test'],
['tesñt'.self::KEY_ARROW_LEFT.self::KEY_BACKSPACE, 'test'],
['tesst'.self::KEY_CTRL_B.self::KEY_CTRL_H, 'test'],
['tes@t'.self::KEY_ARROW_LEFT.self::KEY_ARROW_LEFT.self::KEY_DELETE, 'test'],
['test'.self::KEY_ARROW_LEFT.self::KEY_ARROW_LEFT.'1'.self::KEY_ARROW_RIGHT.'2', 'te1s2t'],
['test'.self::KEY_ARROW_LEFT.self::KEY_ARROW_LEFT.'1'.self::KEY_CTRL_F.'2', 'te1s2t'],
['es'.self::KEY_HOME.'t'.self::KEY_END.'t', 'test'],
['es'.self::KEY_CTRL_A.'t'.self::KEY_CTRL_E.'t', 'test'],
['t e@sñt'.self::KEY_CTRL_ARROW_LEFT.self::KEY_BACKSPACE.self::KEY_CTRL_ARROW_LEFT.self::KEY_BACKSPACE, 'tesñt'],
['t e.sät'.self::KEY_CTRL_ARROW_LEFT.self::KEY_CTRL_ARROW_LEFT.self::KEY_BACKSPACE.self::KEY_CTRL_ARROW_RIGHT.self::KEY_DELETE, 'tesät'],
['t e-sñt'.self::KEY_CTRL_SHIFT_ARROW_LEFT.self::KEY_BACKSPACE.self::KEY_ALT_B.self::KEY_BACKSPACE, 'tesñt'],
['t e?sät'.self::KEY_CTRL_ARROW_LEFT.self::KEY_CTRL_ARROW_LEFT.self::KEY_CTRL_ARROW_LEFT.self::KEY_CTRL_SHIFT_ARROW_RIGHT.self::KEY_DELETE.self::KEY_ALT_F.self::KEY_DELETE, 'tesät'],
];
}

public function testAskInputWithControlsInSection()
{
if (!Terminal::hasSttyAvailable()) {
$this->markTestSkipped('`stty` is required to test autocomplete functionality');
}

$output = $this->createOutputInterface();
$sections = [];
$section = new ConsoleSectionOutput($output->getStream(), $sections, $output->getVerbosity(), false, new OutputFormatter());
$inputStream = $this->getInputStream('es'.self::KEY_HOME.'t'.self::KEY_END.'t'.self::KEY_ENTER);
$io = new SymfonyStyle($this->createStreamableInputInterfaceMock($inputStream), $section);

$this->assertEquals('test', $io->ask('Test the input behavior'));
}

public function testAskNonTrimmed()
{
$dialog = new QuestionHelper();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ public function testOutputProgressIterate()
$this->assertStringEqualsFile($outputFilepath, $this->tester->getDisplay(true));
}

public function testGetOutput()
{
$output = $this->createMock(OutputInterface::class);
$io = new SymfonyStyle($this->createMock(InputInterface::class), $output);

$this->assertSame($output, $io->getOutput());
}

public function testGetErrorStyle()
{
$input = $this->createMock(InputInterface::class);
Expand Down
Loading