Completed
Push — master ( 2edd9d...d97347 )
by Aydin
21s queued 11s
created

CliMenu::getItemStyle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
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\each;
26
27
/**
28
 * @author Michael Woodward <[email protected]>
29
 */
30
class CliMenu
31
{
32
    /**
33
     * @var Terminal
34
     */
35
    protected $terminal;
36
37
    /**
38
     * @var MenuStyle
39
     */
40
    protected $style;
41
42
    /**
43
     * @var Locator
44
     */
45
    private $itemStyleLocator;
46
47
    /**
48
     * @var ?string
49
     */
50
    protected $title;
51
52
    /**
53
     * @var MenuItemInterface[]
54
     */
55
    protected $items = [];
56
57
    /**
58
     * @var int|null
59
     */
60
    protected $selectedItem;
61
62
    /**
63
     * @var bool
64
     */
65
    protected $open = false;
66
67
    /**
68
     * @var CliMenu|null
69
     */
70
    protected $parent;
71
72
    /**
73
     * @var array
74
     */
75
    protected $defaultControlMappings = [
76
        '^P' => InputCharacter::UP,
77
        'k'  => InputCharacter::UP,
78
        '^K' => InputCharacter::DOWN,
79
        'j'  => InputCharacter::DOWN,
80
        "\r" => InputCharacter::ENTER,
81
        ' '  => InputCharacter::ENTER,
82
        'l'  => InputCharacter::LEFT,
83
        'm'  => InputCharacter::RIGHT,
84
    ];
85
86
    /**
87
     * @var array
88
     */
89
    protected $customControlMappings = [];
90
91
    /**
92
     * @var Frame
93
     */
94
    private $currentFrame;
95
96
    public function __construct(
97
        ?string $title,
98
        array $items,
99
        Terminal $terminal = null,
100
        MenuStyle $style = null
101
    ) {
102
        $this->title           = $title;
103
        $this->items           = $items;
104
        $this->terminal        = $terminal ?: TerminalFactory::fromSystem();
105
        $this->style           = $style ?: new MenuStyle($this->terminal);
106
107
        $this->itemStyleLocator = new Locator();
108
109
        $this->selectFirstItem();
110
    }
111
112
    /**
113
     * Configure the terminal to work with CliMenu
114
     */
115
    protected function configureTerminal() : void
116
    {
117
        $this->assertTerminalIsValidTTY();
118
119
        $this->terminal->disableCanonicalMode();
120
        $this->terminal->disableEchoBack();
121
        $this->terminal->disableCursor();
122
        $this->terminal->clear();
123
    }
124
125
    /**
126
     * Revert changes made to the terminal
127
     */
128
    protected function tearDownTerminal() : void
129
    {
130
        $this->terminal->restoreOriginalConfiguration();
131
        $this->terminal->enableCanonicalMode();
132
        $this->terminal->enableEchoBack();
133
        $this->terminal->enableCursor();
134
    }
135
136
    private function assertTerminalIsValidTTY() : void
137
    {
138
        if (!$this->terminal->isInteractive()) {
139
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
140
        }
141
    }
142
143
    public function setTitle(string $title) : void
144
    {
145
        $this->title = $title;
146
    }
147
148
    public function getTitle() : ?string
149
    {
150
        return $this->title;
151
    }
152
153
    public function setParent(CliMenu $parent) : void
154
    {
155
        $this->parent = $parent;
156
    }
157
158
    public function getParent() : ?CliMenu
159
    {
160
        return $this->parent;
161
    }
162
163
    public function getTerminal() : Terminal
164
    {
165
        return $this->terminal;
166
    }
167
168
    public function isOpen() : bool
169
    {
170
        return $this->open;
171
    }
172
173
    /**
174
     * Add a new Item to the menu
175
     */
176
    public function addItem(MenuItemInterface $item) : void
177
    {
178
        $this->items[] = $item;
179
180
        $this->selectFirstItem();
181
    }
182
183
    /**
184
     * Add multiple Items to the menu
185
     */
186
    public function addItems(array $items) : void
187
    {
188
        foreach ($items as $item) {
189
            $this->items[] = $item;
190
        }
191
192
        $this->selectFirstItem();
193
    }
194
195
    /**
196
     * Set Items of the menu
197
     */
198
    public function setItems(array $items) : void
199
    {
200
        $this->selectedItem = null;
201
        $this->items = $items;
202
203
        $this->selectFirstItem();
204
    }
205
206
    /**
207
     * Set the selected pointer to the first selectable item
208
     */
209
    private function selectFirstItem() : void
210
    {
211
        if (null === $this->selectedItem) {
212
            foreach ($this->items as $key => $item) {
213
                if ($item->canSelect()) {
214
                    $this->selectedItem = $key;
215
                    break;
216
                }
217
            }
218
        }
219
    }
220
221
    /**
222
     * Disables the built-in VIM control mappings
223
     */
224
    public function disableDefaultControlMappings() : void
225
    {
226
        $this->defaultControlMappings = [];
227
    }
228
229
    /**
230
     * Set default control mappings
231
     */
232
    public function setDefaultControlMappings(array $defaultControlMappings) : void
233
    {
234
        $this->defaultControlMappings = $defaultControlMappings;
235
    }
236
237
    /**
238
     * Adds a custom control mapping
239
     */
240
    public function addCustomControlMapping(string $input, callable $callable) : void
241
    {
242
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
243
            throw new \InvalidArgumentException('Cannot rebind this input');
244
        }
245
246
        $this->customControlMappings[$input] = $callable;
247
    }
248
249
    public function getCustomControlMappings() : array
250
    {
251
        return $this->customControlMappings;
252
    }
253
254
    /**
255
     * Shorthand function to add multiple custom control mapping at once
256
     */
257
    public function addCustomControlMappings(array $map) : void
258
    {
259
        foreach ($map as $input => $callable) {
260
            $this->addCustomControlMapping($input, $callable);
261
        }
262
    }
263
264
    /**
265
     * Removes a custom control mapping
266
     */
267
    public function removeCustomControlMapping(string $input) : void
268
    {
269
        if (!isset($this->customControlMappings[$input])) {
270
            throw new \InvalidArgumentException('This input is not registered');
271
        }
272
273
        unset($this->customControlMappings[$input]);
274
    }
275
276
    /**
277
     * Display menu and capture input
278
     */
279
    private function display() : void
280
    {
281
        $this->draw();
282
283
        $reader = new NonCanonicalReader($this->terminal);
284
        $reader->addControlMappings($this->defaultControlMappings);
285
286
        while ($this->isOpen()) {
287
            $char = $reader->readCharacter();
288
            if (!$char->isHandledControl()) {
289
                $rawChar = $char->get();
290
                if (isset($this->customControlMappings[$rawChar])) {
291
                    $this->customControlMappings[$rawChar]($this);
292
                }
293
                continue;
294
            }
295
296
            switch ($char->getControl()) {
297
                case InputCharacter::UP:
298
                case InputCharacter::DOWN:
299
                    $this->moveSelectionVertically($char->getControl());
300
                    $this->draw();
301
                    break;
302
                case InputCharacter::LEFT:
303
                case InputCharacter::RIGHT:
304
                    $this->moveSelectionHorizontally($char->getControl());
305
                    $this->draw();
306
                    break;
307
                case InputCharacter::ENTER:
308
                    $this->executeCurrentItem();
309
                    break;
310
            }
311
        }
312
    }
313
314
    /**
315
     * Move the selection in a given direction, up / down
316
     */
317
    protected function moveSelectionVertically(string $direction) : void
318
    {
319
        $itemKeys = array_keys($this->items);
320
321
        $increments = 0;
322
323
        do {
324
            $increments++;
325
326
            if ($increments > count($itemKeys)) {
327
                //full cycle detected, there must be no selected items
328
                //in the menu, so stop trying to select one.
329
                return;
330
            }
331
332
            $direction === 'UP'
333
                ? $this->selectedItem--
334
                : $this->selectedItem++;
335
336
            if ($this->selectedItem !== null && !array_key_exists($this->selectedItem, $this->items)) {
337
                $this->selectedItem  = $direction === 'UP'
338
                    ? (int) end($itemKeys)
339
                    : (int) reset($itemKeys);
340
            }
341
        } while (!$this->canSelect());
342
    }
343
344
    /**
345
     * Move the selection in a given direction, left / right
346
     */
347
    protected function moveSelectionHorizontally(string $direction) : void
348
    {
349
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
350
            return;
351
        }
352
353
        /** @var SplitItem $item */
354
        $item = $this->items[$this->selectedItem];
355
        $itemKeys = array_keys($item->getItems());
356
        $selectedItemIndex = $item->getSelectedItemIndex();
357
358
        if (null === $selectedItemIndex) {
359
            $selectedItemIndex = 0;
360
        }
361
362
        do {
363
            $direction === 'LEFT'
364
                ? $selectedItemIndex--
365
                : $selectedItemIndex++;
366
367
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
368
                $selectedItemIndex = $direction === 'LEFT'
369
                    ? (int) end($itemKeys)
370
                    : (int) reset($itemKeys);
371
            }
372
        } while (!$item->canSelectIndex($selectedItemIndex));
373
        
374
        $item->setSelectedItemIndex($selectedItemIndex);
375
    }
376
377
    /**
378
     * Can the currently selected item actually be selected?
379
     *
380
     * For example:
381
     *  selectable item -> yes
382
     *  static item -> no
383
     *  split item with only static items -> no
384
     *  split item with at least one selectable item -> yes
385
     *
386
     * @return bool
387
     */
388
    private function canSelect() : bool
389
    {
390
        return $this->items[$this->selectedItem]->canSelect();
391
    }
392
393
    /**
394
     * Retrieve the item the user actually selected
395
     *
396
     */
397
    public function getSelectedItem() : MenuItemInterface
398
    {
399
        if (null === $this->selectedItem) {
400
            throw new \RuntimeException('No selected item');
401
        }
402
403
        $item = $this->items[$this->selectedItem];
404
        return $item instanceof SplitItem
405
            ? $item->getSelectedItem()
406
            : $item;
407
    }
408
409
    public function setSelectedItem(MenuItemInterface $item) : void
410
    {
411
        $key = array_search($item, $this->items, true);
412
413
        if (false === $key) {
414
            throw new \InvalidArgumentException('Item does not exist in menu');
415
        }
416
417
        $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...
418
    }
419
420
    public function getSelectedItemIndex() : int
421
    {
422
        if (null === $this->selectedItem) {
423
            throw new \RuntimeException('No selected item');
424
        }
425
426
        return $this->selectedItem;
427
    }
428
429
    public function getItemByIndex(int $index) : MenuItemInterface
430
    {
431
        if (!isset($this->items[$index])) {
432
            throw new \RuntimeException('Item with index does not exist');
433
        }
434
435
        return $this->items[$index];
436
    }
437
438
    public function executeAsSelected(MenuItemInterface $item) : void
439
    {
440
        $current = $this->items[$this->selectedItem];
441
        $this->setSelectedItem($item);
442
        $this->executeCurrentItem();
443
        $this->setSelectedItem($current);
444
    }
445
446
    /**
447
     * Execute the current item
448
     */
449
    protected function executeCurrentItem() : void
450
    {
451
        $item = $this->getSelectedItem();
452
453
        if ($item->canSelect()) {
454
            $callable = $item->getSelectAction();
455
            if ($callable) {
456
                $callable($this);
457
            }
458
        }
459
    }
460
461
    /**
462
     * If true we clear the whole terminal screen, useful
463
     * for example when reducing the width of the menu, to not
464
     * leave leftovers of the previous wider menu.
465
     *
466
     * Redraw the menu
467
     */
468
    public function redraw(bool $clear = false) : void
469
    {
470
        if ($clear) {
471
            $this->terminal->clear();
472
        }
473
474
        $this->assertOpen();
475
        $this->draw();
476
    }
477
478
    private function assertOpen() : void
479
    {
480
        if (!$this->isOpen()) {
481
            throw new MenuNotOpenException;
482
        }
483
    }
484
485
    /**
486
     * Draw the menu to STDOUT
487
     */
488
    protected function draw() : void
489
    {
490
        $frame = new Frame;
491
492
        $frame->newLine(2);
493
494
        if ($this->style->getBorderTopWidth() > 0) {
495
            $frame->addRows($this->style->getBorderTopRows());
496
        }
497
498
        if ($this->style->getPaddingTopBottom() > 0) {
499
            $frame->addRows($this->style->getPaddingTopBottomRows());
500
        }
501
502
        if ($this->title) {
503
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
504
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
505
        }
506
507
        array_map(function ($item, $index) use ($frame) {
508
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
509
        }, $this->items, array_keys($this->items));
510
511
512
        if ($this->style->getPaddingTopBottom() > 0) {
513
            $frame->addRows($this->style->getPaddingTopBottomRows());
514
        }
515
516
        if ($this->style->getBorderBottomWidth() > 0) {
517
            $frame->addRows($this->style->getBorderBottomRows());
518
        }
519
520
        $frame->newLine(2);
521
522
        $this->terminal->moveCursorToTop();
523
        foreach ($frame->getRows() as $row) {
524
            if ($row == "\n") {
525
                $this->terminal->clearLine();
526
            }
527
            $this->terminal->write($row);
528
        }
529
        $this->terminal->clearDown();
530
531
        $this->currentFrame = $frame;
532
    }
533
534
    /**
535
     * Draw a menu item
536
     */
537
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
538
    {
539
        $rows = $item->getRows($this->style, $selected);
540
        
541
        if ($item instanceof SplitItem) {
542
            $selected = false;
543
        }
544
545
        $invertedColoursSetCode = $selected
546
            ? $this->style->getInvertedColoursSetCode()
547
            : '';
548
        $invertedColoursUnsetCode = $selected
549
            ? $this->style->getInvertedColoursUnsetCode()
550
            : '';
551
552
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
553
            $borderColour = $this->style->getBorderColourCode();
554
        } else {
555
            $borderColour = '';
556
        }
557
558
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
559
            return sprintf(
560
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
561
                str_repeat(' ', $this->style->getMargin()),
562
                $borderColour,
563
                str_repeat(' ', $this->style->getBorderLeftWidth()),
564
                $this->style->getColoursSetCode(),
565
                $invertedColoursSetCode,
566
                str_repeat(' ', $this->style->getPaddingLeftRight()),
567
                $row,
568
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
569
                $invertedColoursUnsetCode,
570
                $borderColour,
571
                str_repeat(' ', $this->style->getBorderRightWidth()),
572
                $this->style->getColoursResetCode()
573
            );
574
        }, $rows);
575
    }
576
577
    /**
578
     * @throws InvalidTerminalException
579
     */
580
    public function open() : void
581
    {
582
        if ($this->isOpen()) {
583
            return;
584
        }
585
        
586
        if (count($this->items) === 0) {
587
            throw new \RuntimeException('Menu must have at least 1 item before it can be opened');
588
        }
589
590
        $this->configureTerminal();
591
        $this->open = true;
592
        $this->display();
593
    }
594
595
    /**
596
     * Close the menu
597
     *
598
     * @throws InvalidTerminalException
599
     */
600
    public function close() : void
601
    {
602
        $menu = $this;
603
604
        do {
605
            $menu->closeThis();
606
            $menu = $menu->getParent();
607
        } while (null !== $menu);
608
609
        $this->tearDownTerminal();
610
    }
611
612
    public function closeThis() : void
613
    {
614
        $this->terminal->clean();
615
        $this->terminal->moveCursorToTop();
616
        $this->open = false;
617
    }
618
619
    /**
620
     * @return MenuItemInterface[]
621
     */
622
    public function getItems() : array
623
    {
624
        return $this->items;
625
    }
626
627
    public function removeItem(MenuItemInterface $item) : void
628
    {
629
        $key = array_search($item, $this->items, true);
630
631
        if (false === $key) {
632
            throw new \InvalidArgumentException('Item does not exist in menu');
633
        }
634
635
        unset($this->items[$key]);
636
        $this->items = array_values($this->items);
637
638
        if ($this->selectedItem === $key) {
639
            $this->selectedItem = null;
640
            $this->selectFirstItem();
641
        }
642
    }
643
644
    public function getStyle() : MenuStyle
645
    {
646
        return $this->style;
647
    }
648
649
    public function setStyle(MenuStyle $style) : void
650
    {
651
        $this->style = $style;
652
    }
653
654
    public function setItemStyle(ItemStyle $style, string $styleClass) : void
655
    {
656
        $this->itemStyleLocator->setStyle($style, $styleClass);
657
    }
658
659
    public function getItemStyle(string $styleClass) : ItemStyle
660
    {
661
        return $this->itemStyleLocator->getStyle($styleClass);
662
    }
663
664
    public function getItemStyleForItem(MenuItemInterface $item) : ItemStyle
665
    {
666
        return $this->itemStyleLocator->getStyleForMenuItem($item);
667
    }
668
669
    public function importStyles(CliMenu $menu) : void
670
    {
671
        if (!$this->style->hasChangedFromDefaults()) {
672
            $this->style = $menu->style;
673
        }
674
675
        $this->itemStyleLocator->importFrom($menu->itemStyleLocator);
676
    }
677
678
    public function getCurrentFrame() : Frame
679
    {
680
        return $this->currentFrame;
681
    }
682
683
    public function flash(string $text, MenuStyle $style = null) : Flash
684
    {
685
        $this->guardSingleLine($text);
686
687
        $style = $style ?? (new MenuStyle($this->terminal))
688
            ->setBg('yellow')
689
            ->setFg('red');
690
691
        return new Flash($this, $style, $this->terminal, $text);
692
    }
693
694
    public function confirm(string $text, MenuStyle $style = null) : Confirm
695
    {
696
        $this->guardSingleLine($text);
697
698
        $style = $style ?? (new MenuStyle($this->terminal))
699
            ->setBg('yellow')
700
            ->setFg('red');
701
702
        return new Confirm($this, $style, $this->terminal, $text);
703
    }
704
705
    public function askNumber(MenuStyle $style = null) : Number
706
    {
707
        $this->assertOpen();
708
709
        $style = $style ?? (new MenuStyle($this->terminal))
710
            ->setBg('yellow')
711
            ->setFg('red');
712
713
        return new Number(new InputIO($this, $this->terminal), $style);
714
    }
715
716
    public function askText(MenuStyle $style = null) : Text
717
    {
718
        $this->assertOpen();
719
720
        $style = $style ?? (new MenuStyle($this->terminal))
721
            ->setBg('yellow')
722
            ->setFg('red');
723
724
        return new Text(new InputIO($this, $this->terminal), $style);
725
    }
726
727
    public function askPassword(MenuStyle $style = null) : Password
728
    {
729
        $this->assertOpen();
730
731
        $style = $style ?? (new MenuStyle($this->terminal))
732
            ->setBg('yellow')
733
            ->setFg('red');
734
735
        return new Password(new InputIO($this, $this->terminal), $style);
736
    }
737
738
    private function guardSingleLine(string $text) : void
739
    {
740
        if (strpos($text, "\n") !== false) {
741
            throw new \InvalidArgumentException;
742
        }
743
    }
744
745
    public function propagateStyles() : void
746
    {
747
        each(
748
            array_filter($this->items, function (MenuItemInterface $item) {
749
                return !$item->getStyle()->hasChangedFromDefaults();
750
            }),
751
            function (int $index, $item) {
752
                $item->setStyle(clone $this->getItemStyleForItem($item));
753
            }
754
        );
755
756
        each(
757
            array_filter($this->items, function (MenuItemInterface $item) {
758
                return $item instanceof PropagatesStyles;
759
            }),
760
            function (int $index, PropagatesStyles $item) {
761
                $item->propagateStyles($this);
762
            }
763
        );
764
    }
765
}
766