CliMenu::flash()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 9
rs 10
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\PropagatesStyles;
14
use PhpSchool\CliMenu\MenuItem\SplitItem;
15
use PhpSchool\CliMenu\MenuItem\StaticItem;
16
use PhpSchool\CliMenu\Dialogue\Confirm;
17
use PhpSchool\CliMenu\Dialogue\Flash;
18
use PhpSchool\CliMenu\Style\ItemStyle;
19
use PhpSchool\CliMenu\Style\Locator;
20
use PhpSchool\CliMenu\Terminal\TerminalFactory;
21
use PhpSchool\CliMenu\Util\StringUtil as s;
22
use PhpSchool\Terminal\InputCharacter;
23
use PhpSchool\Terminal\NonCanonicalReader;
24
use PhpSchool\Terminal\Terminal;
25
use function PhpSchool\CliMenu\Util\collect;
26
use function PhpSchool\CliMenu\Util\each;
27
28
/**
29
 * @author Michael Woodward <[email protected]>
30
 */
31
class CliMenu
32
{
33
    /**
34
     * @var Terminal
35
     */
36
    protected $terminal;
37
38
    /**
39
     * @var MenuStyle
40
     */
41
    protected $style;
42
43
    /**
44
     * @var Locator
45
     */
46
    private $itemStyleLocator;
47
48
    /**
49
     * @var ?string
50
     */
51
    protected $title;
52
53
    /**
54
     * @var MenuItemInterface[]
55
     */
56
    protected $items = [];
57
58
    /**
59
     * @var int|null
60
     */
61
    protected $selectedItem;
62
63
    /**
64
     * @var bool
65
     */
66
    protected $open = false;
67
68
    /**
69
     * @var CliMenu|null
70
     */
71
    protected $parent;
72
73
    /**
74
     * @var array
75
     */
76
    protected $defaultControlMappings = [
77
        '^P' => InputCharacter::UP,
78
        'k'  => InputCharacter::UP,
79
        '^K' => InputCharacter::DOWN,
80
        'j'  => InputCharacter::DOWN,
81
        "\r" => InputCharacter::ENTER,
82
        ' '  => InputCharacter::ENTER,
83
        'l'  => InputCharacter::LEFT,
84
        'm'  => InputCharacter::RIGHT,
85
    ];
86
87
    /**
88
     * @var array
89
     */
90
    protected $customControlMappings = [];
91
92
    /**
93
     * @var Frame
94
     */
95
    private $currentFrame;
96
97
    public function __construct(
98
        ?string $title,
99
        array $items,
100
        Terminal $terminal = null,
101
        MenuStyle $style = null
102
    ) {
103
        $this->title           = $title;
104
        $this->items           = $items;
105
        $this->terminal        = $terminal ?: TerminalFactory::fromSystem();
106
        $this->style           = $style ?: new MenuStyle($this->terminal);
107
108
        $this->itemStyleLocator = new Locator();
109
110
        $this->selectFirstItem();
111
    }
112
113
    /**
114
     * Configure the terminal to work with CliMenu
115
     */
116
    protected function configureTerminal() : void
117
    {
118
        $this->assertTerminalIsValidTTY();
119
120
        $this->terminal->disableCanonicalMode();
121
        $this->terminal->disableEchoBack();
122
        $this->terminal->disableCursor();
123
        $this->terminal->clear();
124
    }
125
126
    /**
127
     * Revert changes made to the terminal
128
     */
129
    protected function tearDownTerminal() : void
130
    {
131
        $this->terminal->restoreOriginalConfiguration();
132
        $this->terminal->enableCanonicalMode();
133
        $this->terminal->enableEchoBack();
134
        $this->terminal->enableCursor();
135
    }
136
137
    private function assertTerminalIsValidTTY() : void
138
    {
139
        if (!$this->terminal->isInteractive()) {
140
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
141
        }
142
    }
143
144
    public function setTitle(string $title) : void
145
    {
146
        $this->title = $title;
147
    }
148
149
    public function getTitle() : ?string
150
    {
151
        return $this->title;
152
    }
153
154
    public function setParent(CliMenu $parent) : void
155
    {
156
        $this->parent = $parent;
157
    }
158
159
    public function getParent() : ?CliMenu
160
    {
161
        return $this->parent;
162
    }
163
164
    public function getTerminal() : Terminal
165
    {
166
        return $this->terminal;
167
    }
168
169
    public function isOpen() : bool
170
    {
171
        return $this->open;
172
    }
173
174
    /**
175
     * Add a new Item to the menu
176
     */
177
    public function addItem(MenuItemInterface $item) : void
178
    {
179
        $this->items[] = $item;
180
181
        $this->selectFirstItem();
182
    }
183
184
    /**
185
     * Add multiple Items to the menu
186
     */
187
    public function addItems(array $items) : void
188
    {
189
        foreach ($items as $item) {
190
            $this->items[] = $item;
191
        }
192
193
        $this->selectFirstItem();
194
    }
195
196
    /**
197
     * Set Items of the menu
198
     */
199
    public function setItems(array $items) : void
200
    {
201
        $this->selectedItem = null;
202
        $this->items = $items;
203
204
        $this->selectFirstItem();
205
    }
206
207
    /**
208
     * Set the selected pointer to the first selectable item
209
     */
210
    private function selectFirstItem() : void
211
    {
212
        if (null === $this->selectedItem) {
213
            foreach ($this->items as $key => $item) {
214
                if ($item->canSelect()) {
215
                    $this->selectedItem = $key;
216
                    break;
217
                }
218
            }
219
        }
220
    }
221
222
    /**
223
     * Disables the built-in VIM control mappings
224
     */
225
    public function disableDefaultControlMappings() : void
226
    {
227
        $this->defaultControlMappings = [];
228
    }
229
230
    /**
231
     * Set default control mappings
232
     */
233
    public function setDefaultControlMappings(array $defaultControlMappings) : void
234
    {
235
        $this->defaultControlMappings = $defaultControlMappings;
236
    }
237
238
    /**
239
     * Adds a custom control mapping
240
     */
241
    public function addCustomControlMapping(string $input, callable $callable) : void
242
    {
243
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
244
            throw new \InvalidArgumentException('Cannot rebind this input');
245
        }
246
247
        $this->customControlMappings[$input] = $callable;
248
    }
249
250
    public function getCustomControlMappings() : array
251
    {
252
        return $this->customControlMappings;
253
    }
254
255
    /**
256
     * Shorthand function to add multiple custom control mapping at once
257
     */
258
    public function addCustomControlMappings(array $map) : void
259
    {
260
        foreach ($map as $input => $callable) {
261
            $this->addCustomControlMapping($input, $callable);
262
        }
263
    }
264
265
    /**
266
     * Removes a custom control mapping
267
     */
268
    public function removeCustomControlMapping(string $input) : void
269
    {
270
        if (!isset($this->customControlMappings[$input])) {
271
            throw new \InvalidArgumentException('This input is not registered');
272
        }
273
274
        unset($this->customControlMappings[$input]);
275
    }
276
277
    /**
278
     * Display menu and capture input
279
     */
280
    private function display() : void
281
    {
282
        $this->draw();
283
284
        $reader = new NonCanonicalReader($this->terminal);
285
        $reader->addControlMappings($this->defaultControlMappings);
286
287
        while ($this->isOpen()) {
288
            $char = $reader->readCharacter();
289
            if (!$char->isHandledControl()) {
290
                $rawChar = $char->get();
291
                if (isset($this->customControlMappings[$rawChar])) {
292
                    $this->customControlMappings[$rawChar]($this);
293
                }
294
                continue;
295
            }
296
297
            switch ($char->getControl()) {
298
                case InputCharacter::UP:
299
                case InputCharacter::DOWN:
300
                    $this->moveSelectionVertically($char->getControl());
301
                    $this->draw();
302
                    break;
303
                case InputCharacter::LEFT:
304
                case InputCharacter::RIGHT:
305
                    $this->moveSelectionHorizontally($char->getControl());
306
                    $this->draw();
307
                    break;
308
                case InputCharacter::ENTER:
309
                    $this->executeCurrentItem();
310
                    break;
311
            }
312
        }
313
    }
314
315
    /**
316
     * Move the selection in a given direction, up / down
317
     */
318
    protected function moveSelectionVertically(string $direction) : void
319
    {
320
        $itemKeys = array_keys($this->items);
321
322
        $increments = 0;
323
324
        do {
325
            $increments++;
326
327
            if ($increments > count($itemKeys)) {
328
                //full cycle detected, there must be no selected items
329
                //in the menu, so stop trying to select one.
330
                return;
331
            }
332
333
            $direction === 'UP'
334
                ? $this->selectedItem--
335
                : $this->selectedItem++;
336
337
            if ($this->selectedItem !== null && !array_key_exists($this->selectedItem, $this->items)) {
338
                $this->selectedItem  = $direction === 'UP'
339
                    ? (int) end($itemKeys)
340
                    : (int) reset($itemKeys);
341
            }
342
        } while (!$this->canSelect());
343
    }
344
345
    /**
346
     * Move the selection in a given direction, left / right
347
     */
348
    protected function moveSelectionHorizontally(string $direction) : void
349
    {
350
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
351
            return;
352
        }
353
354
        /** @var SplitItem $item */
355
        $item = $this->items[$this->selectedItem];
356
        $itemKeys = array_keys($item->getItems());
357
        $selectedItemIndex = $item->getSelectedItemIndex();
358
359
        if (null === $selectedItemIndex) {
360
            $selectedItemIndex = 0;
361
        }
362
363
        do {
364
            $direction === 'LEFT'
365
                ? $selectedItemIndex--
366
                : $selectedItemIndex++;
367
368
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
369
                $selectedItemIndex = $direction === 'LEFT'
370
                    ? (int) end($itemKeys)
371
                    : (int) reset($itemKeys);
372
            }
373
        } while (!$item->canSelectIndex($selectedItemIndex));
374
        
375
        $item->setSelectedItemIndex($selectedItemIndex);
376
    }
377
378
    /**
379
     * Can the currently selected item actually be selected?
380
     *
381
     * For example:
382
     *  selectable item -> yes
383
     *  static item -> no
384
     *  split item with only static items -> no
385
     *  split item with at least one selectable item -> yes
386
     *
387
     * @return bool
388
     */
389
    private function canSelect() : bool
390
    {
391
        return $this->items[$this->selectedItem]->canSelect();
392
    }
393
394
    /**
395
     * Retrieve the item the user actually selected
396
     *
397
     */
398
    public function getSelectedItem() : MenuItemInterface
399
    {
400
        if (null === $this->selectedItem) {
401
            throw new \RuntimeException('No selected item');
402
        }
403
404
        $item = $this->items[$this->selectedItem];
405
        return $item instanceof SplitItem
406
            ? $item->getSelectedItem()
407
            : $item;
408
    }
409
410
    public function setSelectedItem(MenuItemInterface $item) : void
411
    {
412
        $key = array_search($item, $this->items, true);
413
414
        if (false === $key) {
415
            throw new \InvalidArgumentException('Item does not exist in menu');
416
        }
417
418
        $this->selectedItem = $key;
0 ignored issues
show
Documentation Bug introduced by
It seems like $key can also be of type string. However, the property $selectedItem is declared as type integer|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
419
    }
420
421
    public function getSelectedItemIndex() : int
422
    {
423
        if (null === $this->selectedItem) {
424
            throw new \RuntimeException('No selected item');
425
        }
426
427
        return $this->selectedItem;
428
    }
429
430
    public function getItemByIndex(int $index) : MenuItemInterface
431
    {
432
        if (!isset($this->items[$index])) {
433
            throw new \RuntimeException('Item with index does not exist');
434
        }
435
436
        return $this->items[$index];
437
    }
438
439
    public function executeAsSelected(MenuItemInterface $item) : void
440
    {
441
        $current = $this->items[$this->selectedItem];
442
        $this->setSelectedItem($item);
443
        $this->executeCurrentItem();
444
        $this->setSelectedItem($current);
445
    }
446
447
    /**
448
     * Execute the current item
449
     */
450
    protected function executeCurrentItem() : void
451
    {
452
        $item = $this->getSelectedItem();
453
454
        if ($item->canSelect()) {
455
            $callable = $item->getSelectAction();
456
            if ($callable) {
457
                $callable($this);
458
            }
459
        }
460
    }
461
462
    /**
463
     * If true we clear the whole terminal screen, useful
464
     * for example when reducing the width of the menu, to not
465
     * leave leftovers of the previous wider menu.
466
     *
467
     * Redraw the menu
468
     */
469
    public function redraw(bool $clear = false) : void
470
    {
471
        if ($clear) {
472
            $this->terminal->clear();
473
        }
474
475
        $this->assertOpen();
476
        $this->draw();
477
    }
478
479
    private function assertOpen() : void
480
    {
481
        if (!$this->isOpen()) {
482
            throw new MenuNotOpenException;
483
        }
484
    }
485
486
    /**
487
     * Draw the menu to STDOUT
488
     */
489
    protected function draw() : void
490
    {
491
        $frame = new Frame;
492
493
        $frame->newLine(2);
494
495
        if ($this->style->getBorderTopWidth() > 0) {
496
            $frame->addRows($this->style->getBorderTopRows());
497
        }
498
499
        if ($this->style->getPaddingTopBottom() > 0) {
500
            $frame->addRows($this->style->getPaddingTopBottomRows());
501
        }
502
503
        if ($this->title) {
504
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
505
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
506
        }
507
508
        array_map(function ($item, $index) use ($frame) {
509
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
510
        }, $this->items, array_keys($this->items));
511
512
513
        if ($this->style->getPaddingTopBottom() > 0) {
514
            $frame->addRows($this->style->getPaddingTopBottomRows());
515
        }
516
517
        if ($this->style->getBorderBottomWidth() > 0) {
518
            $frame->addRows($this->style->getBorderBottomRows());
519
        }
520
521
        $frame->newLine(2);
522
523
        $this->terminal->moveCursorToTop();
524
        foreach ($frame->getRows() as $row) {
525
            if ($row == "\n") {
526
                $this->terminal->clearLine();
527
            }
528
            $this->terminal->write($row);
529
        }
530
        $this->terminal->clearDown();
531
532
        $this->currentFrame = $frame;
533
    }
534
535
    /**
536
     * Draw a menu item
537
     */
538
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
539
    {
540
        $rows = $item->getRows($this->style, $selected);
541
        
542
        if ($item instanceof SplitItem) {
543
            $selected = false;
544
        }
545
546
        $invertedColoursSetCode = $selected
547
            ? $this->style->getInvertedColoursSetCode()
548
            : '';
549
        $invertedColoursUnsetCode = $selected
550
            ? $this->style->getInvertedColoursUnsetCode()
551
            : '';
552
553
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
554
            $borderColour = $this->style->getBorderColourCode();
555
        } else {
556
            $borderColour = '';
557
        }
558
559
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
560
            return sprintf(
561
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
562
                str_repeat(' ', $this->style->getMargin()),
563
                $borderColour,
564
                str_repeat(' ', $this->style->getBorderLeftWidth()),
565
                $this->style->getColoursSetCode(),
566
                $invertedColoursSetCode,
567
                str_repeat(' ', $this->style->getPaddingLeftRight()),
568
                $row,
569
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
570
                $invertedColoursUnsetCode,
571
                $borderColour,
572
                str_repeat(' ', $this->style->getBorderRightWidth()),
573
                $this->style->getColoursResetCode()
574
            );
575
        }, $rows);
576
    }
577
578
    /**
579
     * @throws InvalidTerminalException
580
     */
581
    public function open() : void
582
    {
583
        if ($this->isOpen()) {
584
            return;
585
        }
586
        
587
        if (count($this->items) === 0) {
588
            throw new \RuntimeException('Menu must have at least 1 item before it can be opened');
589
        }
590
591
        $this->configureTerminal();
592
        $this->open = true;
593
        $this->display();
594
    }
595
596
    /**
597
     * Close the menu
598
     *
599
     * @throws InvalidTerminalException
600
     */
601
    public function close() : void
602
    {
603
        $menu = $this;
604
605
        do {
606
            $menu->closeThis();
607
            $menu = $menu->getParent();
608
        } while (null !== $menu);
609
610
        $this->tearDownTerminal();
611
    }
612
613
    public function closeThis() : void
614
    {
615
        $this->terminal->clean();
616
        $this->terminal->moveCursorToTop();
617
        $this->open = false;
618
    }
619
620
    /**
621
     * @return MenuItemInterface[]
622
     */
623
    public function getItems() : array
624
    {
625
        return $this->items;
626
    }
627
628
    public function removeItem(MenuItemInterface $item) : void
629
    {
630
        $key = array_search($item, $this->items, true);
631
632
        if (false === $key) {
633
            throw new \InvalidArgumentException('Item does not exist in menu');
634
        }
635
636
        unset($this->items[$key]);
637
        $this->items = array_values($this->items);
638
639
        if ($this->selectedItem === $key) {
640
            $this->selectedItem = null;
641
            $this->selectFirstItem();
642
        }
643
    }
644
645
    public function getStyle() : MenuStyle
646
    {
647
        return $this->style;
648
    }
649
650
    public function setStyle(MenuStyle $style) : void
651
    {
652
        $this->style = $style;
653
    }
654
655
    public function setItemStyle(ItemStyle $style, string $styleClass) : void
656
    {
657
        $this->itemStyleLocator->setStyle($style, $styleClass);
658
    }
659
660
    public function getItemStyle(string $styleClass) : ItemStyle
661
    {
662
        return $this->itemStyleLocator->getStyle($styleClass);
663
    }
664
665
    public function getItemStyleForItem(MenuItemInterface $item) : ItemStyle
666
    {
667
        return $this->itemStyleLocator->getStyleForMenuItem($item);
668
    }
669
670
    public function getStyleLocator() : Locator
671
    {
672
        return $this->itemStyleLocator;
673
    }
674
675
    public function importStyles(CliMenu $menu) : void
676
    {
677
        if (!$this->style->hasChangedFromDefaults()) {
678
            $this->style = $menu->style;
679
        }
680
681
        $this->itemStyleLocator->importFrom($menu->itemStyleLocator);
682
    }
683
684
    public function getCurrentFrame() : Frame
685
    {
686
        return $this->currentFrame;
687
    }
688
689
    public function flash(string $text, MenuStyle $style = null) : Flash
690
    {
691
        $this->guardSingleLine($text);
692
693
        $style = $style ?? (new MenuStyle($this->terminal))
694
            ->setBg('yellow')
695
            ->setFg('red');
696
697
        return new Flash($this, $style, $this->terminal, $text);
698
    }
699
700
    public function confirm(string $text, MenuStyle $style = null) : Confirm
701
    {
702
        $this->guardSingleLine($text);
703
704
        $style = $style ?? (new MenuStyle($this->terminal))
705
            ->setBg('yellow')
706
            ->setFg('red');
707
708
        return new Confirm($this, $style, $this->terminal, $text);
709
    }
710
711
    public function askNumber(MenuStyle $style = null) : Number
712
    {
713
        $this->assertOpen();
714
715
        $style = $style ?? (new MenuStyle($this->terminal))
716
            ->setBg('yellow')
717
            ->setFg('red');
718
719
        return new Number(new InputIO($this, $this->terminal), $style);
720
    }
721
722
    public function askText(MenuStyle $style = null) : Text
723
    {
724
        $this->assertOpen();
725
726
        $style = $style ?? (new MenuStyle($this->terminal))
727
            ->setBg('yellow')
728
            ->setFg('red');
729
730
        return new Text(new InputIO($this, $this->terminal), $style);
731
    }
732
733
    public function askPassword(MenuStyle $style = null) : Password
734
    {
735
        $this->assertOpen();
736
737
        $style = $style ?? (new MenuStyle($this->terminal))
738
            ->setBg('yellow')
739
            ->setFg('red');
740
741
        return new Password(new InputIO($this, $this->terminal), $style);
742
    }
743
744
    private function guardSingleLine(string $text) : void
745
    {
746
        if (strpos($text, "\n") !== false) {
747
            throw new \InvalidArgumentException;
748
        }
749
    }
750
751
    public function propagateStyles() : void
752
    {
753
        collect($this->items)
754
            ->filter(function (int $k, MenuItemInterface $item) {
755
                return $this->itemStyleLocator->hasStyleForMenuItem($item);
756
            })
757
            ->filter(function (int $k, MenuItemInterface $item) {
758
                return !$item->getStyle()->hasChangedFromDefaults();
759
            })
760
            ->each(function (int $k, $item) {
761
                $item->setStyle(clone $this->getItemStyleForItem($item));
762
            });
763
764
765
        collect($this->items)
766
            ->filter(function (int $k, MenuItemInterface $item) {
767
                return $item instanceof PropagatesStyles;
768
            })
769
            ->each(function (int $k, $item) {
770
                $item->propagateStyles($this);
771
            });
772
    }
773
}
774