Completed
Push — master ( 389ef3...499201 )
by Aydin
24s queued 11s
created

CliMenu::setSelectableStyle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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