diff --git a/examples/checkable-item.php b/examples/checkable-item.php new file mode 100644 index 00000000..13fdde04 --- /dev/null +++ b/examples/checkable-item.php @@ -0,0 +1,41 @@ +getSelectedItem(); + + $item->toggle(); + + $menu->redraw(); +}; + +$menu = (new CliMenuBuilder) + ->setTitle('Select a Language') + ->addSubMenu('Compiled', function (CliMenuBuilder $b) use ($itemCallable) { + $b->setTitle('Compiled Languages') + ->addCheckableItem('Rust', $itemCallable) + ->addCheckableItem('C++', $itemCallable) + ->addCheckableItem('Go', $itemCallable) + ->addCheckableItem('Java', $itemCallable) + ->addCheckableItem('C', $itemCallable) + ; + }) + ->addSubMenu('Interpreted', function (CliMenuBuilder $b) use ($itemCallable) { + $b->setTitle('Interpreted Languages') + ->setUncheckedMarker('[○] ') + ->setCheckedMarker('[●] ') + ->addCheckableItem('PHP', $itemCallable) + ->addCheckableItem('Javascript', $itemCallable) + ->addCheckableItem('Ruby', $itemCallable) + ->addCheckableItem('Python', $itemCallable) + ; + }) + ->build(); + +$menu->open(); diff --git a/src/Builder/CliMenuBuilder.php b/src/Builder/CliMenuBuilder.php index af9668a6..7bd4188d 100644 --- a/src/Builder/CliMenuBuilder.php +++ b/src/Builder/CliMenuBuilder.php @@ -6,6 +6,7 @@ use PhpSchool\CliMenu\Action\GoBackAction; use PhpSchool\CliMenu\Exception\InvalidShortcutException; use PhpSchool\CliMenu\MenuItem\AsciiArtItem; +use PhpSchool\CliMenu\MenuItem\CheckableItem; use PhpSchool\CliMenu\MenuItem\LineBreakItem; use PhpSchool\CliMenu\MenuItem\MenuItemInterface; use PhpSchool\CliMenu\MenuItem\MenuMenuItem; @@ -130,6 +131,17 @@ public function addItems(array $items) : self return $this; } + public function addCheckableItem( + string $text, + callable $itemCallable, + bool $showItemExtra = false, + bool $disabled = false + ) : self { + $this->addMenuItem(new CheckableItem($text, $itemCallable, $showItemExtra, $disabled)); + + return $this; + } + public function addStaticItem(string $text) : self { $this->addMenuItem(new StaticItem($text)); @@ -395,6 +407,20 @@ public function setSelectedMarker(string $marker) : self return $this; } + public function setUncheckedMarker(string $marker) : self + { + $this->style->setUncheckedMarker($marker); + + return $this; + } + + public function setCheckedMarker(string $marker) : self + { + $this->style->setCheckedMarker($marker); + + return $this; + } + public function setItemExtra(string $extra) : self { $this->style->setItemExtra($extra); diff --git a/src/MenuItem/CheckableItem.php b/src/MenuItem/CheckableItem.php new file mode 100644 index 00000000..93f021d7 --- /dev/null +++ b/src/MenuItem/CheckableItem.php @@ -0,0 +1,166 @@ +text = $text; + $this->selectAction = $selectAction; + $this->showItemExtra = $showItemExtra; + $this->disabled = $disabled; + } + + /** + * Execute the items callable if required + */ + public function getSelectAction() : ?callable + { + return $this->selectAction; + } + + /** + * Return the raw string of text + */ + public function getText() : string + { + return $this->text; + } + + /** + * Set the raw string of text + */ + public function setText(string $text) : void + { + $this->text = $text; + } + + /** + * The output text for the item + * + * @param MenuStyle $style + * @param bool $selected Currently unused in this class + * @return array + */ + public function getRows(MenuStyle $style, bool $selected = false) : array + { + $marker = sprintf("%s", $this->checked ? $style->getCheckedMarker() : $style->getUncheckedMarker()); + + $length = $style->getDisplaysExtra() + ? $style->getContentWidth() - (mb_strlen($style->getItemExtra()) + 2) + : $style->getContentWidth(); + + $rows = explode( + "\n", + StringUtil::wordwrap( + sprintf('%s%s', $marker, $this->text), + $length, + sprintf("\n%s", str_repeat(' ', mb_strlen($marker))) + ) + ); + + return array_map(function ($row, $key) use ($style, $length) { + $text = $this->disabled ? $style->getDisabledItemText($row) : $row; + + if ($key === 0) { + return $this->showItemExtra + ? sprintf('%s%s %s', $text, str_repeat(' ', $length - mb_strlen($row)), $style->getItemExtra()) + : $text; + } + + return $text; + }, $rows, array_keys($rows)); + } + + /** + * Can the item be selected + */ + public function canSelect() : bool + { + return !$this->disabled; + } + + public function showsItemExtra() : bool + { + return $this->showItemExtra; + } + + /** + * Enable showing item extra + */ + public function showItemExtra() : void + { + $this->showItemExtra = true; + } + + /** + * Disable showing item extra + */ + public function hideItemExtra() : void + { + $this->showItemExtra = false; + } + + /** + * Toggles checked state + */ + public function toggle() + { + $this->checked = !$this->checked; + } + + /** + * Sets checked state to true + */ + public function setChecked() + { + $this->checked = true; + } + + /** + * Sets checked state to false + */ + public function setUnchecked() + { + $this->checked = false; + } + + public function getChecked(): bool + { + return $this->checked; + } +} diff --git a/src/MenuStyle.php b/src/MenuStyle.php index a740c40a..eececec9 100644 --- a/src/MenuStyle.php +++ b/src/MenuStyle.php @@ -69,6 +69,16 @@ class MenuStyle */ private $unselectedMarker; + /** + * @var string + */ + private $checkedMarker; + + /** + * @var string + */ + private $uncheckedMarker; + /** * @var string */ @@ -158,6 +168,8 @@ class MenuStyle 'margin' => 2, 'selectedMarker' => '● ', 'unselectedMarker' => '○ ', + 'checkedMarker' => '[✔] ', + 'uncheckedMarker' => '[ ] ', 'itemExtra' => '✔', 'displaysExtra' => false, 'titleSeparator' => '=', @@ -229,6 +241,8 @@ public function __construct(Terminal $terminal = null) $this->setMargin(self::$defaultStyleValues['margin']); $this->setSelectedMarker(self::$defaultStyleValues['selectedMarker']); $this->setUnselectedMarker(self::$defaultStyleValues['unselectedMarker']); + $this->setCheckedMarker(self::$defaultStyleValues['checkedMarker']); + $this->setUncheckedMarker(self::$defaultStyleValues['uncheckedMarker']); $this->setItemExtra(self::$defaultStyleValues['itemExtra']); $this->setDisplaysExtra(self::$defaultStyleValues['displaysExtra']); $this->setTitleSeparator(self::$defaultStyleValues['titleSeparator']); @@ -250,6 +264,8 @@ public function hasChangedFromDefaults() : bool $this->margin, $this->selectedMarker, $this->unselectedMarker, + $this->checkedMarker, + $this->uncheckedMarker, $this->itemExtra, $this->displaysExtra, $this->titleSeparator, @@ -557,6 +573,30 @@ public function getMarker(bool $selected) : string return $selected ? $this->selectedMarker : $this->unselectedMarker; } + public function getCheckedMarker() : string + { + return $this->checkedMarker; + } + + public function setCheckedMarker(string $marker) : self + { + $this->checkedMarker = $marker; + + return $this; + } + + public function getUncheckedMarker() : string + { + return $this->uncheckedMarker; + } + + public function setUncheckedMarker(string $marker) : self + { + $this->uncheckedMarker = $marker; + + return $this; + } + public function setItemExtra(string $itemExtra) : self { $this->itemExtra = $itemExtra; diff --git a/test/Builder/CliMenuBuilderTest.php b/test/Builder/CliMenuBuilderTest.php index a57bfae2..70e72c68 100644 --- a/test/Builder/CliMenuBuilderTest.php +++ b/test/Builder/CliMenuBuilderTest.php @@ -5,6 +5,7 @@ use PhpSchool\CliMenu\CliMenu; use PhpSchool\CliMenu\Builder\CliMenuBuilder; use PhpSchool\CliMenu\MenuItem\AsciiArtItem; +use PhpSchool\CliMenu\MenuItem\CheckableItem; use PhpSchool\CliMenu\MenuItem\LineBreakItem; use PhpSchool\CliMenu\MenuItem\MenuMenuItem; use PhpSchool\CliMenu\MenuItem\SelectableItem; @@ -64,6 +65,8 @@ public function testModifyStyles() : void $builder->setMargin(4); $builder->setUnselectedMarker('>'); $builder->setSelectedMarker('x'); + $builder->setUncheckedMarker('-'); + $builder->setCheckedMarker('+'); $builder->setItemExtra('*'); $builder->setTitleSeparator('-'); @@ -78,6 +81,8 @@ public function testModifyStyles() : void self::assertEquals(4, $style->getMargin()); self::assertEquals('>', $style->getUnselectedMarker()); self::assertEquals('x', $style->getSelectedMarker()); + self::assertEquals('-', $style->getUncheckedMarker()); + self::assertEquals('+', $style->getCheckedMarker()); self::assertEquals('*', $style->getItemExtra()); self::assertEquals('-', $style->getTitleSeparator()); } @@ -364,6 +369,31 @@ public function testAddMultipleItems() : void $this->checkMenuItems($menu, $expected); } + public function testAddCheckableItem() : void + { + $callable = function () { + }; + + $builder = new CliMenuBuilder; + $builder->disableDefaultItems(); + $builder->addCheckableItem('Item 1', $callable); + $builder->addCheckableItem('Item 2', $callable); + $menu = $builder->build(); + + $expected = [ + [ + 'class' => CheckableItem::class, + 'text' => 'Item 1', + ], + [ + 'class' => CheckableItem::class, + 'text' => 'Item 2', + ], + ]; + + $this->checkMenuItems($menu, $expected); + } + public function testAddStaticItem() : void { diff --git a/test/MenuItem/CheckableItemTest.php b/test/MenuItem/CheckableItemTest.php new file mode 100644 index 00000000..4b947fb1 --- /dev/null +++ b/test/MenuItem/CheckableItemTest.php @@ -0,0 +1,160 @@ +assertTrue($item->canSelect()); + } + + public function testGetSelectAction() : void + { + $callable = function () { + }; + $item = new CheckableItem('Item', $callable); + $this->assertSame($callable, $item->getSelectAction()); + } + + public function testShowsItemExtra() : void + { + $item = new CheckableItem('Item', function () { + }); + $this->assertFalse($item->showsItemExtra()); + + $item = new CheckableItem('Item', function () { + }, true); + $this->assertTrue($item->showsItemExtra()); + } + + public function testGetText() : void + { + $item = new CheckableItem('Item', function () { + }); + $this->assertEquals('Item', $item->getText()); + } + + public function testGetRows() : void + { + $terminal = $this->createMock(Terminal::class); + $terminal->expects($this->any())->method('getWidth')->willReturn(100); + + $menuStyle = new MenuStyle($terminal); + $menuStyle->setPaddingLeftRight(0); + $menuStyle->setWidth(8); + + $item = new CheckableItem('Item', function () { + }); + + $itemChecked = new CheckableItem('Item', function () { + }); + $itemChecked->toggle(); + $this->assertEquals(['[ ] Item'], $item->getRows($menuStyle)); + $this->assertEquals(['[ ] Item'], $item->getRows($menuStyle, false)); + $this->assertEquals(['[✔] Item'], $itemChecked->getRows($menuStyle, true)); + } + + public function testSetText() : void + { + $terminal = $this->createMock(Terminal::class); + $terminal->expects($this->any())->method('getWidth')->willReturn(100); + + $menuStyle = new MenuStyle($terminal); + $menuStyle->setPaddingLeftRight(0); + $menuStyle->setWidth(12); + + $item = new CheckableItem('Item', function () { + }); + $item->setText('New Text'); + + $itemChecked = new CheckableItem('Item', function () { + }); + $itemChecked->setText('New Text'); + $itemChecked->toggle(); + $this->assertEquals(['[ ] New Text'], $item->getRows($menuStyle)); + $this->assertEquals(['[ ] New Text'], $item->getRows($menuStyle, false)); + $this->assertEquals(['[✔] New Text'], $itemChecked->getRows($menuStyle, true)); + } + + public function testTogglesMarker() : void + { + $terminal = $this->createMock(Terminal::class); + $terminal->expects($this->any())->method('getWidth')->willReturn(100); + + $menuStyle = new MenuStyle($terminal); + $menuStyle->setPaddingLeftRight(0); + $menuStyle->setWidth(12); + + $item = new CheckableItem('Item', function () { + }); + + $itemChecked = new CheckableItem('Item', function () { + }); + $itemChecked->toggle(); + $this->assertEquals(['[ ] Item'], $item->getRows($menuStyle)); + $this->assertEquals(['[ ] Item'], $item->getRows($menuStyle, false)); + $this->assertEquals(['[✔] Item'], $itemChecked->getRows($menuStyle, true)); + + $itemChecked->toggle(); + + $this->assertEquals(['[ ] Item'], $itemChecked->getRows($menuStyle, true)); + } + + public function testGetRowsWithItemExtra() : void + { + $terminal = $this->createMock(Terminal::class); + $terminal->expects($this->any())->method('getWidth')->willReturn(100); + + $menuStyle = new MenuStyle($terminal); + $menuStyle->setPaddingLeftRight(0); + $menuStyle->setWidth(20); + $menuStyle->setItemExtra('[EXTRA]'); + $menuStyle->setDisplaysExtra(true); + + $item = new CheckableItem('Item', function () { + }, true); + $this->assertEquals(['[ ] Item [EXTRA]'], $item->getRows($menuStyle)); + } + + public function testGetRowsWithMultipleLinesWithItemExtra() : void + { + $terminal = $this->createMock(Terminal::class); + $terminal->expects($this->any())->method('getWidth')->willReturn(100); + + $menuStyle = new MenuStyle($terminal); + $menuStyle->setPaddingLeftRight(0); + $menuStyle->setWidth(20); + $menuStyle->setItemExtra('[EXTRA]'); + $menuStyle->setDisplaysExtra(true); + + $item = new CheckableItem('LONG ITEM LINE', function () { + }, true); + $this->assertEquals( + [ + "[ ] LONG [EXTRA]", + " ITEM LINE", + ], + $item->getRows($menuStyle) + ); + } + + public function testHideAndShowItemExtra() : void + { + $item = new CheckableItem('Item', function () { + }); + + $this->assertFalse($item->showsItemExtra()); + $item->showItemExtra(); + $this->assertTrue($item->showsItemExtra()); + $item->hideItemExtra(); + $this->assertFalse($item->showsItemExtra()); + } +} diff --git a/test/MenuStyleTest.php b/test/MenuStyleTest.php index 1c954cc3..6f40fb35 100644 --- a/test/MenuStyleTest.php +++ b/test/MenuStyleTest.php @@ -100,6 +100,8 @@ public function testGetterAndSetters() : void $style->setFg('yellow'); $style->setUnselectedMarker('-'); $style->setSelectedMarker('>'); + $style->setUncheckedMarker('/'); + $style->setCheckedMarker('+'); $style->setItemExtra('EXTRA!'); $style->setDisplaysExtra(true); $style->setTitleSeparator('+'); @@ -117,6 +119,8 @@ public function testGetterAndSetters() : void self::assertSame('yellow', $style->getFg()); self::assertSame('-', $style->getUnselectedMarker()); self::assertSame('>', $style->getSelectedMarker()); + self::assertEquals('/', $style->getUncheckedMarker()); + self::assertEquals('+', $style->getCheckedMarker()); self::assertSame('EXTRA!', $style->getItemExtra()); self::assertTrue($style->getDisplaysExtra()); self::assertSame('+', $style->getTitleSeparator());