diff --git a/examples/custom-item-register.php b/examples/custom-item-register.php new file mode 100644 index 00000000..38c05bd8 --- /dev/null +++ b/examples/custom-item-register.php @@ -0,0 +1,49 @@ +getSelectedItem()->getText(); +}; + +class MyItem extends SelectableItem { + +}; + +class MySelectableStyle extends SelectableStyle { + +} + +$myItem = new MyItem('MY CUSTOM ITEM 1', $itemCallable); +$myItem2 = new MyItem('MY CUSTOM ITEM 2', $itemCallable); + +$menu = (new CliMenuBuilder) + ->registerItemStyle(MyItem::class, new MySelectableStyle()) + ->modifyStyle(MySelectableStyle::class, function (MySelectableStyle $style) { + $style->setUnselectedMarker('--- '); + $style->setSelectedMarker('*** '); + }) + ->setTitle('Showcasing Custom Items & Styles') + ->addMenuItem($myItem) + ->addMenuItem($myItem2) + ->addLineBreak() + ->addSplitItem(function (SplitItemBuilder $b) use ($itemCallable, $myItem) { + $b->addItem('Split Item', $itemCallable); + $b->addSubMenu('Split Item Submenu', function (CliMenuBuilder $b) use ($myItem) { + $b->addMenuItem($myItem); + }); + $b->addMenuItem($myItem); + }) + ->addLineBreak() + ->addSubMenu('Options', function (CliMenuBuilder $b) use ($myItem) { + $b->addMenuItem($myItem); + }) + ->build(); + +$menu->open(); diff --git a/src/Builder/CliMenuBuilder.php b/src/Builder/CliMenuBuilder.php index 867f7859..f3943aad 100644 --- a/src/Builder/CliMenuBuilder.php +++ b/src/Builder/CliMenuBuilder.php @@ -18,10 +18,12 @@ use PhpSchool\CliMenu\MenuStyle; use PhpSchool\CliMenu\Style\CheckboxStyle; use PhpSchool\CliMenu\Style\DefaultStyle; +use PhpSchool\CliMenu\Style\ItemStyle; use PhpSchool\CliMenu\Style\RadioStyle; use PhpSchool\CliMenu\Style\SelectableStyle; use PhpSchool\CliMenu\Terminal\TerminalFactory; use PhpSchool\Terminal\Terminal; +use function PhpSchool\CliMenu\Util\each; /** * @author Michael Woodward @@ -80,6 +82,11 @@ class CliMenuBuilder */ private $autoShortcutsRegex = '/\[(.)\]/'; + /** + * @var array + */ + private $extraItemStyles = []; + /** * @var bool */ @@ -187,6 +194,10 @@ public function addSubMenu(string $text, \Closure $callback) : self $builder->enableAutoShortcuts($this->autoShortcutsRegex); } + each($this->extraItemStyles, function (int $i, array $extraItemStyle) use ($builder) { + $builder->registerItemStyle($extraItemStyle['class'], $extraItemStyle['style']); + }); + $callback($builder); $menu = $builder->build(); @@ -293,6 +304,10 @@ public function addSplitItem(\Closure $callback) : self $builder->enableAutoShortcuts($this->autoShortcutsRegex); } + each($this->extraItemStyles, function (int $i, array $extraItemStyle) use ($builder) { + $builder->registerItemStyle($extraItemStyle['class'], $extraItemStyle['style']); + }); + $callback($builder); $this->menu->addItem($splitItem = $builder->build()); @@ -603,4 +618,21 @@ public function modifyRadioStyle(callable $itemCallable) : self return $this; } + + public function modifyStyle(string $styleClass, callable $itemCallable) : self + { + $itemCallable($this->menu->getItemStyle($styleClass)); + + return $this; + } + + public function registerItemStyle(string $itemClass, ItemStyle $itemStyle) : self + { + $this->menu->getStyleLocator() + ->registerItemStyle($itemClass, $itemStyle); + + $this->extraItemStyles[] = ['class' => $itemClass, 'style' => $itemStyle]; + + return $this; + } } diff --git a/src/Builder/SplitItemBuilder.php b/src/Builder/SplitItemBuilder.php index dfca5189..72b88bd0 100644 --- a/src/Builder/SplitItemBuilder.php +++ b/src/Builder/SplitItemBuilder.php @@ -5,11 +5,14 @@ use PhpSchool\CliMenu\CliMenu; use PhpSchool\CliMenu\MenuItem\CheckboxItem; use PhpSchool\CliMenu\MenuItem\LineBreakItem; +use PhpSchool\CliMenu\MenuItem\MenuItemInterface; use PhpSchool\CliMenu\MenuItem\MenuMenuItem; use PhpSchool\CliMenu\MenuItem\RadioItem; use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuItem\SplitItem; use PhpSchool\CliMenu\MenuItem\StaticItem; +use PhpSchool\CliMenu\Style\ItemStyle; +use function \PhpSchool\CliMenu\Util\each; /** * @author Aydin Hassan @@ -42,6 +45,11 @@ class SplitItemBuilder */ private $autoShortcutsRegex = '/\[(.)\]/'; + /** + * @var array + */ + private $extraItemStyles = []; + public function __construct(CliMenu $menu) { $this->menu = $menu; @@ -103,6 +111,10 @@ public function addSubMenu(string $text, \Closure $callback) : self $builder->enableAutoShortcuts($this->autoShortcutsRegex); } + each($this->extraItemStyles, function (int $i, array $extraItemStyle) use ($builder) { + $builder->registerItemStyle($extraItemStyle['class'], $extraItemStyle['style']); + }); + $callback($builder); $menu = $builder->build(); @@ -117,6 +129,13 @@ public function addSubMenu(string $text, \Closure $callback) : self return $this; } + public function addMenuItem(MenuItemInterface $item) : self + { + $this->splitItem->addItem($item); + + return $this; + } + public function setGutter(int $gutter) : self { $this->splitItem->setGutter($gutter); @@ -135,6 +154,13 @@ public function enableAutoShortcuts(string $regex = null) : self return $this; } + public function registerItemStyle(string $itemClass, ItemStyle $itemStyle) : self + { + $this->extraItemStyles[] = ['class' => $itemClass, 'style' => $itemStyle]; + + return $this; + } + public function build() : SplitItem { return $this->splitItem; diff --git a/src/CliMenu.php b/src/CliMenu.php index 1a0bea7a..daecb1bf 100644 --- a/src/CliMenu.php +++ b/src/CliMenu.php @@ -22,6 +22,7 @@ use PhpSchool\Terminal\InputCharacter; use PhpSchool\Terminal\NonCanonicalReader; use PhpSchool\Terminal\Terminal; +use function PhpSchool\CliMenu\Util\collect; use function PhpSchool\CliMenu\Util\each; /** @@ -666,6 +667,11 @@ public function getItemStyleForItem(MenuItemInterface $item) : ItemStyle return $this->itemStyleLocator->getStyleForMenuItem($item); } + public function getStyleLocator() : Locator + { + return $this->itemStyleLocator; + } + public function importStyles(CliMenu $menu) : void { if (!$this->style->hasChangedFromDefaults()) { @@ -744,22 +750,24 @@ private function guardSingleLine(string $text) : void public function propagateStyles() : void { - each( - array_filter($this->items, function (MenuItemInterface $item) { + collect($this->items) + ->filter(function (int $k, MenuItemInterface $item) { + return $this->itemStyleLocator->hasStyleForMenuItem($item); + }) + ->filter(function (int $k, MenuItemInterface $item) { return !$item->getStyle()->hasChangedFromDefaults(); - }), - function (int $index, $item) { + }) + ->each(function (int $k, $item) { $item->setStyle(clone $this->getItemStyleForItem($item)); - } - ); + }); + - each( - array_filter($this->items, function (MenuItemInterface $item) { + collect($this->items) + ->filter(function (int $k, MenuItemInterface $item) { return $item instanceof PropagatesStyles; - }), - function (int $index, PropagatesStyles $item) { + }) + ->each(function (int $k, $item) { $item->propagateStyles($this); - } - ); + }); } } diff --git a/src/MenuItem/SplitItem.php b/src/MenuItem/SplitItem.php index 001d15a1..f727d86b 100644 --- a/src/MenuItem/SplitItem.php +++ b/src/MenuItem/SplitItem.php @@ -9,6 +9,7 @@ use PhpSchool\CliMenu\Style\ItemStyle; use PhpSchool\CliMenu\Style\Selectable; use PhpSchool\CliMenu\Util\StringUtil; +use function PhpSchool\CliMenu\Util\collect; use function PhpSchool\CliMenu\Util\each; use function PhpSchool\CliMenu\Util\mapWithKeys; use function PhpSchool\CliMenu\Util\max; @@ -364,22 +365,23 @@ public function setStyle(DefaultStyle $style): void */ public function propagateStyles(CliMenu $parent): void { - each( - array_filter($this->getItems(), function (MenuItemInterface $item) { + collect($this->items) + ->filter(function (int $k, MenuItemInterface $item) use ($parent) { + return $parent->getStyleLocator()->hasStyleForMenuItem($item); + }) + ->filter(function (int $k, MenuItemInterface $item) { return !$item->getStyle()->hasChangedFromDefaults(); - }), - function ($index, $item) use ($parent) { + }) + ->each(function (int $k, $item) use ($parent) { $item->setStyle(clone $parent->getItemStyleForItem($item)); - } - ); + }); - each( - array_filter($this->getItems(), function (MenuItemInterface $item) { + collect($this->items) + ->filter(function (int $k, MenuItemInterface $item) { return $item instanceof PropagatesStyles; - }), - function ($index, PropagatesStyles $item) use ($parent) { + }) + ->each(function (int $k, $item) use ($parent) { $item->propagateStyles($parent); - } - ); + }); } } diff --git a/src/Style/Exception/InvalidStyle.php b/src/Style/Exception/InvalidStyle.php index e5f7c166..fde0a505 100644 --- a/src/Style/Exception/InvalidStyle.php +++ b/src/Style/Exception/InvalidStyle.php @@ -22,4 +22,9 @@ public static function unregisteredItem(string $itemClass) : self { return new self("Menu item: '$itemClass' does not have a registered style class"); } + + public static function itemAlreadyRegistered(string $itemClass) : self + { + return new self("Menu item: '$itemClass' already has a registered style class"); + } } diff --git a/src/Style/Locator.php b/src/Style/Locator.php index e3e82d6b..47ffa791 100644 --- a/src/Style/Locator.php +++ b/src/Style/Locator.php @@ -87,6 +87,11 @@ public function setStyle(ItemStyle $itemStyle, string $styleClass) : void $this->styles[$styleClass] = $itemStyle; } + public function hasStyleForMenuItem(MenuItemInterface $item) : bool + { + return isset($this->itemStyleMap[get_class($item)]); + } + public function getStyleForMenuItem(MenuItemInterface $item) : ItemStyle { if (!isset($this->itemStyleMap[get_class($item)])) { @@ -97,4 +102,14 @@ public function getStyleForMenuItem(MenuItemInterface $item) : ItemStyle return $this->getStyle($styleClass); } + + public function registerItemStyle(string $itemClass, ItemStyle $itemStyle) : void + { + if (isset($this->itemStyleMap[$itemClass])) { + throw InvalidStyle::itemAlreadyRegistered($itemClass); + } + + $this->itemStyleMap[$itemClass] = get_class($itemStyle); + $this->styles[get_class($itemStyle)] = $itemStyle; + } } diff --git a/src/Util/ArrayUtils.php b/src/Util/ArrayUtils.php index f51cc90c..2e31c05a 100644 --- a/src/Util/ArrayUtils.php +++ b/src/Util/ArrayUtils.php @@ -16,6 +16,13 @@ function mapWithKeys(array $array, callable $callback) : array return $arr; } +function filter(array $array, callable $callback) : array +{ + return array_filter($array, function ($v, $k) use ($callback) { + return $callback($k, $v); + }, ARRAY_FILTER_USE_BOTH); +} + function each(array $array, callable $callback) : void { foreach ($array as $k => $v) { @@ -27,3 +34,8 @@ function max(array $items) : int { return count($items) > 0 ? \max($items) : 0; } + +function collect(array $items) : Collection +{ + return new Collection($items); +} diff --git a/src/Util/Collection.php b/src/Util/Collection.php new file mode 100644 index 00000000..2e854c0d --- /dev/null +++ b/src/Util/Collection.php @@ -0,0 +1,45 @@ +items = $items; + } + + public function map(callable $cb) : self + { + return new self(mapWithKeys($this->items, $cb)); + } + + public function filter(callable $cb) : self + { + return new self(filter($this->items, $cb)); + } + + public function values() : self + { + return new self(array_values($this->items)); + } + + public function each(callable $cb) : self + { + each($this->items, $cb); + + return $this; + } + + public function all() : array + { + return $this->items; + } +} diff --git a/test/Builder/CliMenuBuilderTest.php b/test/Builder/CliMenuBuilderTest.php index 2891215b..c3e0eb3d 100644 --- a/test/Builder/CliMenuBuilderTest.php +++ b/test/Builder/CliMenuBuilderTest.php @@ -13,6 +13,7 @@ use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuItem\SplitItem; use PhpSchool\CliMenu\MenuItem\StaticItem; +use PhpSchool\CliMenu\Style\DefaultStyle; use PhpSchool\Terminal\Terminal; use PHPUnit\Framework\TestCase; @@ -956,7 +957,6 @@ public function testModifyingItemExtraForcesExtraToBeDisplayedWhenNoItemsDisplay self::assertTrue($menu->getStyle()->getDisplaysExtra()); } - private function checkMenuItems(CliMenu $menu, array $expected) : void { $this->checkItems($menu->getItems(), $expected); @@ -987,4 +987,44 @@ private function checkItems(array $actualItems, array $expected) : void } } } + + public function testRegisterItemStylePropagatesToSubmenus() : void + { + $myItem = new class extends LineBreakItem { + }; + + $myStyle = new class extends DefaultStyle { + }; + + $builder = new CliMenuBuilder; + $builder->registerItemStyle(get_class($myItem), $myStyle); + $builder + ->addSubMenu('My SubMenu', function (CliMenuBuilder $b) use ($myItem) { + $b->disableDefaultItems(); + $b->addMenuItem($myItem); + }) + ->addSplitItem(function (SplitItemBuilder $b) use ($myItem) { + $b->addSubMenu('My Split SubMenu', function (CliMenuBuilder $b) use ($myItem) { + $b->addMenuItem($myItem); + }); + }); + + $menu = $builder->build(); + $subMenuStyleLocator = $menu + ->getItems()[0] + ->getSubMenu() + ->getStyleLocator(); + + self::assertTrue($subMenuStyleLocator->hasStyleForMenuItem($myItem)); + self::assertSame($myStyle, $subMenuStyleLocator->getStyleForMenuItem($myItem)); + + $nestedSubMenuStyleLocator = $menu + ->getItems()[1] + ->getItems()[0] + ->getSubMenu() + ->getStyleLocator(); + + self::assertTrue($nestedSubMenuStyleLocator->hasStyleForMenuItem($myItem)); + self::assertSame($myStyle, $nestedSubMenuStyleLocator->getStyleForMenuItem($myItem)); + } } diff --git a/test/Builder/SplitItemBuilderTest.php b/test/Builder/SplitItemBuilderTest.php index a57a5027..9601b484 100644 --- a/test/Builder/SplitItemBuilderTest.php +++ b/test/Builder/SplitItemBuilderTest.php @@ -6,11 +6,13 @@ use PhpSchool\CliMenu\Builder\SplitItemBuilder; use PhpSchool\CliMenu\CliMenu; use PhpSchool\CliMenu\MenuItem\CheckboxItem; +use PhpSchool\CliMenu\MenuItem\LineBreakItem; use PhpSchool\CliMenu\MenuItem\MenuMenuItem; use PhpSchool\CliMenu\MenuItem\RadioItem; use PhpSchool\CliMenu\MenuItem\SelectableItem; use PhpSchool\CliMenu\MenuItem\SplitItem; use PhpSchool\CliMenu\MenuItem\StaticItem; +use PhpSchool\CliMenu\Style\DefaultStyle; use PHPUnit\Framework\TestCase; class SplitItemBuilderTest extends TestCase @@ -191,4 +193,31 @@ private function checkItems(array $actualItems, array $expected) : void } } } + + public function testRegisterItemStylePropagatesToSubmenus() : void + { + $myItem = new class extends LineBreakItem { + }; + + $myStyle = new class extends DefaultStyle { + }; + + $menu = new CliMenu(null, []); + $builder = new SplitItemBuilder($menu); + $builder->registerItemStyle(get_class($myItem), $myStyle); + $builder->addSubMenu('My SubMenu', function (CliMenuBuilder $b) { + $b->disableDefaultItems(); + $b->addItem('My Item', function () { + }); + }); + + $item = $builder->build(); + $styleLocator = $item + ->getItems()[0] + ->getSubMenu() + ->getStyleLocator(); + + self::assertTrue($styleLocator->hasStyleForMenuItem($myItem)); + self::assertSame($myStyle, $styleLocator->getStyleForMenuItem($myItem)); + } } diff --git a/test/Style/LocatorTest.php b/test/Style/LocatorTest.php index 8d4d957a..5d79e312 100644 --- a/test/Style/LocatorTest.php +++ b/test/Style/LocatorTest.php @@ -186,4 +186,36 @@ public function testSetStyle() : void self::assertSame($new, $locator->getStyle(DefaultStyle::class)); } + + public function testHasStyleForMenuItem() : void + { + $locator = new Locator(); + + $customClass = new class extends LineBreakItem { + }; + + self::assertTrue($locator->hasStyleForMenuItem(new LineBreakItem())); + self::assertFalse($locator->hasStyleForMenuItem($customClass)); + } + + public function testRegisterItemStyleThrowsExceptionIfItemAlreadyRegistered() : void + { + self::expectException(InvalidStyle::class); + + (new Locator())->registerItemStyle(LineBreakItem::class, new DefaultStyle()); + } + + public function testRegisterItemStyle() : void + { + $locator = new Locator(); + + $customClass = new class extends LineBreakItem { + }; + + self::assertFalse($locator->hasStyleForMenuItem($customClass)); + + $locator->registerItemStyle(get_class($customClass), new DefaultStyle()); + + self::assertTrue($locator->hasStyleForMenuItem($customClass)); + } } diff --git a/test/Util/ArrayUtilTest.php b/test/Util/ArrayUtilTest.php index 34e349d1..4936f759 100644 --- a/test/Util/ArrayUtilTest.php +++ b/test/Util/ArrayUtilTest.php @@ -6,7 +6,9 @@ use PhpSchool\CliMenu\Util\ArrayUtil; use PHPUnit\Framework\TestCase; +use function PhpSchool\CliMenu\Util\collect; use function PhpSchool\CliMenu\Util\each; +use function PhpSchool\CliMenu\Util\filter; use function PhpSchool\CliMenu\Util\mapWithKeys; use function PhpSchool\CliMenu\Util\max; @@ -55,4 +57,18 @@ public function testMax() : void self::assertEquals(3, max([1, 2, 3])); self::assertEquals(6, max([1, 6, 3])); } + + public function testFilter() : void + { + $cb = function (int $k, int $v) { + return $v > 3; + }; + + self::assertEquals([3 => 4, 4 => 5, 5 => 6], filter([1, 2, 3, 4, 5, 6], $cb)); + } + + public function testCollect() : void + { + self::assertEquals([1, 2, 3], collect([1, 2, 3])->all()); + } } diff --git a/test/Util/CollectionTest.php b/test/Util/CollectionTest.php new file mode 100644 index 00000000..66e69e50 --- /dev/null +++ b/test/Util/CollectionTest.php @@ -0,0 +1,26 @@ +filter(function ($k, $v) { + return $v > 3; + }) + ->values() + ->map(function ($k, $v) { + return $v * 2; + }) + ->all(); + + self::assertEquals([8, 10, 12], $result); + } +}