Completed
Push — master ( 528484...9b16a2 )
by Aydin
15s queued 11s
created

src/CliMenu.php (2 issues)

1
<?php
2
3
namespace PhpSchool\CliMenu;
4
5
use PhpSchool\CliMenu\Exception\InvalidTerminalException;
6
use PhpSchool\CliMenu\Exception\MenuNotOpenException;
7
use PhpSchool\CliMenu\Input\InputIO;
8
use PhpSchool\CliMenu\Input\Number;
9
use PhpSchool\CliMenu\Input\Password;
10
use PhpSchool\CliMenu\Input\Text;
11
use PhpSchool\CliMenu\MenuItem\LineBreakItem;
12
use PhpSchool\CliMenu\MenuItem\MenuItemInterface;
13
use PhpSchool\CliMenu\MenuItem\SplitItem;
14
use PhpSchool\CliMenu\MenuItem\StaticItem;
15
use PhpSchool\CliMenu\Dialogue\Confirm;
16
use PhpSchool\CliMenu\Dialogue\Flash;
17
use PhpSchool\CliMenu\Terminal\TerminalFactory;
18
use PhpSchool\CliMenu\Util\StringUtil as s;
19
use PhpSchool\Terminal\InputCharacter;
20
use PhpSchool\Terminal\NonCanonicalReader;
21
use PhpSchool\Terminal\Terminal;
22
23
/**
24
 * @author Michael Woodward <[email protected]>
25
 */
26
class CliMenu
27
{
28
    /**
29
     * @var Terminal
30
     */
31
    protected $terminal;
32
33
    /**
34
     * @var MenuStyle
35
     */
36
    protected $style;
37
38
    /**
39
     * @var ?string
40
     */
41
    protected $title;
42
43
    /**
44
     * @var MenuItemInterface[]
45
     */
46
    protected $items = [];
47
48
    /**
49
     * @var int|null
50
     */
51
    protected $selectedItem;
52
53
    /**
54
     * @var bool
55
     */
56
    protected $open = false;
57
58
    /**
59
     * @var CliMenu|null
60
     */
61
    protected $parent;
62
63
    /**
64
     * @var array
65
     */
66
    protected $defaultControlMappings = [
67
        '^P' => InputCharacter::UP,
68
        'k'  => InputCharacter::UP,
69
        '^K' => InputCharacter::DOWN,
70
        'j'  => InputCharacter::DOWN,
71
        "\r" => InputCharacter::ENTER,
72
        ' '  => InputCharacter::ENTER,
73
        'l'  => InputCharacter::LEFT,
74
        'm'  => InputCharacter::RIGHT,
75
    ];
76
77
    /**
78
     * @var array
79
     */
80
    protected $customControlMappings = [];
81
82
    /**
83
     * @var Frame
84
     */
85
    private $currentFrame;
86
87
    public function __construct(
88
        ?string $title,
89
        array $items,
90
        Terminal $terminal = null,
91
        MenuStyle $style = null
92
    ) {
93
        $this->title      = $title;
94
        $this->items      = $items;
95
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
96
        $this->style      = $style ?: new MenuStyle($this->terminal);
97
98
        $this->selectFirstItem();
99
    }
100
101
    /**
102
     * Configure the terminal to work with CliMenu
103
     */
104
    protected function configureTerminal() : void
105
    {
106
        $this->assertTerminalIsValidTTY();
107
108
        $this->terminal->disableCanonicalMode();
109
        $this->terminal->disableEchoBack();
110
        $this->terminal->disableCursor();
111
        $this->terminal->clear();
112
    }
113
114
    /**
115
     * Revert changes made to the terminal
116
     */
117
    protected function tearDownTerminal() : void
118
    {
119
        $this->terminal->restoreOriginalConfiguration();
120
        $this->terminal->enableCursor();
121
    }
122
123
    private function assertTerminalIsValidTTY() : void
124
    {
125
        if (!$this->terminal->isInteractive()) {
126
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
127
        }
128
    }
129
130
    public function setTitle(string $title) : void
131
    {
132
        $this->title = $title;
133
    }
134
135
    public function setParent(CliMenu $parent) : void
136
    {
137
        $this->parent = $parent;
138
    }
139
140
    public function getParent() : ?CliMenu
141
    {
142
        return $this->parent;
143
    }
144
145
    public function getTerminal() : Terminal
146
    {
147
        return $this->terminal;
148
    }
149
150
    public function isOpen() : bool
151
    {
152
        return $this->open;
153
    }
154
155
    /**
156
     * Add a new Item to the menu
157
     */
158
    public function addItem(MenuItemInterface $item) : void
159
    {
160
        $this->items[] = $item;
161
162
        $this->selectFirstItem();
163
    }
164
165
    /**
166
     * Add multiple Items to the menu
167
     */
168
    public function addItems(array $items) : void
169
    {
170
        foreach ($items as $item) {
171
            $this->items[] = $item;
172
        }
173
174
        $this->selectFirstItem();
175
    }
176
177
    /**
178
     * Set Items of the menu
179
     */
180
    public function setItems(array $items) : void
181
    {
182
        $this->selectedItem = null;
183
        $this->items = $items;
184
185
        $this->selectFirstItem();
186
    }
187
188
    /**
189
     * Set the selected pointer to the first selectable item
190
     */
191
    private function selectFirstItem() : void
192
    {
193
        if (null === $this->selectedItem) {
194
            foreach ($this->items as $key => $item) {
195
                if ($item->canSelect()) {
196
                    $this->selectedItem = $key;
197
                    break;
198
                }
199
            }
200
        }
201
    }
202
203
    /**
204
     * Disables the built-in VIM control mappings
205
     */
206
    public function disableDefaultControlMappings() : void
207
    {
208
        $this->defaultControlMappings = [];
209
    }
210
211
    /**
212
     * Set default control mappings
213
     */
214
    public function setDefaultControlMappings(array $defaultControlMappings) : void
215
    {
216
        $this->defaultControlMappings = $defaultControlMappings;
217
    }
218
219
    /**
220
     * Adds a custom control mapping
221
     */
222
    public function addCustomControlMapping(string $input, callable $callable) : void
223
    {
224
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
225
            throw new \InvalidArgumentException('Cannot rebind this input');
226
        }
227
228
        $this->customControlMappings[$input] = $callable;
229
    }
230
231
    /**
232
     * Shorthand function to add multiple custom control mapping at once
233
     */
234
    public function addCustomControlMappings(array $map) : void
235
    {
236
        foreach ($map as $input => $callable) {
237
            $this->addCustomControlMapping($input, $callable);
238
        }
239
    }
240
241
    /**
242
     * Removes a custom control mapping
243
     */
244
    public function removeCustomControlMapping(string $input) : void
245
    {
246
        if (!isset($this->customControlMappings[$input])) {
247
            throw new \InvalidArgumentException('This input is not registered');
248
        }
249
250
        unset($this->customControlMappings[$input]);
251
    }
252
253
    /**
254
     * Display menu and capture input
255
     */
256
    private function display() : void
257
    {
258
        $this->draw();
259
260
        $reader = new NonCanonicalReader($this->terminal);
261
        $reader->addControlMappings($this->defaultControlMappings);
262
263
        while ($this->isOpen()) {
264
            $char = $reader->readCharacter();
265
            if (!$char->isHandledControl()) {
266
                $rawChar = $char->get();
267
                if (isset($this->customControlMappings[$rawChar])) {
268
                    $this->customControlMappings[$rawChar]($this);
269
                }
270
                continue;
271
            }
272
273
            switch ($char->getControl()) {
274
                case InputCharacter::UP:
275
                case InputCharacter::DOWN:
276
                    $this->moveSelectionVertically($char->getControl());
277
                    $this->draw();
278
                    break;
279
                case InputCharacter::LEFT:
280
                case InputCharacter::RIGHT:
281
                    $this->moveSelectionHorizontally($char->getControl());
282
                    $this->draw();
283
                    break;
284
                case InputCharacter::ENTER:
285
                    $this->executeCurrentItem();
286
                    break;
287
            }
288
        }
289
    }
290
291
    /**
292
     * Move the selection in a given direction, up / down
293
     */
294
    protected function moveSelectionVertically(string $direction) : void
295
    {
296
        $itemKeys = array_keys($this->items);
297
298
        $increments = 0;
299
300
        do {
301
            $increments++;
302
303
            if ($increments > count($itemKeys)) {
304
                //full cycle detected, there must be no selected items
305
                //in the menu, so stop trying to select one.
306
                return;
307
            }
308
309
            $direction === 'UP'
310
                ? $this->selectedItem--
311
                : $this->selectedItem++;
312
313
            if ($this->selectedItem !== null && !array_key_exists($this->selectedItem, $this->items)) {
314
                $this->selectedItem  = $direction === 'UP'
315
                    ? end($itemKeys)
316
                    : reset($itemKeys);
317
            }
318
        } while (!$this->canSelect());
319
    }
320
321
    /**
322
     * Move the selection in a given direction, left / right
323
     */
324
    protected function moveSelectionHorizontally(string $direction) : void
325
    {
326
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
327
            return;
328
        }
329
330
        /** @var SplitItem $item */
331
        $item = $this->items[$this->selectedItem];
332
        $itemKeys = array_keys($item->getItems());
333
        $selectedItemIndex = $item->getSelectedItemIndex();
334
335
        do {
336
            $direction === 'LEFT'
337
                ? $selectedItemIndex--
338
                : $selectedItemIndex++;
339
340
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
341
                $selectedItemIndex = $direction === 'LEFT'
342
                    ? end($itemKeys)
343
                    : reset($itemKeys);
344
            }
345
        } while (!$item->canSelectIndex($selectedItemIndex));
0 ignored issues
show
It seems like $selectedItemIndex can also be of type null; however, parameter $index of PhpSchool\CliMenu\MenuIt...tItem::canSelectIndex() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

345
        } while (!$item->canSelectIndex(/** @scrutinizer ignore-type */ $selectedItemIndex));
Loading history...
346
        
347
        $item->setSelectedItemIndex($selectedItemIndex);
0 ignored issues
show
It seems like $selectedItemIndex can also be of type null; however, parameter $index of PhpSchool\CliMenu\MenuIt...:setSelectedItemIndex() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

347
        $item->setSelectedItemIndex(/** @scrutinizer ignore-type */ $selectedItemIndex);
Loading history...
348
    }
349
350
    /**
351
     * Can the currently selected item actually be selected?
352
     *
353
     * For example:
354
     *  selectable item -> yes
355
     *  static item -> no
356
     *  split item with only static items -> no
357
     *  split item with at least one selectable item -> yes
358
     *
359
     * @return bool
360
     */
361
    private function canSelect() : bool
362
    {
363
        return $this->items[$this->selectedItem]->canSelect();
364
    }
365
366
    /**
367
     * Retrieve the item the user actually selected
368
     *
369
     */
370
    public function getSelectedItem() : MenuItemInterface
371
    {
372
        if (null === $this->selectedItem) {
373
            throw new \RuntimeException('No selected item');
374
        }
375
376
        $item = $this->items[$this->selectedItem];
377
        return $item instanceof SplitItem
378
            ? $item->getSelectedItem()
379
            : $item;
380
    }
381
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
401
    /**
402
     * Execute the current item
403
     */
404
    protected function executeCurrentItem() : void
405
    {
406
        $item = $this->getSelectedItem();
407
408
        if ($item->canSelect()) {
409
            $callable = $item->getSelectAction();
410
            if ($callable) {
411
                $callable($this);
412
            }
413
        }
414
    }
415
416
    /**
417
     * If true we clear the whole terminal screen, useful
418
     * for example when reducing the width of the menu, to not
419
     * leave leftovers of the previous wider menu.
420
     *
421
     * Redraw the menu
422
     */
423
    public function redraw(bool $clear = false) : void
424
    {
425
        if ($clear) {
426
            $this->terminal->clear();
427
        }
428
429
        $this->assertOpen();
430
        $this->draw();
431
    }
432
433
    private function assertOpen() : void
434
    {
435
        if (!$this->isOpen()) {
436
            throw new MenuNotOpenException;
437
        }
438
    }
439
440
    /**
441
     * Draw the menu to STDOUT
442
     */
443
    protected function draw() : void
444
    {
445
        $frame = new Frame;
446
447
        $frame->newLine(2);
448
449
        if ($this->style->getBorderTopWidth() > 0) {
450
            $frame->addRows($this->style->getBorderTopRows());
451
        }
452
453
        if ($this->style->getPaddingTopBottom() > 0) {
454
            $frame->addRows($this->style->getPaddingTopBottomRows());
455
        }
456
457
        if ($this->title) {
458
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
459
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
460
        }
461
462
        array_map(function ($item, $index) use ($frame) {
463
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
464
        }, $this->items, array_keys($this->items));
465
466
467
        if ($this->style->getPaddingTopBottom() > 0) {
468
            $frame->addRows($this->style->getPaddingTopBottomRows());
469
        }
470
471
        if ($this->style->getBorderBottomWidth() > 0) {
472
            $frame->addRows($this->style->getBorderBottomRows());
473
        }
474
475
        $frame->newLine(2);
476
477
        $this->terminal->moveCursorToTop();
478
        foreach ($frame->getRows() as $row) {
479
            if ($row == "\n") {
480
                $this->terminal->clearLine();
481
            }
482
            $this->terminal->write($row);
483
        }
484
        $this->terminal->clearDown();
485
486
        $this->currentFrame = $frame;
487
    }
488
489
    /**
490
     * Draw a menu item
491
     */
492
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
493
    {
494
        $rows = $item->getRows($this->style, $selected);
495
        
496
        if ($item instanceof SplitItem) {
497
            $selected = false;
498
        }
499
500
        $invertedColoursSetCode = $selected
501
            ? $this->style->getInvertedColoursSetCode()
502
            : '';
503
        $invertedColoursUnsetCode = $selected
504
            ? $this->style->getInvertedColoursUnsetCode()
505
            : '';
506
507
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
508
            $borderColour = $this->style->getBorderColourCode();
509
        } else {
510
            $borderColour = '';
511
        }
512
513
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
514
            return sprintf(
515
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
516
                str_repeat(' ', $this->style->getMargin()),
517
                $borderColour,
518
                str_repeat(' ', $this->style->getBorderLeftWidth()),
519
                $this->style->getColoursSetCode(),
520
                $invertedColoursSetCode,
521
                str_repeat(' ', $this->style->getPaddingLeftRight()),
522
                $row,
523
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
524
                $invertedColoursUnsetCode,
525
                $borderColour,
526
                str_repeat(' ', $this->style->getBorderRightWidth()),
527
                $this->style->getColoursResetCode()
528
            );
529
        }, $rows);
530
    }
531
532
    /**
533
     * @throws InvalidTerminalException
534
     */
535
    public function open() : void
536
    {
537
        if ($this->isOpen()) {
538
            return;
539
        }
540
        
541
        if (count($this->items) === 0) {
542
            throw new \RuntimeException('Menu must have at least 1 item before it can be opened');
543
        }
544
545
        $this->configureTerminal();
546
        $this->open = true;
547
        $this->display();
548
    }
549
550
    /**
551
     * Close the menu
552
     *
553
     * @throws InvalidTerminalException
554
     */
555
    public function close() : void
556
    {
557
        $menu = $this;
558
559
        do {
560
            $menu->closeThis();
561
            $menu = $menu->getParent();
562
        } while (null !== $menu);
563
564
        $this->tearDownTerminal();
565
    }
566
567
    public function closeThis() : void
568
    {
569
        $this->terminal->clean();
570
        $this->terminal->moveCursorToTop();
571
        $this->open = false;
572
    }
573
574
    /**
575
     * @return MenuItemInterface[]
576
     */
577
    public function getItems() : array
578
    {
579
        return $this->items;
580
    }
581
582
    public function removeItem(MenuItemInterface $item) : void
583
    {
584
        $key = array_search($item, $this->items, true);
585
586
        if (false === $key) {
587
            throw new \InvalidArgumentException('Item does not exist in menu');
588
        }
589
590
        unset($this->items[$key]);
591
        $this->items = array_values($this->items);
592
593
        if ($this->selectedItem === $key) {
594
            $this->selectedItem = null;
595
            $this->selectFirstItem();
596
        }
597
    }
598
599
    public function getStyle() : MenuStyle
600
    {
601
        return $this->style;
602
    }
603
604
    public function setStyle(MenuStyle $style) : void
605
    {
606
        $this->style = $style;
607
    }
608
609
    public function getCurrentFrame() : Frame
610
    {
611
        return $this->currentFrame;
612
    }
613
614
    public function flash(string $text, MenuStyle $style = null) : Flash
615
    {
616
        $this->guardSingleLine($text);
617
618
        $style = $style ?? (new MenuStyle($this->terminal))
619
            ->setBg('yellow')
620
            ->setFg('red');
621
622
        return new Flash($this, $style, $this->terminal, $text);
623
    }
624
625
    public function confirm(string $text, MenuStyle $style = null) : Confirm
626
    {
627
        $this->guardSingleLine($text);
628
629
        $style = $style ?? (new MenuStyle($this->terminal))
630
            ->setBg('yellow')
631
            ->setFg('red');
632
633
        return new Confirm($this, $style, $this->terminal, $text);
634
    }
635
636
    public function askNumber(MenuStyle $style = null) : Number
637
    {
638
        $this->assertOpen();
639
640
        $style = $style ?? (new MenuStyle($this->terminal))
641
            ->setBg('yellow')
642
            ->setFg('red');
643
644
        return new Number(new InputIO($this, $this->terminal), $style);
645
    }
646
647
    public function askText(MenuStyle $style = null) : Text
648
    {
649
        $this->assertOpen();
650
651
        $style = $style ?? (new MenuStyle($this->terminal))
652
            ->setBg('yellow')
653
            ->setFg('red');
654
655
        return new Text(new InputIO($this, $this->terminal), $style);
656
    }
657
658
    public function askPassword(MenuStyle $style = null) : Password
659
    {
660
        $this->assertOpen();
661
662
        $style = $style ?? (new MenuStyle($this->terminal))
663
            ->setBg('yellow')
664
            ->setFg('red');
665
666
        return new Password(new InputIO($this, $this->terminal), $style);
667
    }
668
669
    private function guardSingleLine($text) : void
670
    {
671
        if (strpos($text, "\n") !== false) {
672
            throw new \InvalidArgumentException;
673
        }
674
    }
675
}
676