diff --git a/src/CliMenu.php b/src/CliMenu.php index 43d6da09..cccdb731 100644 --- a/src/CliMenu.php +++ b/src/CliMenu.php @@ -12,6 +12,7 @@ use PhpSchool\CliMenu\Input\Text; use PhpSchool\CliMenu\MenuItem\LineBreakItem; use PhpSchool\CliMenu\MenuItem\MenuItemInterface; +use PhpSchool\CliMenu\MenuItem\SplitItem; use PhpSchool\CliMenu\MenuItem\StaticItem; use PhpSchool\CliMenu\Dialogue\Confirm; use PhpSchool\CliMenu\Dialogue\Flash; @@ -256,6 +257,8 @@ private function display() : void switch ($char->getControl()) { case InputCharacter::UP: case InputCharacter::DOWN: + case InputCharacter::LEFT: + case InputCharacter::RIGHT: $this->moveSelection($char->getControl()); $this->draw(); break; @@ -272,25 +275,57 @@ private function display() : void protected function moveSelection(string $direction) : void { do { - $itemKeys = array_keys($this->items); - - $direction === 'UP' - ? $this->selectedItem-- - : $this->selectedItem++; - - if (!array_key_exists($this->selectedItem, $this->items)) { - $this->selectedItem = $direction === 'UP' - ? end($itemKeys) - : reset($itemKeys); - } elseif ($this->getSelectedItem()->canSelect()) { - return; + if ($direction === 'UP' || $direction === 'DOWN') { + $itemKeys = array_keys($this->items); + + $direction === 'UP' + ? $this->selectedItem-- + : $this->selectedItem++; + + if (!array_key_exists($this->selectedItem, $this->items)) { + $this->selectedItem = $direction === 'UP' + ? end($itemKeys) + : reset($itemKeys); + } elseif ($this->getSelectedItem()->canSelect()) { + return; + } + } else { + $item = $this->getSelectedItem(true); + if (!$item instanceof SplitItem) { + return; + } + + $itemKeys = array_keys($item->getItems()); + $selectedItemIndex = $item->getSelectedItemIndex(); + $direction === 'LEFT' + ? $selectedItemIndex-- + : $selectedItemIndex++; + $item->setSelectedItemIndex($selectedItemIndex); + + if (!array_key_exists($selectedItemIndex, $item->getItems())) { + $selectedItemIndex = $direction === 'LEFT' + ? end($itemKeys) + : reset($itemKeys); + $item->setSelectedItemIndex($selectedItemIndex); + } elseif ($item->getItems()[$item->getSelectedItemIndex()]->canSelect()) { + return; + } } } while (!$this->getSelectedItem()->canSelect()); } - public function getSelectedItem() : MenuItemInterface + public function getSelectedItem(bool $oneLevelDeep = false) : MenuItemInterface { - return $this->items[$this->selectedItem]; + if ($oneLevelDeep) { + return $this->items[$this->selectedItem]; + } else { + $item = $this->items[$this->selectedItem]; + if ($item instanceof SplitItem) { + $item = $item->getItems()[$item->getSelectedItemIndex()]; + } + + return $item; + } } /** @@ -385,6 +420,10 @@ protected function draw() : void protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array { $rows = $item->getRows($this->style, $selected); + + if ($item instanceof SplitItem) { + $selected = false; + } $invertedColoursSetCode = $selected ? $this->style->getInvertedColoursSetCode() diff --git a/src/CliMenuBuilder.php b/src/CliMenuBuilder.php index 62d092ca..3d8c3b6e 100644 --- a/src/CliMenuBuilder.php +++ b/src/CliMenuBuilder.php @@ -9,6 +9,7 @@ use PhpSchool\CliMenu\MenuItem\MenuItemInterface; use PhpSchool\CliMenu\MenuItem\MenuMenuItem; use PhpSchool\CliMenu\MenuItem\SelectableItem; +use PhpSchool\CliMenu\MenuItem\SplitItem; use PhpSchool\CliMenu\MenuItem\StaticItem; use PhpSchool\CliMenu\Terminal\TerminalFactory; use PhpSchool\CliMenu\Util\ColourUtil; @@ -31,6 +32,8 @@ class CliMenuBuilder */ private $parent; + private $previousBuilder = null; + /** * @var self[] */ @@ -81,9 +84,10 @@ class CliMenuBuilder */ private $disabled = false; - public function __construct(CliMenuBuilder $parent = null) + public function __construct(CliMenuBuilder $parent = null, $previousBuilder = null) { $this->parent = $parent; + $this->previousBuilder = $previousBuilder; $this->terminal = $this->parent !== null ? $this->parent->getTerminal() : TerminalFactory::fromSystem(); @@ -161,6 +165,27 @@ public function addSubMenu(string $id, CliMenuBuilder $subMenuBuilder = null) : return $this; } + /** + * Injects a submenu directly (without going through the builder + */ + public function injectSubMenu(string $id, CliMenu $subMenu) : CliMenuBuilder + { + $this->subMenus[$id] = $subMenu; + + return $this; + } + + /** + * Add a split item + */ + public function addSplitItem() : SplitItem + { + $splitItem = new SplitItem($this); + $this->addMenuItem($splitItem); + + return $splitItem; + } + /** * Disable a submenu * @@ -411,8 +436,12 @@ private function buildStyle() : MenuStyle * * @throws RuntimeException */ - public function end() : CliMenuBuilder + public function end() { + if (null !== $this->previousBuilder) { + return $this->previousBuilder; + } + if (null === $this->parent) { throw new RuntimeException('No parent builder to return to'); } diff --git a/src/MenuItem/SplitItem.php b/src/MenuItem/SplitItem.php new file mode 100644 index 00000000..f9ea9c8c --- /dev/null +++ b/src/MenuItem/SplitItem.php @@ -0,0 +1,303 @@ + + */ +class SplitItem implements MenuItemInterface +{ + /** + * @var array + */ + private $items; + + /** + * @var CliMenuBuilder + */ + private $parentBuilder; + + /** + * @var int + */ + private $selectedItemIndex; + + /** + * @var bool + */ + private $canBeSelected = true; + + /** + * @var int + */ + private $margin = 2; + + /** + * @var array + */ + private $blacklistedItems = [ + '\PhpSchool\CliMenu\MenuItem\AsciiArtItem', + '\PhpSchool\CliMenu\MenuItem\LineBreakItem', + '\PhpSchool\CliMenu\MenuItem\SplitItem', + ]; + + + public function __construct(CliMenuBuilder $builder, array $items = []) + { + $this->parentBuilder = $builder; + $this->items = $items; + + $this->setDefaultSelectedItem(); + } + + /** + * Select default item + */ + private function setDefaultSelectedItem() + { + foreach ($this->items as $index => $item) { + if ($item instanceof MenuItemInterface && $item->canSelect()) { + $this->canBeSelected = true; + $this->selectedItemIndex = $index; + return; + } + } + + $this->canBeSelected = false; + $this->selectedItemIndex = null; + } + + public function addMenuItem(MenuItemInterface $item) : self + { + foreach ($this->blacklistedItems as $bl) { + if ($item instanceof $bl) { + throw new \InvalidArgumentException("Cannot add a $bl to a SplitItem"); + } + } + + $this->items[] = $item; + + $this->setDefaultSelectedItem(); + + return $this; + } + + public function addMenuItems(array $items) : self + { + foreach ($items as $item) { + $this->addMenuItem($item); + } + + return $this; + } + + public function setItems(array $items) : self + { + $this->items = []; + $this->addMenuItems($item); + + return $this; + } + + public function addItem( + string $text, + callable $itemCallable, + bool $showItemExtra = false, + bool $disabled = false + ) : self { + $this->items[] = new SelectableItem($text, $itemCallable, $showItemExtra, $disabled); + + return $this; + } + + public function addStaticItem(string $text) : self + { + $this->items[] = new StaticItem($text); + + return $this; + } + + public function addSubMenu(string $id, CliMenuBuilder $subMenuBuilder = null) : CliMenuBuilder + { + if (null === $subMenuBuilder) { + $subMenuBuilder = new CliMenuBuilder($this->parentBuilder, $this); + } + + $this->items[] = $id; + $this->subMenuBuilders[$id] = $subMenuBuilder; + + return $subMenuBuilder; + } + + public function end() : CliMenuBuilder + { + $this->items = array_map(function ($item) { + if (!is_string($item) || empty($this->subMenuBuilders[$item])) { + return $item; + } + + $subMenuBuilder = $this->subMenuBuilders[$item]; + $subMenu = $subMenuBuilder->build(); + $this->parentBuilder->injectSubMenu($item, $subMenu); + + return new MenuMenuItem($item, $subMenu, $subMenuBuilder->isMenuDisabled()); + }, $this->items); + + $this->setDefaultSelectedItem(); + + return $this->parentBuilder; + } + + /** + * The output text for the item + */ + public function getRows(MenuStyle $style, bool $selected = false) : array + { + $numberOfItems = count($this->items); + + if (!$selected) { + $this->setDefaultSelectedItem(); + } + + $length = $style->getDisplaysExtra() + ? floor(($style->getContentWidth() - mb_strlen($style->getItemExtra()) + 2) / $numberOfItems) - $this->margin + : floor($style->getContentWidth() / $numberOfItems) - $this->margin; + $missingLength = $style->getContentWidth() % $numberOfItems; + + $lines = 0; + $cells = []; + foreach ($this->items as $index => $item) { + $isSelected = $selected && $index === $this->selectedItemIndex; + $marker = sprintf("%s ", $style->getMarker($isSelected)); + $content = StringUtil::wordwrap( + sprintf('%s%s', $marker, $item->getText()), + $length + ); + $cell = array_map(function ($row) use ($index, $length, $style, $isSelected) { + $invertedColoursSetCode = $isSelected + ? $style->getInvertedColoursSetCode() + : ''; + $invertedColoursUnsetCode = $isSelected + ? $style->getInvertedColoursUnsetCode() + : ''; + + return sprintf( + "%s%s%s%s%s", + $invertedColoursSetCode, + $row, + str_repeat(' ', $length - mb_strlen($row)), + $invertedColoursUnsetCode, + str_repeat(' ', $this->margin) + ); + }, explode("\n", $content)); + $lineCount = count($cell); + if ($lineCount > $lines) { + $lines = $lineCount; + } + $cells[] = $cell; + } + + $rows = []; + for ($i = 0; $i < $lines; $i++) { + $row = ""; + if ($i > 0) { + $row .= str_repeat(' ', 2); + } + foreach ($cells as $cell) { + if (isset($cell[$i])) { + $row .= $cell[$i]; + } else { + $row .= str_repeat(' ', $length); + } + } + if ($missingLength) { + $row .= str_repeat(' ', $missingLength); + } + $rows[] = $row; + } + + return $rows; + } + + public function setSelectedItemIndex(int $index) : void + { + $this->selectedItemIndex = $index; + } + + public function getSelectedItemIndex() : int + { + if ($this->selectedItemIndex === null) { + return 0; + } + return $this->selectedItemIndex; + } + + public function getSelectedItem() : MenuItem + { + return $this->items[$this->selectedItemIndex]; + } + + public function getItems() : array + { + return $this->items; + } + + /** + * Can the item be selected + * Not really in this case but that's the trick + */ + public function canSelect() : bool + { + return $this->canBeSelected; + } + + /** + * Execute the items callable if required + */ + public function getSelectAction() : ?callable + { + return null; + } + + /** + * Whether or not the menu item is showing the menustyle extra value + */ + public function showsItemExtra() : bool + { + return false; + } + + /** + * Enable showing item extra + */ + public function showItemExtra() : void + { + //noop + } + + /** + * Disable showing item extra + */ + public function hideItemExtra() : void + { + //noop + } + + /** + * Return the raw string of text + */ + public function getText() : string + { + $text = []; + foreach ($this->items as $item) { + $text[] = $item->getText(); + } + return explode(' - ', $text); + } +}