Completed
Push — master ( da5a0b...090289 )
by Aydin
18s queued 11s
created

CliMenu   F

Complexity

Total Complexity 110

Size/Duplication

Total Lines 717
Duplicated Lines 0 %

Importance

Changes 32
Bugs 2 Features 1
Metric Value
eloc 270
c 32
b 2
f 1
dl 0
loc 717
rs 2
wmc 110

52 Methods

Rating   Name   Duplication   Size   Complexity  
B drawMenuItem() 0 38 6
A setDefaultControlMappings() 0 3 1
A addItem() 0 5 1
A askText() 0 9 1
A guardSingleLine() 0 4 2
A setTitle() 0 3 1
A flash() 0 9 1
A closeThis() 0 5 1
A setSelectedItem() 0 9 2
A getCustomControlMappings() 0 3 1
A getSelectedItemIndex() 0 7 2
A getCheckboxStyle() 0 3 1
A getCurrentFrame() 0 3 1
A setCheckboxStyle() 0 5 1
A askNumber() 0 9 1
A getParent() 0 3 1
A askPassword() 0 9 1
A assertTerminalIsValidTTY() 0 4 2
A getTerminal() 0 3 1
A executeCurrentItem() 0 8 3
A setStyle() 0 3 1
A getRadioStyle() 0 3 1
A getSelectedItem() 0 10 3
A setRadioStyle() 0 5 1
A setParent() 0 3 1
B moveSelectionVertically() 0 25 7
A configureTerminal() 0 8 1
A addCustomControlMapping() 0 7 3
A assertOpen() 0 4 2
A isOpen() 0 3 1
B draw() 0 44 8
A __construct() 0 14 3
A getItemByIndex() 0 7 2
A disableDefaultControlMappings() 0 3 1
A addItems() 0 7 2
B display() 0 31 9
A redraw() 0 8 2
A setItems() 0 6 1
A executeAsSelected() 0 6 1
A getTitle() 0 3 1
A open() 0 13 3
A confirm() 0 9 1
A getStyle() 0 3 1
A tearDownTerminal() 0 6 1
A removeCustomControlMapping() 0 7 2
A getItems() 0 3 1
A canSelect() 0 3 1
A addCustomControlMappings() 0 4 2
A removeItem() 0 14 3
A close() 0 10 2
A selectFirstItem() 0 7 4
B moveSelectionHorizontally() 0 28 7

How to fix   Complexity   

Complex Class

Complex classes like CliMenu often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CliMenu, and based on these observations, apply Extract Interface, too.

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