diff --git a/examples/confirm-cancellable.php b/examples/confirm-cancellable.php new file mode 100644 index 00000000..023cf213 --- /dev/null +++ b/examples/confirm-cancellable.php @@ -0,0 +1,29 @@ +cancellableConfirm('PHP School FTW!', null, true) + ->display('OK', 'Cancel'); + + if ($continue) { + // Something Destructive + echo "OK"; + } else { + // Do nothing + echo "Cancel"; + } +}; + +$menu = (new CliMenuBuilder) + ->setTitle('Basic CLI Menu') + ->addItem('First Item', $itemCallable) + ->addItem('Second Item', $itemCallable) + ->addItem('Third Item', $itemCallable) + ->addLineBreak('-') + ->build(); + +$menu->open(); diff --git a/src/CliMenu.php b/src/CliMenu.php index 22e3cfa4..0f44d92d 100644 --- a/src/CliMenu.php +++ b/src/CliMenu.php @@ -2,6 +2,7 @@ namespace PhpSchool\CliMenu; +use PhpSchool\CliMenu\Dialogue\CancellableConfirm; use PhpSchool\CliMenu\Exception\InvalidTerminalException; use PhpSchool\CliMenu\Exception\MenuNotOpenException; use PhpSchool\CliMenu\Input\InputIO; @@ -708,6 +709,17 @@ public function confirm(string $text, MenuStyle $style = null) : Confirm return new Confirm($this, $style, $this->terminal, $text); } + public function cancellableConfirm(string $text, MenuStyle $style = null) : CancellableConfirm + { + $this->guardSingleLine($text); + + $style = $style ?? (new MenuStyle($this->terminal)) + ->setBg('yellow') + ->setFg('red'); + + return new CancellableConfirm($this, $style, $this->terminal, $text); + } + public function askNumber(MenuStyle $style = null) : Number { $this->assertOpen(); diff --git a/src/Dialogue/CancellableConfirm.php b/src/Dialogue/CancellableConfirm.php new file mode 100644 index 00000000..4680e746 --- /dev/null +++ b/src/Dialogue/CancellableConfirm.php @@ -0,0 +1,111 @@ + + */ +class CancellableConfirm extends Dialogue +{ + /** + * @var bool + */ + private $confirm = true; + + /** + * Display confirmation with a button with the given text + */ + public function display(string $confirmText = 'OK', string $cancelText = 'Cancel') : bool + { + $this->drawDialog($confirmText, $cancelText); + + $reader = new NonCanonicalReader($this->terminal); + + while ($char = $reader->readCharacter()) { + if ($char->isControl() && $char->getControl() === InputCharacter::ENTER) { + $this->parentMenu->redraw(); + return $this->confirm; + } elseif ($char->isControl() && $char->getControl() === InputCharacter::TAB || + ($char->isControl() && $char->getControl() === InputCharacter::RIGHT && $this->confirm) || + ($char->isControl() && $char->getControl() === InputCharacter::LEFT && !$this->confirm) + ) { + $this->confirm = !$this->confirm; + $this->drawDialog($confirmText, $cancelText); + } + } + } + + private function drawDialog(string $confirmText = 'OK', string $cancelText = 'Cancel'): void + { + $this->assertMenuOpen(); + + $this->terminal->moveCursorToRow($this->y); + + $promptWidth = mb_strlen($this->text) + 4; + + $buttonLength = mb_strlen($confirmText) + 6; + $buttonLength += mb_strlen($cancelText) + 7; + + $confirmButton = sprintf( + '%s%s < %s > %s%s', + $this->style->getOptionCode($this->confirm ? 'bold' : 'dim'), + $this->style->getInvertedColoursSetCode(), + $confirmText, + $this->style->getInvertedColoursUnsetCode(), + $this->style->getOptionCode($this->confirm ? 'bold' : 'dim', false) + ); + + $cancelButton = sprintf( + '%s%s < %s > %s%s', + $this->style->getOptionCode($this->confirm ? 'dim' : 'bold'), + $this->style->getInvertedColoursSetCode(), + $cancelText, + $this->style->getInvertedColoursUnsetCode(), + $this->style->getOptionCode($this->confirm ? 'dim' : 'bold', false) + ); + + $buttonRow = $confirmButton . " " . $cancelButton; + + if ($promptWidth < $buttonLength) { + $pad = ($buttonLength - $promptWidth) / 2; + $this->text = sprintf( + '%s%s%s', + str_repeat(' ', intval(round($pad, 0, 2) + $this->style->getPaddingLeftRight())), + $this->text, + str_repeat(' ', intval(round($pad, 0, 1) + $this->style->getPaddingLeftRight())) + ); + $promptWidth = mb_strlen($this->text) + 4; + } + + $leftFill = (int) (($promptWidth / 2) - ($buttonLength / 2)); + + $this->emptyRow(); + + $this->write(sprintf( + "%s%s%s%s%s\n", + $this->style->getColoursSetCode(), + str_repeat(' ', $this->style->getPaddingLeftRight()), + $this->text, + str_repeat(' ', $this->style->getPaddingLeftRight()), + $this->style->getColoursResetCode() + )); + + $this->emptyRow(); + + $this->write(sprintf( + "%s%s%s%s%s\n", + $this->style->getColoursSetCode(), + str_repeat(' ', $leftFill), + $buttonRow, + str_repeat(' ', (int) ceil($promptWidth - $leftFill - $buttonLength)), + $this->style->getColoursResetCode() + )); + + $this->emptyRow(); + + $this->terminal->moveCursorToTop(); + } +} diff --git a/src/Dialogue/Dialogue.php b/src/Dialogue/Dialogue.php index 5ec0b4f7..d6f60240 100644 --- a/src/Dialogue/Dialogue.php +++ b/src/Dialogue/Dialogue.php @@ -32,6 +32,11 @@ abstract class Dialogue */ protected $text; + /** + * @var bool $cancellable + */ + protected $cancellable; + /** * @var int */ @@ -42,8 +47,12 @@ abstract class Dialogue */ protected $y; - public function __construct(CliMenu $parentMenu, MenuStyle $menuStyle, Terminal $terminal, string $text) - { + public function __construct( + CliMenu $parentMenu, + MenuStyle $menuStyle, + Terminal $terminal, + string $text + ) { $this->style = $menuStyle; $this->terminal = $terminal; $this->text = $text; diff --git a/src/Input/InputIO.php b/src/Input/InputIO.php index 2f17af08..fb76ab0d 100644 --- a/src/Input/InputIO.php +++ b/src/Input/InputIO.php @@ -108,7 +108,7 @@ function (string $line) { }, $lines ) - ); + ) ? : 0; } private function calculateYPosition() : int diff --git a/src/MenuStyle.php b/src/MenuStyle.php index fa318a6b..29ce72c8 100644 --- a/src/MenuStyle.php +++ b/src/MenuStyle.php @@ -509,6 +509,7 @@ private function generatePaddingTopBottomRows() : void ); } + $this->paddingTopBottom = $this->paddingTopBottom >= 0 ? $this->paddingTopBottom : 0; $this->paddingTopBottomRows = array_fill(0, $this->paddingTopBottom, $paddingRow); } @@ -663,6 +664,10 @@ private function generateBorderRows() : void ); } + $this->borderTopWidth = $this->borderTopWidth >= 0 ? $this->borderTopWidth : 0; + $this->borderBottomWidth = $this->borderBottomWidth >= 0 ? $this->borderBottomWidth : 0; + + $this->borderTopRows = array_fill(0, $this->borderTopWidth, $borderRow); $this->borderBottomRows = array_fill(0, $this->borderBottomWidth, $borderRow); } @@ -813,6 +818,20 @@ public function getBorderColourCode() : string return sprintf("\033[%sm", $borderColourCode); } + + /** + * Get ansi escape sequence for setting or unsetting the specified option code. + * + * @param string $string Option code (bold|dim|underscore|blink|reverse|conceal) + * @param bool $set Whether to set or unset the code + * + * @return string + */ + public function getOptionCode(string $string, bool $set = true): string + { + return sprintf("\033[%sm", self::$availableOptions[$string][$set ? 'set' : 'unset']); + } + /** * Get a string of given length consisting of 0-9 * eg $length = 15 : 012345678901234 diff --git a/test/Dialogue/ConfirmTest.php b/test/Dialogue/ConfirmTest.php index 56dd976c..c91d955b 100644 --- a/test/Dialogue/ConfirmTest.php +++ b/test/Dialogue/ConfirmTest.php @@ -140,6 +140,54 @@ public function testConfirmWithOddLengthConfirmAndEvenLengthButton() : void static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } + public function testConfirmCancellableWithShortPrompt(): void + { + $this->terminal + ->method('read') + ->will($this->onConsecutiveCalls( + "\n", + "\n" + )); + + $style = $this->getStyle($this->terminal); + + $item = new SelectableItem('Item 1', function (CliMenu $menu) { + $menu->cancellableConfirm('PHP', null, true) + ->display('OK', 'Cancel'); + + $menu->close(); + }); + + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); + $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); + } + + public function testConfirmCancellableWithLongPrompt(): void + { + $this->terminal + ->method('read') + ->will($this->onConsecutiveCalls( + "\n", + "\n" + )); + + $style = $this->getStyle($this->terminal); + + $item = new SelectableItem('Item 1', function (CliMenu $menu) { + $menu->cancellableConfirm('PHP School Rocks FTW!', null, true) + ->display('OK', 'Cancel'); + + $menu->close(); + }); + + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); + $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); + } + public function testConfirmCanOnlyBeClosedWithEnter() : void { $this->terminal @@ -166,6 +214,79 @@ public function testConfirmCanOnlyBeClosedWithEnter() : void static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } + public function testConfirmOkNonCancellableReturnsTrue() + { + $this->terminal + ->method('read') + ->will($this->onConsecutiveCalls( + "\n", + 'tab', + "\n" + )); + + $style = $this->getStyle($this->terminal); + + $return = ''; + + $item = new SelectableItem('Item 1', function (CliMenu $menu) use (&$return) { + $return = $menu->cancellableConfirm('PHP School FTW!') + ->display('OK'); + + $menu->close(); + }); + + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); + $menu->open(); + + static::assertTrue($return); + } + + public function testConfirmOkCancellableReturnsTrue() + { + $this->terminal + ->method('read') + ->willReturn("\n", "\t", "\t", "\n"); + + $style = $this->getStyle($this->terminal); + + $return = ''; + + $item = new SelectableItem('Item 1', function (CliMenu $menu) use (&$return) { + $return = $menu->cancellableConfirm('PHP School FTW!') + ->display('OK', 'Cancel'); + + $menu->close(); + }); + + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); + $menu->open(); + + static::assertTrue($return); + } + + public function testConfirmCancelCancellableReturnsFalse() + { + $this->terminal + ->method('read') + ->willReturn("\n", "\t", "\n"); + + $style = $this->getStyle($this->terminal); + + $return = ''; + + $item = new SelectableItem('Item 1', function (CliMenu $menu) use (&$return) { + $return = $menu->cancellableConfirm('PHP School FTW!', null) + ->display('OK', 'Cancel'); + + $menu->close(); + }); + + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); + $menu->open(); + + static::assertFalse($return); + } + private function getTestFile() : string { return sprintf('%s/../res/%s.txt', __DIR__, $this->getName()); diff --git a/test/res/testConfirmCancellableWithLongPrompt.txt b/test/res/testConfirmCancellableWithLongPrompt.txt new file mode 100644 index 00000000..6ca1dc84 --- /dev/null +++ b/test/res/testConfirmCancellableWithLongPrompt.txt @@ -0,0 +1,23 @@ + + +   +  PHP School FTW  +  ========================================  +  ● Item 1  +   + + +  + PHP School Rocks FTW!  +  +  < OK >   < Cancel >   +  + + +   +  PHP School FTW  +  ========================================  +  ● Item 1  +   + + diff --git a/test/res/testConfirmCancellableWithShortPrompt.txt b/test/res/testConfirmCancellableWithShortPrompt.txt new file mode 100644 index 00000000..9d84fc5a --- /dev/null +++ b/test/res/testConfirmCancellableWithShortPrompt.txt @@ -0,0 +1,23 @@ + + +   +  PHP School FTW  +  ========================================  +  ● Item 1  +   + + +  + PHP  +  +  < OK >   < Cancel >   +  + + +   +  PHP School FTW  +  ========================================  +  ● Item 1  +   + +