|
34 | 34 | */
|
35 | 35 | class QuestionHelper extends Helper
|
36 | 36 | {
|
| 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 | + |
37 | 56 | /**
|
38 | 57 | * @var resource|null
|
39 | 58 | */
|
@@ -123,7 +142,7 @@ private function doAsk(OutputInterface $output, Question $question): mixed
|
123 | 142 | }
|
124 | 143 |
|
125 | 144 | if (false === $ret) {
|
126 |
| - $ret = $this->readInput($inputStream, $question); |
| 145 | + $ret = $this->readInput($inputStream, $question, $output); |
127 | 146 | if (false === $ret) {
|
128 | 147 | throw new MissingInputException('Aborted.');
|
129 | 148 | }
|
@@ -511,13 +530,12 @@ private function isInteractiveInput($inputStream): bool
|
511 | 530 | * @param resource $inputStream The handler resource
|
512 | 531 | * @param Question $question The question being asked
|
513 | 532 | */
|
514 |
| - private function readInput($inputStream, Question $question): string|false |
| 533 | + private function readInput($inputStream, Question $question, OutputInterface $output): string|false |
515 | 534 | {
|
516 | 535 | if (!$question->isMultiline()) {
|
517 | 536 | $cp = $this->setIOCodepage();
|
518 |
| - $ret = fgets($inputStream, 4096); |
519 | 537 |
|
520 |
| - return $this->resetIOCodepage($cp, $ret); |
| 538 | + return $this->resetIOCodepage($cp, $this->handleCliInput($inputStream, $output)); |
521 | 539 | }
|
522 | 540 |
|
523 | 541 | $multiLineStreamReader = $this->cloneInputStream($inputStream);
|
@@ -598,4 +616,150 @@ private function cloneInputStream($inputStream)
|
598 | 616 |
|
599 | 617 | return $cloneStream;
|
600 | 618 | }
|
| 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 | + } |
601 | 765 | }
|
0 commit comments