Skip to content

Commit f490fe8

Browse files
authored
Implement item shortcuts (#176)
Implement item shortcuts
2 parents 40f0c13 + 2426a48 commit f490fe8

File tree

7 files changed

+248
-9
lines changed

7 files changed

+248
-9
lines changed

README.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
* [Menu Methods](#menu-methods)
5353
* [Redrawing the Menu](#redrawing-the-menu)
5454
* [Getting, Removing and Adding items](#getting-removing-and-adding-items)
55-
* [Custom Control Mapping](#custom-control-mapping)
55+
* [Custom Control Mapping](#custom-control-mapping)
56+
* [Item Keyboard Shortcuts](#item-keyboard-shortcuts)
5657
* [Dialogues](#dialogues)
5758
* [Flash](#flash)
5859
* [Confirm](#confirm)
@@ -61,7 +62,7 @@
6162
* [Number](#number-input)
6263
* [Password](#password-input)
6364
* [Custom Input](#custom-input)
64-
* [Dialogues & Input Styling](#dialogues-input-styling)
65+
* [Dialogues & Input Styling](#dialogues--input-styling)
6566
* [Docs Translations](#docs-translations)
6667
* [Integrations](#integrations)
6768

@@ -888,7 +889,7 @@ $menu = (new CliMenuBuilder)
888889
$menu->open();
889890
```
890891

891-
### Custom Control Mapping
892+
## Custom Control Mapping
892893

893894
This functionality allows to map custom key presses to a callable. For example we can set the key press "x" to close the menu:
894895

@@ -933,6 +934,41 @@ $menu->addCustomControlMapping('C', $myCallback);
933934
$menu->open();
934935
```
935936

937+
## Item Keyboard Shortcuts
938+
939+
If you enable auto shortcuts `CliMenuBuilder` will parse the items text and check for shortcuts. Any single character inside square brackets
940+
will be treated as a shortcut. Pressing that character when the menu is open will trigger that items callable.
941+
942+
This functionality works for split items as well as sub menus. The same characters can be used inside sub menus and the
943+
callable which is invoked will depend on which menu is currently open.
944+
945+
Note: all shortcuts are lower cased.
946+
947+
To enable this automatic keyboard shortcut mapping simply call `->enableAutoShortcuts()`:
948+
949+
```php
950+
<?php
951+
952+
use PhpSchool\CliMenu\Builder\CliMenuBuilder;
953+
use PhpSchool\CliMenu\CliMenu;
954+
955+
$myCallback = function(CliMenu $menu) {
956+
// Do something
957+
};
958+
959+
$menu = (new CliMenuBuilder)
960+
->enableAutoShortcuts()
961+
->addItem('List of [C]lients', $myCallback)
962+
->build();
963+
964+
$menu->open();
965+
966+
//Pressing c will execute $myCallback.
967+
```
968+
969+
You can customise the shortcut matching by passing your own regex to `enableAutoShortcuts`. Be careful to only match
970+
one character in the first capture group or an exception will be thrown.
971+
936972
### Dialogues
937973

938974
#### Flash

examples/shortcuts.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
use PhpSchool\CliMenu\Builder\SplitItemBuilder;
4+
use PhpSchool\CliMenu\CliMenu;
5+
use PhpSchool\CliMenu\Builder\CliMenuBuilder;
6+
7+
require_once(__DIR__ . '/../vendor/autoload.php');
8+
9+
$itemCallable = function (CliMenu $menu) {
10+
echo $menu->getSelectedItem()->getText();
11+
};
12+
13+
$menu = (new CliMenuBuilder)
14+
->enableAutoShortcuts()
15+
->setTitle('Basic CLI Menu')
16+
->addItem('[F]irst Item', $itemCallable)
17+
->addItem('Se[c]ond Item', $itemCallable)
18+
->addItem('Third [I]tem', $itemCallable)
19+
->addSubMenu('[O]ptions', function (CliMenuBuilder $b) {
20+
$b->setTitle('CLI Menu > Options')
21+
->addItem('[F]irst option', function (CliMenu $menu) {
22+
echo sprintf('Executing option: %s', $menu->getSelectedItem()->getText());
23+
})
24+
->addLineBreak('-');
25+
})
26+
->addSplitItem(function (SplitItemBuilder $b) use ($itemCallable) {
27+
$b->addItem('Split Item [1]', function() { echo 'Split Item 1!'; })
28+
->addItem('Split Item [2]', function() { echo 'Split Item 2!'; })
29+
->addItem('Split Item [3]', function() { echo 'Split Item 3!'; })
30+
->addSubMenu('Split Item [4]', function (CliMenuBuilder $builder) use ($itemCallable) {
31+
$builder->addItem('Third [I]tem', $itemCallable);
32+
33+
});
34+
})
35+
->addLineBreak('-')
36+
->build();
37+
38+
$menu->open();
39+

src/Builder/CliMenuBuilder.php

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
use PhpSchool\CliMenu\Action\ExitAction;
66
use PhpSchool\CliMenu\Action\GoBackAction;
7+
use PhpSchool\CliMenu\Exception\InvalidShortcutException;
78
use PhpSchool\CliMenu\MenuItem\AsciiArtItem;
89
use PhpSchool\CliMenu\MenuItem\LineBreakItem;
910
use PhpSchool\CliMenu\MenuItem\MenuItemInterface;
1011
use PhpSchool\CliMenu\MenuItem\MenuMenuItem;
1112
use PhpSchool\CliMenu\MenuItem\SelectableItem;
1213
use PhpSchool\CliMenu\CliMenu;
14+
use PhpSchool\CliMenu\MenuItem\SplitItem;
1315
use PhpSchool\CliMenu\MenuItem\StaticItem;
1416
use PhpSchool\CliMenu\MenuStyle;
1517
use PhpSchool\CliMenu\Terminal\TerminalFactory;
@@ -56,6 +58,22 @@ class CliMenuBuilder
5658
*/
5759
private $disabled = false;
5860

61+
/**
62+
* Whether or not to auto create keyboard shortcuts for items
63+
* when they contain square brackets. Eg: [M]y item
64+
*
65+
* @var bool
66+
*/
67+
private $autoShortcuts = false;
68+
69+
/**
70+
* Regex to auto match for shortcuts defaults to looking
71+
* for a single character encased in square brackets
72+
*
73+
* @var string
74+
*/
75+
private $autoShortcutsRegex = '/\[(.)\]/';
76+
5977
/**
6078
* @var bool
6179
*/
@@ -87,6 +105,8 @@ public function addMenuItem(MenuItemInterface $item) : self
87105
{
88106
$this->menu->addItem($item);
89107

108+
$this->processItemShortcut($item);
109+
90110
return $this;
91111
}
92112

@@ -135,6 +155,10 @@ public function addSubMenu(string $text, \Closure $callback) : self
135155
{
136156
$builder = self::newSubMenu($this->terminal);
137157

158+
if ($this->autoShortcuts) {
159+
$builder->enableAutoShortcuts($this->autoShortcutsRegex);
160+
}
161+
138162
$callback = $callback->bindTo($builder);
139163
$callback($builder);
140164

@@ -147,12 +171,14 @@ public function addSubMenu(string $text, \Closure $callback) : self
147171
$menu->setStyle($this->menu->getStyle());
148172
}
149173

150-
$this->menu->addItem(new MenuMenuItem(
174+
$this->menu->addItem($item = new MenuMenuItem(
151175
$text,
152176
$menu,
153177
$builder->isMenuDisabled()
154178
));
155-
179+
180+
$this->processItemShortcut($item);
181+
156182
return $this;
157183
}
158184

@@ -167,24 +193,98 @@ public function addSubMenuFromBuilder(string $text, CliMenuBuilder $builder) : s
167193
$menu->setStyle($this->menu->getStyle());
168194
}
169195

170-
$this->menu->addItem(new MenuMenuItem(
196+
$this->menu->addItem($item = new MenuMenuItem(
171197
$text,
172198
$menu,
173199
$builder->isMenuDisabled()
174200
));
175201

202+
$this->processItemShortcut($item);
203+
176204
return $this;
177205
}
178206

207+
public function enableAutoShortcuts(string $regex = null) : self
208+
{
209+
$this->autoShortcuts = true;
210+
211+
if (null !== $regex) {
212+
$this->autoShortcutsRegex = $regex;
213+
}
214+
215+
return $this;
216+
}
217+
218+
private function extractShortcut(string $title) : ?string
219+
{
220+
preg_match($this->autoShortcutsRegex, $title, $match);
221+
222+
if (!isset($match[1])) {
223+
return null;
224+
}
225+
226+
if (mb_strlen($match[1]) > 1) {
227+
throw InvalidShortcutException::fromShortcut($match[1]);
228+
}
229+
230+
return isset($match[1]) ? strtolower($match[1]) : null;
231+
}
232+
233+
private function processItemShortcut(MenuItemInterface $item) : void
234+
{
235+
$this->processIndividualShortcut($item, function (CliMenu $menu) use ($item) {
236+
$menu->executeAsSelected($item);
237+
});
238+
}
239+
240+
private function processSplitItemShortcuts(SplitItem $splitItem) : void
241+
{
242+
foreach ($splitItem->getItems() as $item) {
243+
$this->processIndividualShortcut($item, function (CliMenu $menu) use ($splitItem, $item) {
244+
$current = $splitItem->getSelectedItemIndex();
245+
246+
$splitItem->setSelectedItemIndex(
247+
array_search($item, $splitItem->getItems(), true)
248+
);
249+
250+
$menu->executeAsSelected($splitItem);
251+
252+
if ($current !== null) {
253+
$splitItem->setSelectedItemIndex($current);
254+
}
255+
});
256+
}
257+
}
258+
259+
private function processIndividualShortcut(MenuItemInterface $item, callable $callback) : void
260+
{
261+
if (!$this->autoShortcuts) {
262+
return;
263+
}
264+
265+
if ($shortcut = $this->extractShortcut($item->getText())) {
266+
$this->menu->addCustomControlMapping(
267+
$shortcut,
268+
$callback
269+
);
270+
}
271+
}
272+
179273
public function addSplitItem(\Closure $callback) : self
180274
{
181275
$builder = new SplitItemBuilder($this->menu);
182276

277+
if ($this->autoShortcuts) {
278+
$builder->enableAutoShortcuts($this->autoShortcutsRegex);
279+
}
280+
183281
$callback = $callback->bindTo($builder);
184282
$callback($builder);
185283

186-
$this->menu->addItem($builder->build());
187-
284+
$this->menu->addItem($splitItem = $builder->build());
285+
286+
$this->processSplitItemShortcuts($splitItem);
287+
188288
return $this;
189289
}
190290

src/Builder/SplitItemBuilder.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ class SplitItemBuilder
2424
*/
2525
private $splitItem;
2626

27+
/**
28+
* Whether or not to auto create keyboard shortcuts for items
29+
* when they contain square brackets. Eg: [M]y item
30+
*
31+
* @var bool
32+
*/
33+
private $autoShortcuts = false;
34+
35+
/**
36+
* Regex to auto match for shortcuts defaults to looking
37+
* for a single character encased in square brackets
38+
*
39+
* @var string
40+
*/
41+
private $autoShortcutsRegex = '/\[(.)\]/';
42+
2743
public function __construct(CliMenu $menu)
2844
{
2945
$this->menu = $menu;
@@ -59,6 +75,10 @@ public function addSubMenu(string $text, \Closure $callback) : self
5975
{
6076
$builder = CliMenuBuilder::newSubMenu($this->menu->getTerminal());
6177

78+
if ($this->autoShortcuts) {
79+
$builder->enableAutoShortcuts($this->autoShortcutsRegex);
80+
}
81+
6282
$callback = $callback->bindTo($builder);
6383
$callback($builder);
6484

@@ -80,6 +100,17 @@ public function setGutter(int $gutter) : self
80100

81101
return $this;
82102
}
103+
104+
public function enableAutoShortcuts(string $regex = null) : self
105+
{
106+
$this->autoShortcuts = true;
107+
108+
if (null !== $regex) {
109+
$this->autoShortcutsRegex = $regex;
110+
}
111+
112+
return $this;
113+
}
83114

84115
public function build() : SplitItem
85116
{

src/CliMenu.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,25 @@ public function getSelectedItem() : MenuItemInterface
379379
: $item;
380380
}
381381

382+
public function setSelectedItem(MenuItemInterface $item) : void
383+
{
384+
$key = array_search($item, $this->items, true);
385+
386+
if (false === $key) {
387+
throw new \InvalidArgumentException('Item does not exist in menu');
388+
}
389+
390+
$this->selectedItem = $key;
391+
}
392+
393+
public function executeAsSelected(MenuItemInterface $item) : void
394+
{
395+
$current = $this->items[$this->selectedItem];
396+
$this->setSelectedItem($item);
397+
$this->executeCurrentItem();
398+
$this->setSelectedItem($current);
399+
}
400+
382401
/**
383402
* Execute the current item
384403
*/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace PhpSchool\CliMenu\Exception;
4+
5+
/**
6+
* @author Aydin Hassan <[email protected]>
7+
*/
8+
class InvalidShortcutException extends \RuntimeException
9+
{
10+
public static function fromShortcut(string $shortcut) : self
11+
{
12+
return new static(sprintf('Shortcut key must be only one character. Got: "%s"', $shortcut));
13+
}
14+
}

test/Builder/CliMenuBuilderTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ public function testAddSplitItemWithClosureBinding() : void
779779

780780
$this->checkItems($menu->getItems()[0]->getItems(), $expected);
781781
}
782-
782+
783783
private function checkMenuItems(CliMenu $menu, array $expected) : void
784784
{
785785
$this->checkItems($this->readAttribute($menu, 'items'), $expected);

0 commit comments

Comments
 (0)