Skip to content

Commit 04fbdd8

Browse files
committed
[Console] Support a set of control keys and key combinations in QuestionHelper
1 parent 521d210 commit 04fbdd8

File tree

2 files changed

+222
-4
lines changed

2 files changed

+222
-4
lines changed

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

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@
3434
*/
3535
class QuestionHelper extends Helper
3636
{
37+
private const KEY_ALT_B = "\033b";
38+
private const KEY_ALT_F = "\033f";
39+
private const KEY_ARROW_LEFT = "\033[D";
40+
private const KEY_ARROW_RIGHT = "\033[C";
41+
private const KEY_BACKSPACE = "\177";
42+
private const KEY_CTRL_A = "\001";
43+
private const KEY_CTRL_B = "\002";
44+
private const KEY_CTRL_E = "\005";
45+
private const KEY_CTRL_F = "\006";
46+
private const KEY_CTRL_H = "\010";
47+
private const KEY_CTRL_ARROW_LEFT = "\033[1;5D";
48+
private const KEY_CTRL_ARROW_RIGHT = "\033[1;5C";
49+
private const KEY_CTRL_SHIFT_ARROW_LEFT = "\033[1;6D";
50+
private const KEY_CTRL_SHIFT_ARROW_RIGHT = "\033[1;6C";
51+
private const KEY_DELETE = "\033[3~";
52+
private const KEY_END = "\033[F";
53+
private const KEY_ENTER = "\n";
54+
private const KEY_HOME = "\033[H";
55+
3756
/**
3857
* @var resource|null
3958
*/
@@ -123,7 +142,7 @@ private function doAsk(OutputInterface $output, Question $question): mixed
123142
}
124143

125144
if (false === $ret) {
126-
$ret = $this->readInput($inputStream, $question);
145+
$ret = $this->readInput($inputStream, $question, $output);
127146
if (false === $ret) {
128147
throw new MissingInputException('Aborted.');
129148
}
@@ -511,13 +530,12 @@ private function isInteractiveInput($inputStream): bool
511530
* @param resource $inputStream The handler resource
512531
* @param Question $question The question being asked
513532
*/
514-
private function readInput($inputStream, Question $question): string|false
533+
private function readInput($inputStream, Question $question, OutputInterface $output): string|false
515534
{
516535
if (!$question->isMultiline()) {
517536
$cp = $this->setIOCodepage();
518-
$ret = fgets($inputStream, 4096);
519537

520-
return $this->resetIOCodepage($cp, $ret);
538+
return $this->resetIOCodepage($cp, $this->handleCliInput($inputStream, $output));
521539
}
522540

523541
$multiLineStreamReader = $this->cloneInputStream($inputStream);
@@ -598,4 +616,150 @@ private function cloneInputStream($inputStream)
598616

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

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@
3232
*/
3333
class QuestionHelperTest extends AbstractQuestionHelperTest
3434
{
35+
private const KEY_ALT_B = "\033b";
36+
private const KEY_ALT_F = "\033f";
37+
private const KEY_ARROW_LEFT = "\033[D";
38+
private const KEY_ARROW_RIGHT = "\033[C";
39+
private const KEY_BACKSPACE = "\177";
40+
private const KEY_CTRL_A = "\001";
41+
private const KEY_CTRL_B = "\002";
42+
private const KEY_CTRL_E = "\005";
43+
private const KEY_CTRL_F = "\006";
44+
private const KEY_CTRL_H = "\010";
45+
private const KEY_CTRL_ARROW_LEFT = "\033[1;5D";
46+
private const KEY_CTRL_ARROW_RIGHT = "\033[1;5C";
47+
private const KEY_CTRL_SHIFT_ARROW_LEFT = "\033[1;6D";
48+
private const KEY_CTRL_SHIFT_ARROW_RIGHT = "\033[1;6C";
49+
private const KEY_DELETE = "\033[3~";
50+
private const KEY_END = "\033[F";
51+
private const KEY_ENTER = "\n";
52+
private const KEY_HOME = "\033[H";
53+
3554
public function testAskChoice()
3655
{
3756
$questionHelper = new QuestionHelper();
@@ -172,6 +191,41 @@ public function testAsk()
172191
$this->assertEquals('What time is it?', stream_get_contents($output->getStream()));
173192
}
174193

194+
/**
195+
* @dataProvider getAskInputWithControlsData
196+
*/
197+
public function testAskInputWithControls(string $input, string $expected)
198+
{
199+
if (!Terminal::hasSttyAvailable()) {
200+
$this->markTestSkipped('`stty` is required to test autocomplete functionality');
201+
}
202+
$dialog = new QuestionHelper();
203+
204+
$inputStream = $this->getInputStream($input.self::KEY_ENTER);
205+
206+
$question = new Question('Question?');
207+
$this->assertEquals($expected, $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question));
208+
}
209+
210+
public function getAskInputWithControlsData()
211+
{
212+
return [
213+
['test1234,;.:_-+*\'#\\()/@!äßñ', 'test1234,;.:_-+*\'#\\()/@!äßñ'],
214+
['tet'.self::KEY_ARROW_LEFT.'s', 'test'],
215+
['tesñt'.self::KEY_ARROW_LEFT.self::KEY_BACKSPACE, 'test'],
216+
['tesst'.self::KEY_CTRL_B.self::KEY_CTRL_H, 'test'],
217+
['tes@t'.self::KEY_ARROW_LEFT.self::KEY_ARROW_LEFT.self::KEY_DELETE, 'test'],
218+
['test'.self::KEY_ARROW_LEFT.self::KEY_ARROW_LEFT.'1'.self::KEY_ARROW_RIGHT.'2', 'te1s2t'],
219+
['test'.self::KEY_ARROW_LEFT.self::KEY_ARROW_LEFT.'1'.self::KEY_CTRL_F.'2', 'te1s2t'],
220+
['es'.self::KEY_HOME.'t'.self::KEY_END.'t', 'test'],
221+
['es'.self::KEY_CTRL_A.'t'.self::KEY_CTRL_E.'t', 'test'],
222+
['t e@sñt'.self::KEY_CTRL_ARROW_LEFT.self::KEY_BACKSPACE.self::KEY_CTRL_ARROW_LEFT.self::KEY_BACKSPACE, 'tesñt'],
223+
['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'],
224+
['t e-sñt'.self::KEY_CTRL_SHIFT_ARROW_LEFT.self::KEY_BACKSPACE.self::KEY_ALT_B.self::KEY_BACKSPACE, 'tesñt'],
225+
['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'],
226+
];
227+
}
228+
175229
public function testAskNonTrimmed()
176230
{
177231
$dialog = new QuestionHelper();

0 commit comments

Comments
 (0)