diff --git a/composer.json b/composer.json index b5003a4d..0af52afb 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "require": { "php" : ">=7.1", "beberlei/assert": "^2.4", + "php-school/terminal": "dev-catch-all-controls", "ext-posix": "*" }, "autoload" : { diff --git a/examples/input-advanced.php b/examples/input-advanced.php new file mode 100644 index 00000000..61458bd9 --- /dev/null +++ b/examples/input-advanced.php @@ -0,0 +1,32 @@ +askText() + ->setPromptText('Enter username') + ->setPlaceholderText('alice') + ->ask(); + + $age = $menu->askNumber() + ->setPromptText('Enter age') + ->setPlaceholderText('28') + ->ask(); + + $password = $menu->askPassword() + ->setPromptText('Enter password') + ->ask(); + + var_dump($username->fetch(), $age->fetch(), $password->fetch()); +}; + +$menu = (new CliMenuBuilder) + ->setTitle('User Manager') + ->addItem('Create New User', $itemCallable) + ->addLineBreak('-') + ->build(); + +$menu->open(); diff --git a/examples/input-number.php b/examples/input-number.php new file mode 100644 index 00000000..ea8fa902 --- /dev/null +++ b/examples/input-number.php @@ -0,0 +1,24 @@ +askNumber() + ->setPlaceholderText(10) + ->ask(); + + var_dump($result->fetch()); +}; + +$menu = (new CliMenuBuilder) + ->setTitle('Basic CLI Menu') + ->addItem('Enter number', $itemCallable) + ->addItem('Second Item', $itemCallable) + ->addItem('Third Item', $itemCallable) + ->addLineBreak('-') + ->build(); + +$menu->open(); diff --git a/examples/input-password.php b/examples/input-password.php new file mode 100644 index 00000000..86d1a2c9 --- /dev/null +++ b/examples/input-password.php @@ -0,0 +1,35 @@ +askPassword() + ->setPlaceholderText('') + ->setValidator(function ($password) { + if ($password === 'password') { + $this->setValidationFailedText('Password is too weak'); + return false; + } else if (strlen($password) <= 6) { + $this->setValidationFailedText('Password is not long enough'); + return false; + } + + return true; + }) + ->ask(); + + var_dump($result->fetch()); +}; + +$menu = (new CliMenuBuilder) + ->setTitle('Basic CLI Menu') + ->addItem('Enter password', $itemCallable) + ->addItem('Second Item', $itemCallable) + ->addItem('Third Item', $itemCallable) + ->addLineBreak('-') + ->build(); + +$menu->open(); diff --git a/examples/input-text.php b/examples/input-text.php new file mode 100644 index 00000000..01b3b731 --- /dev/null +++ b/examples/input-text.php @@ -0,0 +1,24 @@ +askText() + ->setPlaceholderText('Enter something here') + ->ask(); + + var_dump($result->fetch()); +}; + +$menu = (new CliMenuBuilder) + ->setTitle('Basic CLI Menu') + ->addItem('Enter text', $itemCallable) + ->addItem('Second Item', $itemCallable) + ->addItem('Third Item', $itemCallable) + ->addLineBreak('-') + ->build(); + +$menu->open(); diff --git a/src/CliMenu.php b/src/CliMenu.php index 16c9bb5f..99d2bcc3 100644 --- a/src/CliMenu.php +++ b/src/CliMenu.php @@ -2,17 +2,26 @@ namespace PhpSchool\CliMenu; +use PhpSchool\CliMenu\Dialogue\NumberInput; use PhpSchool\CliMenu\Exception\InvalidInstantiationException; use PhpSchool\CliMenu\Exception\InvalidTerminalException; use PhpSchool\CliMenu\Exception\MenuNotOpenException; +use PhpSchool\CliMenu\Input\InputIO; +use PhpSchool\CliMenu\Input\Number; +use PhpSchool\CliMenu\Input\Password; +use PhpSchool\CliMenu\Input\Text; use PhpSchool\CliMenu\MenuItem\LineBreakItem; use PhpSchool\CliMenu\MenuItem\MenuItemInterface; use PhpSchool\CliMenu\MenuItem\StaticItem; use PhpSchool\CliMenu\Dialogue\Confirm; use PhpSchool\CliMenu\Dialogue\Flash; use PhpSchool\CliMenu\Terminal\TerminalFactory; -use PhpSchool\CliMenu\Terminal\TerminalInterface; use PhpSchool\CliMenu\Util\StringUtil as s; +use PhpSchool\Terminal\Exception\NotInteractiveTerminal; +use PhpSchool\Terminal\InputCharacter; +use PhpSchool\Terminal\NonCanonicalReader; +use PhpSchool\Terminal\Terminal; +use PhpSchool\Terminal\TerminalReader; /** * @author Michael Woodward @@ -20,7 +29,7 @@ class CliMenu { /** - * @var TerminalInterface + * @var Terminal */ protected $terminal; @@ -62,7 +71,7 @@ class CliMenu public function __construct( ?string $title, array $items, - TerminalInterface $terminal = null, + Terminal $terminal = null, MenuStyle $style = null ) { $this->title = $title; @@ -75,40 +84,33 @@ public function __construct( /** * Configure the terminal to work with CliMenu - * - * @throws InvalidTerminalException */ protected function configureTerminal() : void { $this->assertTerminalIsValidTTY(); - $this->terminal->setCanonicalMode(); + $this->terminal->disableCanonicalMode(); + $this->terminal->disableEchoBack(); $this->terminal->disableCursor(); $this->terminal->clear(); } /** * Revert changes made to the terminal - * - * @throws InvalidTerminalException */ protected function tearDownTerminal() : void { - $this->assertTerminalIsValidTTY(); - - $this->terminal->setCanonicalMode(false); - $this->terminal->enableCursor(); + $this->terminal->restoreOriginalConfiguration(); } private function assertTerminalIsValidTTY() : void { - if (!$this->terminal->isTTY()) { - throw new InvalidTerminalException( - sprintf('Terminal "%s" is not a valid TTY', $this->terminal->getDetails()) - ); + if (!$this->terminal->isInteractive()) { + throw new InvalidTerminalException('Terminal is not interactive (TTY)'); } } + public function setParent(CliMenu $parent) : void { $this->parent = $parent; @@ -119,7 +121,7 @@ public function getParent() : ?CliMenu return $this->parent; } - public function getTerminal() : TerminalInterface + public function getTerminal() : Terminal { return $this->terminal; } @@ -161,14 +163,28 @@ private function display() : void { $this->draw(); - while ($this->isOpen() && $input = $this->terminal->getKeyedInput()) { - switch ($input) { - case 'up': - case 'down': - $this->moveSelection($input); + $reader = new NonCanonicalReader($this->terminal); + $reader->addControlMappings([ + '^P' => InputCharacter::UP, + 'k' => InputCharacter::UP, + '^K' => InputCharacter::DOWN, + 'j' => InputCharacter::DOWN, + "\r" => InputCharacter::ENTER, + ' ' => InputCharacter::ENTER, + ]); + + while ($this->isOpen() && $char = $reader->readCharacter()) { + if (!$char->isHandledControl()) { + continue; + } + + switch ($char->getControl()) { + case InputCharacter::UP: + case InputCharacter::DOWN: + $this->moveSelection($char->getControl()); $this->draw(); break; - case 'enter': + case InputCharacter::ENTER: $this->executeCurrentItem(); break; } @@ -183,12 +199,12 @@ protected function moveSelection(string $direction) : void do { $itemKeys = array_keys($this->items); - $direction === 'up' + $direction === 'UP' ? $this->selectedItem-- : $this->selectedItem++; if (!array_key_exists($this->selectedItem, $this->items)) { - $this->selectedItem = $direction === 'up' + $this->selectedItem = $direction === 'UP' ? end($itemKeys) : reset($itemKeys); } elseif ($this->getSelectedItem()->canSelect()) { @@ -219,12 +235,16 @@ protected function executeCurrentItem() : void * Redraw the menu */ public function redraw() : void + { + $this->assertOpen(); + $this->draw(); + } + + private function assertOpen() : void { if (!$this->isOpen()) { throw new MenuNotOpenException; } - - $this->draw(); } /** @@ -254,7 +274,7 @@ protected function draw() : void $frame->newLine(2); foreach ($frame->getRows() as $row) { - echo $row; + $this->terminal->write($row); } $this->currentFrame = $frame; @@ -277,7 +297,7 @@ protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) return array_map(function ($row) use ($setColour, $unsetColour) { return sprintf( - "%s%s%s%s%s%s%s\n\r", + "%s%s%s%s%s%s%s\n", str_repeat(' ', $this->style->getMargin()), $setColour, str_repeat(' ', $this->style->getPadding()), @@ -359,9 +379,7 @@ public function getCurrentFrame() : Frame public function flash(string $text) : Flash { - if (strpos($text, "\n") !== false) { - throw new \InvalidArgumentException; - } + $this->guardSingleLine($text); $style = (new MenuStyle($this->terminal)) ->setBg('yellow') @@ -372,9 +390,7 @@ public function flash(string $text) : Flash public function confirm($text) : Confirm { - if (strpos($text, "\n") !== false) { - throw new \InvalidArgumentException; - } + $this->guardSingleLine($text); $style = (new MenuStyle($this->terminal)) ->setBg('yellow') @@ -382,4 +398,44 @@ public function confirm($text) : Confirm return new Confirm($this, $style, $this->terminal, $text); } + + public function askNumber() : Number + { + $this->assertOpen(); + + $style = (new MenuStyle($this->terminal)) + ->setBg('yellow') + ->setFg('red'); + + return new Number(new InputIO($this, $this->terminal), $style); + } + + public function askText() : Text + { + $this->assertOpen(); + + $style = (new MenuStyle($this->terminal)) + ->setBg('yellow') + ->setFg('red'); + + return new Text(new InputIO($this, $this->terminal), $style); + } + + public function askPassword() : Password + { + $this->assertOpen(); + + $style = (new MenuStyle($this->terminal)) + ->setBg('yellow') + ->setFg('red'); + + return new Password(new InputIO($this, $this->terminal), $style); + } + + private function guardSingleLine($text) + { + if (strpos($text, "\n") !== false) { + throw new \InvalidArgumentException; + } + } } diff --git a/src/CliMenuBuilder.php b/src/CliMenuBuilder.php index d612f2b2..6a0c09a9 100644 --- a/src/CliMenuBuilder.php +++ b/src/CliMenuBuilder.php @@ -11,8 +11,8 @@ use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuItem\StaticItem; use PhpSchool\CliMenu\Terminal\TerminalFactory; -use PhpSchool\CliMenu\Terminal\TerminalInterface; use Assert\Assertion; +use PhpSchool\Terminal\Terminal; use RuntimeException; /** @@ -62,7 +62,7 @@ class CliMenuBuilder private $style; /** - * @var TerminalInterface + * @var Terminal */ private $terminal; @@ -262,7 +262,7 @@ public function setTitleSeparator(string $separator) : self return $this; } - public function setTerminal(TerminalInterface $terminal) : self + public function setTerminal(Terminal $terminal) : self { $this->terminal = $terminal; return $this; diff --git a/src/Dialogue/Confirm.php b/src/Dialogue/Confirm.php index be8ac4cd..68ee3f49 100644 --- a/src/Dialogue/Confirm.php +++ b/src/Dialogue/Confirm.php @@ -2,6 +2,9 @@ namespace PhpSchool\CliMenu\Dialogue; +use PhpSchool\Terminal\InputCharacter; +use PhpSchool\Terminal\NonCanonicalReader; + /** * @author Aydin Hassan */ @@ -33,35 +36,21 @@ public function display(string $confirmText = 'OK') : void $this->emptyRow(); $confirmText = sprintf(' < %s > ', $confirmText); - $leftFill = ($promptWidth / 2) - (mb_strlen($confirmText) / 2); + $leftFill = ($promptWidth / 2) - (mb_strlen($confirmText) / 2); $this->write(sprintf( - '%s%s%s', + "%s%s%s%s%s%s%s%s%s\n", $this->style->getUnselectedSetCode(), str_repeat(' ', $leftFill), - $this->style->getUnselectedSetCode() + $this->style->getUnselectedUnsetCode(), + $this->style->getSelectedSetCode(), + $confirmText, + $this->style->getSelectedUnsetCode(), + $this->style->getUnselectedSetCode(), + str_repeat(' ', ceil($promptWidth - $leftFill - mb_strlen($confirmText))), + $this->style->getUnselectedUnsetCode() )); - $this->write( - sprintf( - '%s%s%s', - $this->style->getSelectedSetCode(), - $confirmText, - $this->style->getSelectedUnsetCode() - ), - -1 - ); - - $this->write( - sprintf( - "%s%s%s\n", - $this->style->getUnselectedSetCode(), - str_repeat(' ', ceil($promptWidth - $leftFill - mb_strlen($confirmText))), - $this->style->getSelectedUnsetCode() - ), - -1 - ); - $this->write(sprintf( "%s%s%s%s%s\n", $this->style->getUnselectedSetCode(), @@ -72,12 +61,14 @@ public function display(string $confirmText = 'OK') : void )); $this->terminal->moveCursorToTop(); - $input = $this->terminal->getKeyedInput(); - while ($input !== 'enter') { - $input = $this->terminal->getKeyedInput(); - } + $reader = new NonCanonicalReader($this->terminal); - $this->parentMenu->redraw(); + while ($char = $reader->readCharacter()) { + if ($char->isControl() && $char->getControl() === InputCharacter::ENTER) { + $this->parentMenu->redraw(); + return; + } + } } } diff --git a/src/Dialogue/Dialogue.php b/src/Dialogue/Dialogue.php index 34f3c113..f9278222 100644 --- a/src/Dialogue/Dialogue.php +++ b/src/Dialogue/Dialogue.php @@ -5,7 +5,7 @@ use PhpSchool\CliMenu\CliMenu; use PhpSchool\CliMenu\Exception\MenuNotOpenException; use PhpSchool\CliMenu\MenuStyle; -use PhpSchool\CliMenu\Terminal\TerminalInterface; +use PhpSchool\Terminal\Terminal; /** * @author Aydin Hassan @@ -23,7 +23,7 @@ abstract class Dialogue protected $parentMenu; /** - * @var TerminalInterface + * @var Terminal */ protected $terminal; @@ -42,7 +42,7 @@ abstract class Dialogue */ protected $y; - public function __construct(CliMenu $parentMenu, MenuStyle $menuStyle, TerminalInterface $terminal, string $text) + public function __construct(CliMenu $parentMenu, MenuStyle $menuStyle, Terminal $terminal, string $text) { $this->style = $menuStyle; $this->terminal = $terminal; @@ -101,7 +101,7 @@ protected function emptyRow() : void protected function write(string $text, int $column = null) : void { $this->terminal->moveCursorToColumn($column ?: $this->x); - echo $text; + $this->terminal->write($text); } public function getStyle() : MenuStyle diff --git a/src/Dialogue/Flash.php b/src/Dialogue/Flash.php index 6e9227d6..2d305da5 100644 --- a/src/Dialogue/Flash.php +++ b/src/Dialogue/Flash.php @@ -2,6 +2,8 @@ namespace PhpSchool\CliMenu\Dialogue; +use PhpSchool\Terminal\NonCanonicalReader; + /** * @author Aydin Hassan */ @@ -29,8 +31,12 @@ public function display() : void )); $this->emptyRow(); + $this->terminal->moveCursorToTop(); - $this->terminal->getKeyedInput(); + + $reader = new NonCanonicalReader($this->terminal); + $reader->readCharacter(); + $this->parentMenu->redraw(); } } diff --git a/src/Input/Input.php b/src/Input/Input.php new file mode 100644 index 00000000..520eb78d --- /dev/null +++ b/src/Input/Input.php @@ -0,0 +1,31 @@ + + */ +interface Input +{ + public function ask() : InputResult; + + public function validate(string $input) : bool; + + public function setPromptText(string $promptText) : Input; + + public function getPromptText() : string; + + public function setValidationFailedText(string $validationFailedText) : Input; + + public function getValidationFailedText() : string; + + public function setPlaceholderText(string $placeholderText) : Input; + + public function getPlaceholderText() : string; + + public function filter(string $value) : string; + + public function getStyle() : MenuStyle; +} diff --git a/src/Input/InputIO.php b/src/Input/InputIO.php new file mode 100644 index 00000000..ae655cfd --- /dev/null +++ b/src/Input/InputIO.php @@ -0,0 +1,252 @@ + + */ +class InputIO +{ + /** + * @var CliMenu + */ + private $parentMenu; + + /** + * @var Terminal + */ + private $terminal; + + /** + * @var callable[][] + */ + private $callbacks = []; + + public function __construct(CliMenu $parentMenu, Terminal $terminal) + { + $this->terminal = $terminal; + $this->parentMenu = $parentMenu; + } + + public function collect(Input $input) : InputResult + { + $this->drawInput($input, $input->getPlaceholderText()); + + $inputValue = $input->getPlaceholderText(); + $havePlaceHolderValue = !empty($inputValue); + + $reader = new NonCanonicalReader($this->terminal); + + while ($char = $reader->readCharacter()) { + if ($char->isNotControl()) { + if ($havePlaceHolderValue) { + $inputValue = $char->get(); + $havePlaceHolderValue = false; + } else { + $inputValue .= $char->get(); + } + + $this->parentMenu->redraw(); + $this->drawInput($input, $inputValue); + continue; + } + + if ($char->isHandledControl()) { + switch ($char->getControl()) { + case InputCharacter::ENTER: + if ($input->validate($inputValue)) { + $this->parentMenu->redraw(); + return new InputResult($inputValue); + } else { + $this->drawInputWithError($input, $inputValue); + continue 2; + } + + case InputCharacter::BACKSPACE: + $inputValue = substr($inputValue, 0, -1); + $this->parentMenu->redraw(); + $this->drawInput($input, $inputValue); + continue 2; + } + + if (!empty($this->callbacks[$char->getControl()])) { + foreach ($this->callbacks[$char->getControl()] as $callback) { + $inputValue = $callback($inputValue); + $this->drawInput($input, $inputValue); + } + } + } + } + } + + public function registerControlCallback(string $control, callable $callback) : void + { + if (!isset($this->callbacks[$control])) { + $this->callbacks[$control] = []; + } + + $this->callbacks[$control][] = $callback; + } + + private function getInputWidth(array $lines) + { + return max( + array_map( + function (string $line) { + return mb_strlen($line); + }, + $lines + ) + ); + } + + private function calculateYPosition() : int + { + $lines = 5; //1. empty 2. prompt text 3. empty 4. input 5. empty + + return ceil($this->parentMenu->getCurrentFrame()->count() / 2) - ceil($lines /2) + 1; + } + + private function calculateYPositionWithError() : int + { + $lines = 7; //1. empty 2. prompt text 3. empty 4. input 5. empty 6. error 7. empty + + return ceil($this->parentMenu->getCurrentFrame()->count() / 2) - ceil($lines /2) + 1; + } + + private function calculateXPosition(Input $input, string $userInput) : int + { + $width = $this->getInputWidth( + [ + $input->getPromptText(), + $input->getValidationFailedText(), + $userInput + ] + ); + + $parentStyle = $this->parentMenu->getStyle(); + $halfWidth = ($width + ($input->getStyle()->getPadding() * 2)) / 2; + $parentHalfWidth = ceil($parentStyle->getWidth() / 2); + + return $parentHalfWidth - $halfWidth; + } + + private function drawLine(Input $input, string $userInput, string $text) : void + { + $this->terminal->moveCursorToColumn($this->calculateXPosition($input, $userInput)); + + $line = sprintf( + "%s%s%s%s%s\n", + $input->getStyle()->getUnselectedSetCode(), + str_repeat(' ', $input->getStyle()->getPadding()), + $text, + str_repeat(' ', $input->getStyle()->getPadding()), + $input->getStyle()->getUnselectedUnsetCode() + ); + + $this->terminal->write($line); + } + + private function drawCenteredLine(Input $input, string $userInput, string $text) : void + { + $width = $this->getInputWidth( + [ + $input->getPromptText(), + $input->getValidationFailedText(), + $userInput + ] + ); + + $textLength = mb_strlen(StringUtil::stripAnsiEscapeSequence($text)); + $leftFill = ($width / 2) - ($textLength / 2); + $rightFill = ceil($width - $leftFill - $textLength); + + $this->drawLine( + $input, + $userInput, + sprintf( + '%s%s%s', + str_repeat(' ', $leftFill), + $text, + str_repeat(' ', $rightFill) + ) + ); + } + + private function drawEmptyLine(Input $input, string $userInput) : void + { + $width = $this->getInputWidth( + [ + $input->getPromptText(), + $input->getValidationFailedText(), + $userInput + ] + ); + + $this->drawLine( + $input, + $userInput, + str_repeat(' ', $width) + ); + } + + private function drawInput(Input $input, string $userInput) : void + { + $this->terminal->moveCursorToRow($this->calculateYPosition()); + + $this->drawEmptyLine($input, $userInput); + $this->drawTitle($input, $userInput); + $this->drawEmptyLine($input, $userInput); + $this->drawInputField($input, $input->filter($userInput)); + $this->drawEmptyLine($input, $userInput); + } + + private function drawInputWithError(Input $input, string $userInput) : void + { + $this->terminal->moveCursorToRow($this->calculateYPositionWithError()); + + $this->drawEmptyLine($input, $userInput); + $this->drawTitle($input, $userInput); + $this->drawEmptyLine($input, $userInput); + $this->drawInputField($input, $input->filter($userInput)); + $this->drawEmptyLine($input, $userInput); + $this->drawCenteredLine( + $input, + $userInput, + $input->getValidationFailedText() + ); + $this->drawEmptyLine($input, $userInput); + } + + private function drawTitle(Input $input, string $userInput) : void + { + + $this->drawCenteredLine( + $input, + $userInput, + $input->getPromptText() + ); + } + + private function drawInputField(Input $input, string $userInput) : void + { + $this->drawCenteredLine( + $input, + $userInput, + sprintf( + '%s%s%s%s%s', + $input->getStyle()->getUnselectedUnsetCode(), + $input->getStyle()->getSelectedSetCode(), + $userInput, + $input->getStyle()->getSelectedUnsetCode(), + $input->getStyle()->getUnselectedSetCode() + ) + ); + } +} diff --git a/src/Input/InputResult.php b/src/Input/InputResult.php new file mode 100644 index 00000000..43f526a3 --- /dev/null +++ b/src/Input/InputResult.php @@ -0,0 +1,24 @@ + + */ +class InputResult +{ + /** + * @var string + */ + private $input; + + public function __construct(string $input) + { + $this->input = $input; + } + + public function fetch() : string + { + return $this->input; + } +} diff --git a/src/Input/Number.php b/src/Input/Number.php new file mode 100644 index 00000000..f2db5edf --- /dev/null +++ b/src/Input/Number.php @@ -0,0 +1,107 @@ + + */ +class Number implements Input +{ + /** + * @var InputIO + */ + private $inputIO; + + /** + * @var string + */ + private $promptText = 'Enter a number:'; + + /** + * @var string + */ + private $validationFailedText = 'Not a valid number, try again'; + + /** + * @var string + */ + private $placeholderText = ''; + + /** + * @var MenuStyle + */ + private $style; + + public function __construct(InputIO $inputIO, MenuStyle $style) + { + $this->inputIO = $inputIO; + $this->style = $style; + } + + public function setPromptText(string $promptText) : Input + { + $this->promptText = $promptText; + + return $this; + } + + public function getPromptText() : string + { + return $this->promptText; + } + + public function setValidationFailedText(string $validationFailedText) : Input + { + $this->validationFailedText = $validationFailedText; + + return $this; + } + + public function getValidationFailedText() : string + { + return $this->validationFailedText; + } + + public function setPlaceholderText(string $placeholderText) : Input + { + $this->placeholderText = $placeholderText; + + return $this; + } + + public function getPlaceholderText() : string + { + return $this->placeholderText; + } + + public function ask() : InputResult + { + $this->inputIO->registerControlCallback(InputCharacter::UP, function (string $input) { + return $this->validate($input) ? $input + 1 : $input; + }); + + $this->inputIO->registerControlCallback(InputCharacter::DOWN, function (string $input) { + return $this->validate($input) ? $input - 1 : $input; + }); + + return $this->inputIO->collect($this); + } + + public function validate(string $input) : bool + { + return (bool) preg_match('/^-?\d+$/', $input); + } + + public function filter(string $value) : string + { + return $value; + } + + public function getStyle() : MenuStyle + { + return $this->style; + } +} diff --git a/src/Input/Password.php b/src/Input/Password.php new file mode 100644 index 00000000..c4daa7c6 --- /dev/null +++ b/src/Input/Password.php @@ -0,0 +1,120 @@ + + */ +class Password implements Input +{ + /** + * @var InputIO + */ + private $inputIO; + + /** + * @var string + */ + private $promptText = 'Enter password:'; + + /** + * @var string + */ + private $validationFailedText = 'Invalid password, try again'; + + /** + * @var string + */ + private $placeholderText = ''; + + /** + * @var null|callable + */ + private $validator; + + /** + * @var MenuStyle + */ + private $style; + + public function __construct(InputIO $inputIO, MenuStyle $style) + { + $this->inputIO = $inputIO; + $this->style = $style; + } + + public function setPromptText(string $promptText) : Input + { + $this->promptText = $promptText; + + return $this; + } + + public function getPromptText() : string + { + return $this->promptText; + } + + public function setValidationFailedText(string $validationFailedText) : Input + { + $this->validationFailedText = $validationFailedText; + + return $this; + } + + public function getValidationFailedText() : string + { + return $this->validationFailedText; + } + + public function setPlaceholderText(string $placeholderText) : Input + { + $this->placeholderText = $placeholderText; + + return $this; + } + + public function getPlaceholderText() : string + { + return $this->placeholderText; + } + + public function setValidator(callable $validator) : Input + { + $this->validator = $validator; + + return $this; + } + + public function ask() : InputResult + { + return $this->inputIO->collect($this); + } + + public function validate(string $input) : bool + { + if ($this->validator) { + $validator = $this->validator; + + if ($validator instanceof \Closure) { + $validator = $validator->bindTo($this); + } + + return $validator($input); + } + + return mb_strlen($input) >= 16; + } + + public function filter(string $value) : string + { + return str_repeat('*', mb_strlen($value)); + } + + public function getStyle() : MenuStyle + { + return $this->style; + } +} diff --git a/src/Input/Text.php b/src/Input/Text.php new file mode 100644 index 00000000..9b99f510 --- /dev/null +++ b/src/Input/Text.php @@ -0,0 +1,98 @@ + + */ +class Text implements Input +{ + /** + * @var InputIO + */ + private $inputIO; + + /** + * @var string + */ + private $promptText = 'Enter text:'; + + /** + * @var string + */ + private $validationFailedText = 'Invalid, try again'; + + /** + * @var string + */ + private $placeholderText = ''; + + /** + * @var MenuStyle + */ + private $style; + + public function __construct(InputIO $inputIO, MenuStyle $style) + { + $this->inputIO = $inputIO; + $this->style = $style; + } + + public function setPromptText(string $promptText) : Input + { + $this->promptText = $promptText; + + return $this; + } + + public function getPromptText() : string + { + return $this->promptText; + } + + public function setValidationFailedText(string $validationFailedText) : Input + { + $this->validationFailedText = $validationFailedText; + + return $this; + } + + public function getValidationFailedText() : string + { + return $this->validationFailedText; + } + + public function setPlaceholderText(string $placeholderText) : Input + { + $this->placeholderText = $placeholderText; + + return $this; + } + + public function getPlaceholderText() : string + { + return $this->placeholderText; + } + + public function ask() : InputResult + { + return $this->inputIO->collect($this); + } + + public function validate(string $input) : bool + { + return !empty($input); + } + + public function filter(string $value) : string + { + return $value; + } + + public function getStyle() : MenuStyle + { + return $this->style; + } +} diff --git a/src/MenuStyle.php b/src/MenuStyle.php index c1996afe..9f630719 100644 --- a/src/MenuStyle.php +++ b/src/MenuStyle.php @@ -4,7 +4,7 @@ use PhpSchool\CliMenu\Exception\InvalidInstantiationException; use PhpSchool\CliMenu\Terminal\TerminalFactory; -use PhpSchool\CliMenu\Terminal\TerminalInterface; +use PhpSchool\Terminal\Terminal; //TODO: B/W fallback @@ -14,7 +14,7 @@ class MenuStyle { /** - * @var TerminalInterface + * @var Terminal */ protected $terminal; @@ -141,7 +141,7 @@ public static function getDefaultStyleValues() : array /** * Initialise style */ - public function __construct(TerminalInterface $terminal = null) + public function __construct(Terminal $terminal = null) { $this->terminal = $terminal ?: TerminalFactory::fromSystem(); diff --git a/src/Terminal/TerminalFactory.php b/src/Terminal/TerminalFactory.php index 250d4a7f..6a5e589c 100644 --- a/src/Terminal/TerminalFactory.php +++ b/src/Terminal/TerminalFactory.php @@ -2,13 +2,18 @@ namespace PhpSchool\CliMenu\Terminal; +use PhpSchool\Terminal\IO\ResourceInputStream; +use PhpSchool\Terminal\IO\ResourceOutputStream; +use PhpSchool\Terminal\Terminal; +use PhpSchool\Terminal\UnixTerminal; + /** * @author Michael Woodward */ class TerminalFactory { - public static function fromSystem() : TerminalInterface + public static function fromSystem() : Terminal { - return new UnixTerminal(); + return new UnixTerminal(new ResourceInputStream, new ResourceOutputStream); } } diff --git a/src/Terminal/TerminalInterface.php b/src/Terminal/TerminalInterface.php deleted file mode 100644 index 85adda05..00000000 --- a/src/Terminal/TerminalInterface.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -interface TerminalInterface -{ - /** - * Get terminal details - */ - public function getDetails() : string ; - - /** - * Get the available width of the terminal - */ - public function getWidth() : int; - - /** - * Get the available height of the terminal - */ - public function getHeight() : int; - - /** - * Toggle canonical mode on TTY - */ - public function setCanonicalMode(bool $useCanonicalMode = true) : void; - - /** - * Check if TTY is in canonical mode - */ - public function isCanonical() : bool; - - /** - * Test whether terminal is valid TTY - */ - public function isTTY() : bool; - - /** - * Test whether terminal supports colour output - */ - public function supportsColour() : bool; - - /** - * Clear the terminal window - */ - public function clear() : void; - - /** - * Clear the current cursors line - */ - public function clearLine() : void; - - /** - * Move the cursor to the top left of the window - */ - public function moveCursorToTop() : void; - - /** - * Move the cursor to the start of a specific row - */ - public function moveCursorToRow(int $rowNumber) : void; - - /** - * Move the cursor to a specific column - */ - public function moveCursorToColumn(int $columnNumber) : void; - - /** - * Clean the whole console without jumping the window - */ - public function clean() : void; - - /** - * Enable cursor display - */ - public function enableCursor() : void; - - /** - * Disable cursor display - */ - public function disableCursor() : void; - - /** - * @return string - */ - public function getKeyedInput() : string; -} diff --git a/src/Terminal/UnixTerminal.php b/src/Terminal/UnixTerminal.php deleted file mode 100644 index 0e701ed3..00000000 --- a/src/Terminal/UnixTerminal.php +++ /dev/null @@ -1,224 +0,0 @@ - - */ -class UnixTerminal implements TerminalInterface -{ - /** - * @var bool - */ - private $isTTY; - - /** - * @var bool - */ - private $isCanonical = false; - - /** - * @var int - */ - private $width; - - /** - * @var int - */ - private $height; - - /** - * @var string - */ - private $details; - - /** - * @var string - */ - private $originalConfiguration; - - /** - * Initialise the terminal from resource - * - */ - public function __construct() - { - $this->getOriginalConfiguration(); - } - - /** - * Get the available width of the terminal - */ - public function getWidth() : int - { - return $this->width ?: $this->width = (int) exec('tput cols'); - } - - /** - * Get the available height of the terminal - */ - public function getHeight() : int - { - return $this->height ?: $this->height = (int) exec('tput lines'); - } - - /** - * Get terminal details - */ - public function getDetails() : string - { - if (!$this->details) { - $this->details = function_exists('posix_ttyname') - ? @posix_ttyname(STDOUT) - : "Can't retrieve terminal details"; - } - - return $this->details; - } - - /** - * Get the original terminal configuration / mode - */ - private function getOriginalConfiguration() : string - { - return $this->originalConfiguration ?: $this->originalConfiguration = exec('stty -g'); - } - - /** - * Toggle canonical mode on TTY - */ - public function setCanonicalMode(bool $useCanonicalMode = true) : void - { - if ($useCanonicalMode) { - exec('stty -icanon'); - $this->isCanonical = true; - } else { - exec('stty ' . $this->getOriginalConfiguration()); - $this->isCanonical = false; - } - } - - /** - * Check if TTY is in canonical mode - * Assumes the terminal was never in canonical mode - */ - public function isCanonical() : bool - { - return $this->isCanonical; - } - - /** - * Test whether terminal is valid TTY - */ - public function isTTY() : bool - { - return $this->isTTY ?: $this->isTTY = function_exists('posix_isatty') && @posix_isatty(STDOUT); - } - - /** - * Test whether terminal supports colour output - * - * @link https://github.com/symfony/Console/blob/master/Output/StreamOutput.php#L95-L102 - */ - public function supportsColour() : bool - { - if (DIRECTORY_SEPARATOR === '\\') { - return false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI') || 'xterm' === getenv('TERM'); - } - - return $this->isTTY(); - } - - public function getKeyedInput() : string - { - // TODO: Move to class var? - // TODO: up, down, enter etc in Abstract CONSTs - $map = [ - "\033[A" => 'up', - "k" => 'up', - "" => 'up', // emacs ^P - "\033[B" => 'down', - "j" => 'down', - "" => 'down', //emacs ^N - "\n" => 'enter', - "\r" => 'enter', - " " => 'enter', - ]; - - $input = fread(STDIN, 4); - $this->clearLine(); - - return array_key_exists($input, $map) - ? $map[$input] - : $input; - } - - /** - * Clear the terminal window - */ - public function clear() : void - { - echo "\033[2J"; - } - - /** - * Enable cursor - */ - public function enableCursor() : void - { - echo "\033[?25h"; - } - - /** - * Disable cursor - */ - public function disableCursor() : void - { - echo "\033[?25l"; - } - - /** - * Move the cursor to the top left of the window - * - * @return void - */ - public function moveCursorToTop() : void - { - echo "\033[H"; - } - - /** - * Move the cursor to the start of a specific row - */ - public function moveCursorToRow(int $rowNumber) : void - { - echo sprintf("\033[%d;0H", $rowNumber); - } - - /** - * Move the cursor to the start of a specific column - */ - public function moveCursorToColumn(int $column) : void - { - echo sprintf("\033[%dC", $column); - } - - /** - * Clear the current cursors line - */ - public function clearLine() : void - { - echo sprintf("\033[%dD\033[K", $this->getWidth()); - } - - /** - * Clean the whole console without jumping the window - */ - public function clean() : void - { - foreach (range(0, $this->getHeight()) as $rowNum) { - $this->moveCursorToRow($rowNum); - $this->clearLine(); - } - } -} diff --git a/test/CliMenuBuilderTest.php b/test/CliMenuBuilderTest.php index 6cd41fda..1cd73a98 100644 --- a/test/CliMenuBuilderTest.php +++ b/test/CliMenuBuilderTest.php @@ -9,7 +9,7 @@ use PhpSchool\CliMenu\MenuItem\MenuMenuItem; use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuItem\StaticItem; -use PhpSchool\CliMenu\Terminal\TerminalInterface; +use PhpSchool\Terminal\Terminal; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -62,7 +62,7 @@ public function testModifyStyles() : void $builder->setItemExtra('*'); $builder->setTitleSeparator('-'); - $terminal = static::createMock(TerminalInterface::class); + $terminal = static::createMock(Terminal::class); $terminal ->expects($this->any()) ->method('getWidth') diff --git a/test/CliMenuTest.php b/test/CliMenuTest.php index dd655ef6..49419ca5 100644 --- a/test/CliMenuTest.php +++ b/test/CliMenuTest.php @@ -7,8 +7,9 @@ use PhpSchool\CliMenu\MenuItem\LineBreakItem; use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuStyle; -use PhpSchool\CliMenu\Terminal\TerminalInterface; -use PhpSchool\CliMenu\Terminal\UnixTerminal; +use PhpSchool\Terminal\Terminal; +use PhpSchool\Terminal\UnixTerminal; +use PhpSchool\Terminal\IO\BufferedOutput; use PHPUnit\Framework\TestCase; /** @@ -16,6 +17,36 @@ */ class CliMenuTest extends TestCase { + /** + * @var Terminal + */ + private $terminal; + + /** + * @var BufferedOutput + */ + private $output; + + public function setUp() + { + $this->output = new BufferedOutput; + $this->terminal = $this->createMock(Terminal::class); + + $this->terminal->expects($this->any()) + ->method('isInteractive') + ->willReturn(true); + + $this->terminal->expects($this->any()) + ->method('getWidth') + ->willReturn(50); + + $this->terminal->expects($this->any()) + ->method('write') + ->will($this->returnCallback(function ($buffer) { + $this->output->write($buffer); + })); + } + public function testGetMenuStyle() : void { $menu = new CliMenu('PHP School FTW', []); @@ -37,49 +68,29 @@ public function testReDrawThrowsExceptionIfMenuNotOpen() : void public function testSimpleOpenClose() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); + $this->terminal->expects($this->once()) + ->method('read') + ->willReturn("\n"); - $terminal->expects($this->once()) - ->method('getKeyedInput') - ->willReturn('enter'); - - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testReDrawReDrawsImmediately() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); + $this->terminal->expects($this->once()) + ->method('read') + ->willReturn("\n"); - $terminal->expects($this->once()) - ->method('getKeyedInput') - ->willReturn('enter'); - - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->getStyle()->setBg('red'); @@ -87,10 +98,10 @@ public function testReDrawReDrawsImmediately() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testGetItems() : void @@ -99,7 +110,7 @@ public function testGetItems() : void $item2 = new LineBreakItem(); - $terminal = $this->createMock(TerminalInterface::class); + $terminal = $this->createMock(Terminal::class); $style = $this->getStyle($terminal); $menu = new CliMenu( @@ -120,7 +131,7 @@ public function testRemoveItem() : void $item1 = new LineBreakItem(); $item2 = new LineBreakItem(); - $terminal = $this->createMock(TerminalInterface::class); + $terminal = $this->createMock(Terminal::class); $style = $this->getStyle($terminal); $menu = new CliMenu( @@ -170,9 +181,9 @@ public function testThrowsExceptionIfTerminalIsNotValidTTY() : void { $this->expectException(\PhpSchool\CliMenu\Exception\InvalidTerminalException::class); - $terminal = $this->createMock(TerminalInterface::class); + $terminal = $this->createMock(Terminal::class); $terminal->expects($this->once()) - ->method('isTTY') + ->method('isInteractive') ->willReturn(false); $menu = new CliMenu('PHP School FTW', [], $terminal); @@ -201,12 +212,126 @@ public function testAddItem() : void $this->assertCount(1, $menu->getItems()); } + public function testAskNumberThrowsExceptionIfMenuNotOpen() : void + { + $menu = new CliMenu('PHP School FTW', []); + + static::expectException(MenuNotOpenException::class); + + $menu->askNumber(); + } + + public function testAskNumberStyle() : void + { + $terminal = $this->createMock(Terminal::class); + + $terminal->expects($this->any()) + ->method('isInteractive') + ->willReturn(true); + + $terminal->expects($this->any()) + ->method('getWidth') + ->willReturn(100); + + $terminal->expects($this->any()) + ->method('read') + ->willReturn("\n"); + + $menu = new CliMenu('PHP School FTW', [], $terminal); + + $number = null; + $menu->addItem(new SelectableItem('Ask Number', function (CliMenu $menu) use (&$number) { + $number = $menu->askNumber(); + $menu->close(); + })); + $menu->open(); + + static::assertEquals('yellow', $number->getStyle()->getBg()); + static::assertEquals('red', $number->getStyle()->getFg()); + } + + public function testAskTextThrowsExceptionIfMenuNotOpen() : void + { + $menu = new CliMenu('PHP School FTW', []); + + static::expectException(MenuNotOpenException::class); + + $menu->askText(); + } + + public function testAskTextStyle() : void + { + $terminal = $this->createMock(Terminal::class); + + $terminal->expects($this->any()) + ->method('isInteractive') + ->willReturn(true); + + $terminal->expects($this->any()) + ->method('getWidth') + ->willReturn(100); + + $terminal->expects($this->any()) + ->method('read') + ->willReturn("\n"); + + $menu = new CliMenu('PHP School FTW', [], $terminal); + + $text = null; + $menu->addItem(new SelectableItem('Ask Number', function (CliMenu $menu) use (&$text) { + $text = $menu->askText(); + $menu->close(); + })); + $menu->open(); + + static::assertEquals('yellow', $text->getStyle()->getBg()); + static::assertEquals('red', $text->getStyle()->getFg()); + } + + public function testAskPasswordThrowsExceptionIfMenuNotOpen() : void + { + $menu = new CliMenu('PHP School FTW', []); + + static::expectException(MenuNotOpenException::class); + + $menu->askPassword(); + } + + public function testAskPasswordStyle() : void + { + $terminal = $this->createMock(Terminal::class); + + $terminal->expects($this->any()) + ->method('isInteractive') + ->willReturn(true); + + $terminal->expects($this->any()) + ->method('getWidth') + ->willReturn(100); + + $terminal->expects($this->any()) + ->method('read') + ->willReturn("\n"); + + $menu = new CliMenu('PHP School FTW', [], $terminal); + + $password = null; + $menu->addItem(new SelectableItem('Ask Number', function (CliMenu $menu) use (&$password) { + $password = $menu->askPassword(); + $menu->close(); + })); + $menu->open(); + + static::assertEquals('yellow', $password->getStyle()->getBg()); + static::assertEquals('red', $password->getStyle()->getFg()); + } + private function getTestFile() : string { return sprintf('%s/res/%s.txt', __DIR__, $this->getName()); } - private function getStyle(TerminalInterface $terminal) : MenuStyle + private function getStyle(Terminal $terminal) : MenuStyle { return new MenuStyle($terminal); } diff --git a/test/Dialogue/ConfirmTest.php b/test/Dialogue/ConfirmTest.php index 10ae72b0..18ce0fd5 100644 --- a/test/Dialogue/ConfirmTest.php +++ b/test/Dialogue/ConfirmTest.php @@ -5,7 +5,8 @@ use PhpSchool\CliMenu\CliMenu; use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuStyle; -use PhpSchool\CliMenu\Terminal\TerminalInterface; +use PhpSchool\Terminal\IO\BufferedOutput; +use PhpSchool\Terminal\Terminal; use PHPUnit\Framework\TestCase; /** @@ -13,26 +14,46 @@ */ class ConfirmTest extends TestCase { - public function testConfirmWithOddLengthConfirmAndButton() : void + /** + * @var TerminalInterface + */ + private $terminal; + + /** + * @var BufferedOutput + */ + private $output; + + public function setUp() { - $terminal = $this->createMock(TerminalInterface::class); + $this->output = new BufferedOutput; + $this->terminal = $this->createMock(Terminal::class); - $terminal->expects($this->any()) - ->method('isTTY') + $this->terminal->expects($this->any()) + ->method('isInteractive') ->willReturn(true); - $terminal - ->method('getKeyedInput') - ->will($this->onConsecutiveCalls( - 'enter', - 'enter' - )); - - $terminal->expects($this->any()) + $this->terminal->expects($this->any()) ->method('getWidth') ->willReturn(50); - $style = $this->getStyle($terminal); + $this->terminal->expects($this->any()) + ->method('write') + ->will($this->returnCallback(function ($buffer) { + $this->output->write($buffer); + })); + } + + public function testConfirmWithOddLengthConfirmAndButton() : void + { + $this->terminal + ->method('read') + ->will($this->onConsecutiveCalls( + "\n", + "\n" + )); + + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->confirm('PHP School FTW!') @@ -41,32 +62,22 @@ public function testConfirmWithOddLengthConfirmAndButton() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testConfirmWithEvenLengthConfirmAndButton() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); - - $terminal - ->method('getKeyedInput') + $this->terminal + ->method('read') ->will($this->onConsecutiveCalls( - 'enter', - 'enter' + "\n", + "\n" )); - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->confirm('PHP School FTW') @@ -75,32 +86,22 @@ public function testConfirmWithEvenLengthConfirmAndButton() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testConfirmWithEvenLengthConfirmAndOddLengthButton() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); - - $terminal - ->method('getKeyedInput') + $this->terminal + ->method('read') ->will($this->onConsecutiveCalls( - 'enter', - 'enter' + "\n", + "\n" )); - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->confirm('PHP School FTW') @@ -109,32 +110,22 @@ public function testConfirmWithEvenLengthConfirmAndOddLengthButton() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testConfirmWithOddLengthConfirmAndEvenLengthButton() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); - - $terminal - ->method('getKeyedInput') + $this->terminal + ->method('read') ->will($this->onConsecutiveCalls( - 'enter', - 'enter' + "\n", + "\n" )); - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->confirm('PHP School FTW!') @@ -143,34 +134,24 @@ public function testConfirmWithOddLengthConfirmAndEvenLengthButton() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testConfirmCanOnlyBeClosedWithEnter() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); - - $terminal - ->method('getKeyedInput') + $this->terminal + ->method('read') ->will($this->onConsecutiveCalls( - 'enter', + "\n", 'up', 'down', - 'enter' + "\n" )); - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->confirm('PHP School FTW!') @@ -179,10 +160,10 @@ public function testConfirmCanOnlyBeClosedWithEnter() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } private function getTestFile() : string @@ -190,7 +171,7 @@ private function getTestFile() : string return sprintf('%s/../res/%s.txt', __DIR__, $this->getName()); } - private function getStyle(TerminalInterface $terminal) : MenuStyle + private function getStyle(Terminal $terminal) : MenuStyle { return new MenuStyle($terminal); } diff --git a/test/Dialogue/FlashTest.php b/test/Dialogue/FlashTest.php index dced92ce..51b8c852 100644 --- a/test/Dialogue/FlashTest.php +++ b/test/Dialogue/FlashTest.php @@ -5,7 +5,8 @@ use PhpSchool\CliMenu\CliMenu; use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuStyle; -use PhpSchool\CliMenu\Terminal\TerminalInterface; +use PhpSchool\Terminal\IO\BufferedOutput; +use PhpSchool\Terminal\Terminal; use PHPUnit\Framework\TestCase; /** @@ -13,26 +14,46 @@ */ class FlashTest extends TestCase { - public function testFlashWithOddLength() : void + /** + * @var TerminalInterface + */ + private $terminal; + + /** + * @var BufferedOutput + */ + private $output; + + public function setUp() { - $terminal = $this->createMock(TerminalInterface::class); + $this->output = new BufferedOutput; + $this->terminal = $this->createMock(Terminal::class); - $terminal->expects($this->any()) - ->method('isTTY') + $this->terminal->expects($this->any()) + ->method('isInteractive') ->willReturn(true); - $terminal - ->method('getKeyedInput') - ->will($this->onConsecutiveCalls( - 'enter', - 'enter' - )); - - $terminal->expects($this->any()) + $this->terminal->expects($this->any()) ->method('getWidth') ->willReturn(50); - $style = $this->getStyle($terminal); + $this->terminal->expects($this->any()) + ->method('write') + ->will($this->returnCallback(function ($buffer) { + $this->output->write($buffer); + })); + } + + public function testFlashWithOddLength() : void + { + $this->terminal + ->method('read') + ->will($this->onConsecutiveCalls( + "\n", + "\n" + )); + + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->flash('PHP School FTW!') @@ -41,32 +62,22 @@ public function testFlashWithOddLength() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function testFlashWithEvenLength() : void { - $terminal = $this->createMock(TerminalInterface::class); - - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); - - $terminal - ->method('getKeyedInput') + $this->terminal + ->method('read') ->will($this->onConsecutiveCalls( - 'enter', - 'enter' + "\n", + "\n" )); - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->flash('PHP School FTW') @@ -75,10 +86,10 @@ public function testFlashWithEvenLength() : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } /** @@ -86,21 +97,11 @@ public function testFlashWithEvenLength() : void */ public function testFlashCanBeClosedWithAnyKey(string $key) : void { - $terminal = $this->createMock(TerminalInterface::class); + $this->terminal + ->method('read') + ->will($this->onConsecutiveCalls("\n", $key)); - $terminal->expects($this->any()) - ->method('isTTY') - ->willReturn(true); - - $terminal - ->method('getKeyedInput') - ->will($this->onConsecutiveCalls('enter', $key)); - - $terminal->expects($this->any()) - ->method('getWidth') - ->willReturn(50); - - $style = $this->getStyle($terminal); + $style = $this->getStyle($this->terminal); $item = new SelectableItem('Item 1', function (CliMenu $menu) { $menu->flash('PHP School FTW!') @@ -109,16 +110,16 @@ public function testFlashCanBeClosedWithAnyKey(string $key) : void $menu->close(); }); - $this->expectOutputString(file_get_contents($this->getTestFile())); - - $menu = new CliMenu('PHP School FTW', [$item], $terminal, $style); + $menu = new CliMenu('PHP School FTW', [$item], $this->terminal, $style); $menu->open(); + + static::assertStringEqualsFile($this->getTestFile(), $this->output->fetch()); } public function keyProvider() : array { return [ - ['enter'], + ["\n"], ['right'], ['down'], ['up'], @@ -130,7 +131,7 @@ private function getTestFile() : string return sprintf('%s/../res/%s.txt', __DIR__, $this->getName(false)); } - private function getStyle(TerminalInterface $terminal) : MenuStyle + private function getStyle(Terminal $terminal) : MenuStyle { return new MenuStyle($terminal); } diff --git a/test/Input/InputIOTest.php b/test/Input/InputIOTest.php new file mode 100644 index 00000000..58403e8a --- /dev/null +++ b/test/Input/InputIOTest.php @@ -0,0 +1,128 @@ + + */ +class InputIOTest extends TestCase +{ + /** + * @var Terminal + */ + private $terminal; + + /** + * @var BufferedOutput + */ + private $output; + + /** + * @var CliMenu + */ + private $menu; + + /** + * @var MenuStyle + */ + private $style; + + /** + * @var InputIO + */ + private $inputIO; + + public function setUp() + { + $this->terminal = $this->createMock(Terminal::class); + $this->output = new BufferedOutput; + $this->menu = $this->createMock(CliMenu::class); + $this->style = new MenuStyle($this->terminal); + $this->inputIO = new InputIO($this->menu, $this->terminal); + + $this->style->setBg('yellow'); + $this->style->setFg('red'); + + $this->terminal + ->method('getWidth') + ->willReturn(100); + + $parentStyle = new MenuStyle($this->terminal); + $parentStyle->setBg('blue'); + + $this->menu + ->expects($this->any()) + ->method('getStyle') + ->willReturn($parentStyle); + } + + public function testEnterReturnsOutputIfValid() : void + { + $this->terminal + ->expects($this->exactly(2)) + ->method('read') + ->willReturn('1', "\n"); + + $result = $this->inputIO->collect(new Text($this->inputIO, $this->style)); + + self::assertEquals('1', $result->fetch()); + + echo $this->output->fetch(); + } + + public function testCustomControlFunctions() : void + { + $this->inputIO->registerControlCallback(InputCharacter::UP, function ($input) { + return ++$input; + }); + + $this->terminal + ->expects($this->exactly(4)) + ->method('read') + ->willReturn('1', '0', "\033[A", "\n"); + + $result = $this->inputIO->collect(new Text($this->inputIO, $this->style)); + + self::assertEquals('11', $result->fetch()); + } + + public function testBackspaceDeletesPreviousCharacter() : void + { + $this->terminal + ->expects($this->exactly(6)) + ->method('read') + ->willReturn('1', '6', '7', "\177", "\177", "\n"); + + $result = $this->inputIO->collect(new Text($this->inputIO, $this->style)); + + self::assertEquals('1', $result->fetch()); + } + + public function testValidationErrorCausesErrorMessageToBeDisplayed() : void + { + $input = new class ($this->inputIO, $this->style) extends Text { + public function validate(string $input) : bool + { + return $input[-1] === 'p'; + } + }; + + $this->terminal + ->expects($this->exactly(6)) + ->method('read') + ->willReturn('1', 't', "\n", "\177", 'p', "\n"); + + $result = $this->inputIO->collect($input); + + self::assertEquals('1p', $result->fetch()); + } +} diff --git a/test/Input/InputResultTest.php b/test/Input/InputResultTest.php new file mode 100644 index 00000000..5838421d --- /dev/null +++ b/test/Input/InputResultTest.php @@ -0,0 +1,17 @@ + + */ +class InputResultTest extends TestCase +{ + public function testFetch() : void + { + static::assertEquals('my-password', (new InputResult('my-password'))->fetch()); + } +} diff --git a/test/Input/NumberTest.php b/test/Input/NumberTest.php new file mode 100644 index 00000000..6fa2f94a --- /dev/null +++ b/test/Input/NumberTest.php @@ -0,0 +1,115 @@ + + */ +class NumberTest extends TestCase +{ + /** + * @var Terminal + */ + private $terminal; + + /** + * @var InputIO + */ + private $inputIO; + + /** + * @var Number + */ + private $input; + + public function setUp() + { + $this->terminal = $this->createMock(Terminal::class); + $menu = $this->createMock(CliMenu::class); + $style = $this->createMock(MenuStyle::class); + + $this->inputIO = new InputIO($menu, $this->terminal); + $this->input = new Number($this->inputIO, $style); + } + + public function testGetSetPromptText() : void + { + static::assertEquals('Enter a number:', $this->input->getPromptText()); + + $this->input->setPromptText('Number please:'); + static::assertEquals('Number please:', $this->input->getPromptText()); + } + + public function testGetSetValidationFailedText() : void + { + static::assertEquals('Not a valid number, try again', $this->input->getValidationFailedText()); + + $this->input->setValidationFailedText('Failed!'); + static::assertEquals('Failed!', $this->input->getValidationFailedText()); + } + + public function testGetSetPlaceholderText() : void + { + static::assertEquals('', $this->input->getPlaceholderText()); + + $this->input->setPlaceholderText('some placeholder text'); + static::assertEquals('some placeholder text', $this->input->getPlaceholderText()); + } + + /** + * @dataProvider validateProvider + */ + public function testValidate(string $value, bool $result) : void + { + static::assertEquals($this->input->validate($value), $result); + } + + public function validateProvider() : array + { + return [ + ['10', true], + ['10t', false], + ['t10', false], + ['0', true], + ['0000000000', true], + ['9999999999', true], + ['-9999999999', true], + ['-54', true], + ['-1', true], + ['-t10', false], + ['-t', false], + ]; + } + + public function testFilterReturnsInputAsIs() : void + { + static::assertEquals('9999', $this->input->filter('9999')); + } + + public function testUpKeyIncrementsNumber() : void + { + $this->terminal + ->expects($this->exactly(4)) + ->method('read') + ->willReturn('1', '0', "\033[A", "\n"); + + self::assertEquals(11, $this->input->ask()->fetch()); + } + + public function testDownKeyDecrementsNumber() : void + { + $this->terminal + ->expects($this->exactly(4)) + ->method('read') + ->willReturn('1', '0', "\033[B", "\n"); + + self::assertEquals(9, $this->input->ask()->fetch()); + } +} diff --git a/test/Input/PasswordTest.php b/test/Input/PasswordTest.php new file mode 100644 index 00000000..c38b8401 --- /dev/null +++ b/test/Input/PasswordTest.php @@ -0,0 +1,139 @@ + + */ +class PasswordTest extends TestCase +{ + /** + * @var Terminal + */ + private $terminal; + + /** + * @var InputIO + */ + private $inputIO; + + /** + * @var Text + */ + private $input; + + public function setUp() + { + $this->terminal = $this->createMock(Terminal::class); + $menu = $this->createMock(CliMenu::class); + $style = $this->createMock(MenuStyle::class); + + $this->inputIO = new InputIO($menu, $this->terminal); + $this->input = new Password($this->inputIO, $style); + } + + public function testGetSetPromptText() : void + { + static::assertEquals('Enter password:', $this->input->getPromptText()); + + $this->input->setPromptText('Password please:'); + static::assertEquals('Password please:', $this->input->getPromptText()); + } + + public function testGetSetValidationFailedText() : void + { + static::assertEquals('Invalid password, try again', $this->input->getValidationFailedText()); + + $this->input->setValidationFailedText('Failed!'); + static::assertEquals('Failed!', $this->input->getValidationFailedText()); + } + + public function testGetSetPlaceholderText() : void + { + static::assertEquals('', $this->input->getPlaceholderText()); + + $this->input->setPlaceholderText('***'); + static::assertEquals('***', $this->input->getPlaceholderText()); + } + + /** + * @dataProvider validateProvider + */ + public function testValidate(string $value, bool $result) : void + { + static::assertEquals($this->input->validate($value), $result); + } + + public function validateProvider() : array + { + return [ + ['10', false], + ['mypassword', false], + ['pppppppppppppppp', true], + ]; + } + + public function testFilterConcealsPassword() : void + { + static::assertEquals('****', $this->input->filter('pppp')); + } + + public function testAskPassword() : void + { + $this->terminal + ->expects($this->exactly(17)) + ->method('read') + ->willReturn('1', '2', '3', '4', '5', '6', '7', '8', '9', '1', '2', '3', '4', '5', '6', '7', "\n"); + + self::assertEquals('1234567891234567', $this->input->ask()->fetch()); + } + + /** + * @dataProvider customValidateProvider + */ + public function testValidateWithCustomValidator(string $value, bool $result) : void + { + $customValidate = function ($input) { + return preg_match('/\d/', $input) && preg_match('/[a-zA-Z]/', $input); + }; + + $this->input->setValidator($customValidate); + + static::assertEquals($this->input->validate($value), $result); + } + + public function customValidateProvider() : array + { + return [ + ['10', false], + ['mypassword', false], + ['pppppppppppppppp', false], + ['1t', true], + ['999ppp', true], + ]; + } + + public function testWithCustomValidatorAndCustomValidationMessage() : void + { + $customValidate = function ($input) { + if ($input === 'mypassword') { + $this->setValidationFailedText('Password too generic'); + return false; + } + return true; + }; + + $this->input->setValidator($customValidate); + + self::assertTrue($this->input->validate('superstrongpassword')); + self::assertFalse($this->input->validate('mypassword')); + self::assertEquals('Password too generic', $this->input->getValidationFailedText()); + } +} diff --git a/test/Input/TextTest.php b/test/Input/TextTest.php new file mode 100644 index 00000000..2b3e6125 --- /dev/null +++ b/test/Input/TextTest.php @@ -0,0 +1,97 @@ + + */ +class TextTest extends TestCase +{ + /** + * @var Terminal + */ + private $terminal; + + /** + * @var InputIO + */ + private $inputIO; + + /** + * @var Text + */ + private $input; + + public function setUp() + { + $this->terminal = $this->createMock(Terminal::class); + $menu = $this->createMock(CliMenu::class); + $style = $this->createMock(MenuStyle::class); + + $this->inputIO = new InputIO($menu, $this->terminal); + $this->input = new Text($this->inputIO, $style); + } + + public function testGetSetPromptText() : void + { + static::assertEquals('Enter text:', $this->input->getPromptText()); + + $this->input->setPromptText('Text please:'); + static::assertEquals('Text please:', $this->input->getPromptText()); + } + + public function testGetSetValidationFailedText() : void + { + static::assertEquals('Invalid, try again', $this->input->getValidationFailedText()); + + $this->input->setValidationFailedText('Failed!'); + static::assertEquals('Failed!', $this->input->getValidationFailedText()); + } + + public function testGetSetPlaceholderText() : void + { + static::assertEquals('', $this->input->getPlaceholderText()); + + $this->input->setPlaceholderText('My Title'); + static::assertEquals('My Title', $this->input->getPlaceholderText()); + } + + /** + * @dataProvider validateProvider + */ + public function testValidate(string $value, bool $result) : void + { + static::assertEquals($this->input->validate($value), $result); + } + + public function validateProvider() : array + { + return [ + ['', false], + ['some text', true], + ['some more text', true], + ]; + } + + public function testFilterReturnsInputAsIs() : void + { + static::assertEquals('9999', $this->input->filter('9999')); + } + + public function testAskText() : void + { + $this->terminal + ->expects($this->exactly(10)) + ->method('read') + ->willReturn('s', 'o', 'm', 'e', ' ', 't', 'e', 'x', 't', "\n"); + + self::assertEquals('some text', $this->input->ask()->fetch()); + } +} diff --git a/test/res/testConfirmCanOnlyBeClosedWithEnter.txt b/test/res/testConfirmCanOnlyBeClosedWithEnter.txt index 8d660f00..6d515273 100644 --- a/test/res/testConfirmCanOnlyBeClosedWithEnter.txt +++ b/test/res/testConfirmCanOnlyBeClosedWithEnter.txt @@ -1,23 +1,23 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW!    -  < OK >   +  < OK >       -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testConfirmWithEvenLengthConfirmAndButton.txt b/test/res/testConfirmWithEvenLengthConfirmAndButton.txt index 614464dc..81ed512f 100644 --- a/test/res/testConfirmWithEvenLengthConfirmAndButton.txt +++ b/test/res/testConfirmWithEvenLengthConfirmAndButton.txt @@ -1,23 +1,23 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW    -  < OK >   +  < OK >       -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testConfirmWithEvenLengthConfirmAndOddLengthButton.txt b/test/res/testConfirmWithEvenLengthConfirmAndOddLengthButton.txt index 770bd618..ab1ea82d 100644 --- a/test/res/testConfirmWithEvenLengthConfirmAndOddLengthButton.txt +++ b/test/res/testConfirmWithEvenLengthConfirmAndOddLengthButton.txt @@ -1,23 +1,23 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW    -  < OK! >   +  < OK! >       -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testConfirmWithOddLengthConfirmAndButton.txt b/test/res/testConfirmWithOddLengthConfirmAndButton.txt index b77a3d67..9739d1df 100644 --- a/test/res/testConfirmWithOddLengthConfirmAndButton.txt +++ b/test/res/testConfirmWithOddLengthConfirmAndButton.txt @@ -1,23 +1,23 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW!    -  < OK! >   +  < OK! >       -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testConfirmWithOddLengthConfirmAndEvenLengthButton.txt b/test/res/testConfirmWithOddLengthConfirmAndEvenLengthButton.txt index 8d660f00..6d515273 100644 --- a/test/res/testConfirmWithOddLengthConfirmAndEvenLengthButton.txt +++ b/test/res/testConfirmWithOddLengthConfirmAndEvenLengthButton.txt @@ -1,23 +1,23 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW!    -  < OK >   +  < OK >       -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testFlashCanBeClosedWithAnyKey.txt b/test/res/testFlashCanBeClosedWithAnyKey.txt index c06ba9ae..ce963387 100644 --- a/test/res/testFlashCanBeClosedWithAnyKey.txt +++ b/test/res/testFlashCanBeClosedWithAnyKey.txt @@ -1,11 +1,11 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW!  @@ -13,9 +13,9 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testFlashWithEvenLength.txt b/test/res/testFlashWithEvenLength.txt index 6fba08a7..a32aedd0 100644 --- a/test/res/testFlashWithEvenLength.txt +++ b/test/res/testFlashWithEvenLength.txt @@ -1,11 +1,11 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW  @@ -13,9 +13,9 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testFlashWithOddLength.txt b/test/res/testFlashWithOddLength.txt index c06ba9ae..ce963387 100644 --- a/test/res/testFlashWithOddLength.txt +++ b/test/res/testFlashWithOddLength.txt @@ -1,11 +1,11 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +    PHP School FTW!  @@ -13,9 +13,9 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testReDrawReDrawsImmediately.txt b/test/res/testReDrawReDrawsImmediately.txt index 4ace7f16..0867fb87 100644 --- a/test/res/testReDrawReDrawsImmediately.txt +++ b/test/res/testReDrawReDrawsImmediately.txt @@ -1,18 +1,18 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   + diff --git a/test/res/testSimpleOpenClose.txt b/test/res/testSimpleOpenClose.txt index dd97f8c2..dfd69aef 100644 --- a/test/res/testSimpleOpenClose.txt +++ b/test/res/testSimpleOpenClose.txt @@ -1,9 +1,9 @@   -  PHP School FTW  -  ==========================================  -  ● Item 1  -   - +  PHP School FTW  +  ==========================================  +  ● Item 1  +   +