Skip to content

Commit 51b434d

Browse files
committed
[Console] Support left and right arrow for QuestionHelper
1 parent 521d210 commit 51b434d

File tree

2 files changed

+165
-4
lines changed

2 files changed

+165
-4
lines changed

src/Symfony/Component/Console/Helper/QuestionHelper.php

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434
*/
3535
class QuestionHelper extends Helper
3636
{
37+
public const KEY_ARROW_RIGHT = "\033[C";
38+
public const KEY_ARROW_LEFT = "\033[D";
39+
public const KEY_ENTER = "\n";
40+
public const KEY_DELETE = "\e[3~";
41+
public const KEY_CTRL_A = "\001";
42+
public const KEY_CTRL_B = "\002";
43+
public const KEY_CTRL_E = "\005";
44+
public const KEY_CTRL_F = "\006";
45+
public const KEY_CTRL_H = "\010";
46+
public const KEY_BACKSPACE = "\177";
47+
3748
/**
3849
* @var resource|null
3950
*/
@@ -123,7 +134,7 @@ private function doAsk(OutputInterface $output, Question $question): mixed
123134
}
124135

125136
if (false === $ret) {
126-
$ret = $this->readInput($inputStream, $question);
137+
$ret = $this->readInput($inputStream, $question, $output);
127138
if (false === $ret) {
128139
throw new MissingInputException('Aborted.');
129140
}
@@ -511,13 +522,12 @@ private function isInteractiveInput($inputStream): bool
511522
* @param resource $inputStream The handler resource
512523
* @param Question $question The question being asked
513524
*/
514-
private function readInput($inputStream, Question $question): string|false
525+
private function readInput($inputStream, Question $question, OutputInterface $output): string|false
515526
{
516527
if (!$question->isMultiline()) {
517528
$cp = $this->setIOCodepage();
518-
$ret = fgets($inputStream, 4096);
519529

520-
return $this->resetIOCodepage($cp, $ret);
530+
return $this->resetIOCodepage($cp, $this->handleCliInput($inputStream, $output));
521531
}
522532

523533
$multiLineStreamReader = $this->cloneInputStream($inputStream);
@@ -598,4 +608,125 @@ private function cloneInputStream($inputStream)
598608

599609
return $cloneStream;
600610
}
611+
612+
/**
613+
* @param resource $inputStream The handler resource
614+
*/
615+
private function handleCliInput($inputStream, OutputInterface $output): string|false
616+
{
617+
if (!Terminal::hasSttyAvailable()) {
618+
return fgets($inputStream, 4096);
619+
}
620+
621+
// memory not supported for stream_select
622+
$isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
623+
$sttyMode = shell_exec('stty -g');
624+
// Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
625+
shell_exec('stty -icanon -echo');
626+
627+
$cursor = new Cursor($output);
628+
$startXPos = $cursor->getCurrentPosition()[0];
629+
$pressedKey = false;
630+
$ret = [];
631+
$currentInputXPos = 0;
632+
633+
while (!feof($inputStream) && self::KEY_ENTER !== $pressedKey) {
634+
$read = [$inputStream];
635+
$write = $except = null;
636+
while ($isStdin && 0 === @stream_select($read, $write, $except, 0, 100)) {
637+
// Give signal handlers a chance to run
638+
$read = [$inputStream];
639+
}
640+
$pressedKey = fread($inputStream, 1);
641+
642+
if ((false === $pressedKey || 0 === \ord($pressedKey)) && empty($ret)) {
643+
// Reset stty so it behaves normally again
644+
shell_exec('stty '.$sttyMode);
645+
646+
return false;
647+
}
648+
649+
if ("\033" === $pressedKey) {
650+
// ctrl keys need at least 3 chars
651+
$pressedKey .= fread($inputStream, 2);
652+
if (isset($pressedKey[2]) && 51 === \ord($pressedKey[2])) {
653+
// del needs 4
654+
$pressedKey .= fread($inputStream, 1);
655+
}
656+
} elseif ("\303" === $pressedKey) {
657+
// special chars need 2 chars
658+
$pressedKey .= fread($inputStream, 1);
659+
}
660+
661+
switch (true) {
662+
case self::KEY_ARROW_LEFT === $pressedKey && $currentInputXPos > 0:
663+
case self::KEY_CTRL_B === $pressedKey && $currentInputXPos > 0:
664+
$cursor->moveLeft();
665+
--$currentInputXPos;
666+
break;
667+
case self::KEY_ARROW_RIGHT === $pressedKey && $currentInputXPos < \count($ret):
668+
case self::KEY_CTRL_F === $pressedKey && $currentInputXPos < \count($ret):
669+
$cursor->moveRight();
670+
++$currentInputXPos;
671+
break;
672+
case self::KEY_CTRL_H === $pressedKey && $currentInputXPos > 0:
673+
case self::KEY_BACKSPACE === $pressedKey && $currentInputXPos > 0:
674+
array_splice($ret, $currentInputXPos - 1, 1);
675+
$cursor->moveToColumn($startXPos);
676+
if ($isStdin) {
677+
$output->write(implode('', $ret));
678+
}
679+
$cursor->clearLineAfter()
680+
->moveToColumn(($currentInputXPos + $startXPos) - 1);
681+
--$currentInputXPos;
682+
break;
683+
case self::KEY_DELETE === $pressedKey && $currentInputXPos < \count($ret):
684+
array_splice($ret, $currentInputXPos, 1);
685+
$cursor->moveToColumn($startXPos);
686+
if ($isStdin) {
687+
$output->write(implode('', $ret));
688+
}
689+
$cursor->clearLineAfter()
690+
->moveToColumn($currentInputXPos + $startXPos);
691+
break;
692+
case self::KEY_CTRL_A === $pressedKey:
693+
$cursor->moveToColumn($startXPos);
694+
$currentInputXPos = 0;
695+
break;
696+
case self::KEY_CTRL_E === $pressedKey:
697+
$cursor->moveToColumn($startXPos + \count($ret));
698+
$currentInputXPos = \count($ret);
699+
break;
700+
case !preg_match('@[[:cntrl:]]@', $pressedKey):
701+
if ($currentInputXPos >= 0 && $currentInputXPos < \count($ret)) {
702+
array_splice($ret, $currentInputXPos, 0, $pressedKey);
703+
$cursor->moveToColumn($startXPos);
704+
if ($isStdin) {
705+
$output->write(implode('', $ret));
706+
}
707+
$cursor->clearLineAfter()
708+
->moveToColumn($currentInputXPos + $startXPos + 1);
709+
} else {
710+
$ret[] = $pressedKey;
711+
if ($isStdin) {
712+
$output->write($pressedKey);
713+
}
714+
}
715+
++$currentInputXPos;
716+
break;
717+
case self::KEY_ENTER === $pressedKey:
718+
if ($isStdin) {
719+
$output->writeln('');
720+
}
721+
break;
722+
default:
723+
break;
724+
}
725+
}
726+
727+
// Reset stty so it behaves normally again
728+
shell_exec('stty '.$sttyMode);
729+
730+
return implode('', $ret);
731+
}
601732
}

src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,36 @@ public function testAsk()
172172
$this->assertEquals('What time is it?', stream_get_contents($output->getStream()));
173173
}
174174

175+
/**
176+
* @dataProvider getAskInputWithControlsData
177+
*/
178+
public function testAskInputWithControls(string $input, string $expected)
179+
{
180+
if (!Terminal::hasSttyAvailable()) {
181+
$this->markTestSkipped('`stty` is required to test autocomplete functionality');
182+
}
183+
$dialog = new QuestionHelper();
184+
185+
$inputStream = $this->getInputStream($input);
186+
187+
$question = new Question('Question?');
188+
$this->assertEquals($expected, $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question));
189+
}
190+
191+
public function getAskInputWithControlsData()
192+
{
193+
return [
194+
['test1234,;.:_-+*#\\()/@!äßñ', 'test1234,;.:_-+*#\\()/@!äßñ'],
195+
['tet'.QuestionHelper::KEY_ARROW_LEFT.'s', 'test'],
196+
['tesñt'.QuestionHelper::KEY_ARROW_LEFT.QuestionHelper::KEY_BACKSPACE, 'test'],
197+
['tesst'.QuestionHelper::KEY_CTRL_B.QuestionHelper::KEY_CTRL_H, 'test'],
198+
['tes@t'.QuestionHelper::KEY_ARROW_LEFT.QuestionHelper::KEY_ARROW_LEFT.QuestionHelper::KEY_DELETE, 'test'],
199+
['test'.QuestionHelper::KEY_ARROW_LEFT.QuestionHelper::KEY_ARROW_LEFT.'1'.QuestionHelper::KEY_ARROW_RIGHT.'2', 'te1s2t'],
200+
['test'.QuestionHelper::KEY_ARROW_LEFT.QuestionHelper::KEY_ARROW_LEFT.'1'.QuestionHelper::KEY_CTRL_F.'2', 'te1s2t'],
201+
['es'.QuestionHelper::KEY_CTRL_A.'t'.QuestionHelper::KEY_CTRL_E.'t', 'test'],
202+
];
203+
}
204+
175205
public function testAskNonTrimmed()
176206
{
177207
$dialog = new QuestionHelper();

0 commit comments

Comments
 (0)