Completed
Pull Request — master (#205)
by Aydin
02:11
created

CliMenu::getRadioStyle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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