Passed
Pull Request — master (#230)
by Aydin
01:56
created

CliMenu::askText()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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