Passed
Pull Request — master (#176)
by Aydin
01:49
created

CliMenu::setSelectedItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
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\Terminal\TerminalFactory;
18
use PhpSchool\CliMenu\Util\StringUtil as s;
19
use PhpSchool\Terminal\InputCharacter;
20
use PhpSchool\Terminal\NonCanonicalReader;
21
use PhpSchool\Terminal\Terminal;
22
23
/**
24
 * @author Michael Woodward <[email protected]>
25
 */
26
class CliMenu
27
{
28
    /**
29
     * @var Terminal
30
     */
31
    protected $terminal;
32
33
    /**
34
     * @var MenuStyle
35
     */
36
    protected $style;
37
38
    /**
39
     * @var ?string
40
     */
41
    protected $title;
42
43
    /**
44
     * @var MenuItemInterface[]
45
     */
46
    protected $items = [];
47
48
    /**
49
     * @var int|null
50
     */
51
    protected $selectedItem;
52
53
    /**
54
     * @var bool
55
     */
56
    protected $open = false;
57
58
    /**
59
     * @var CliMenu|null
60
     */
61
    protected $parent;
62
63
    /**
64
     * @var array
65
     */
66
    protected $defaultControlMappings = [
67
        '^P' => InputCharacter::UP,
68
        'k'  => InputCharacter::UP,
69
        '^K' => InputCharacter::DOWN,
70
        'j'  => InputCharacter::DOWN,
71
        "\r" => InputCharacter::ENTER,
72
        ' '  => InputCharacter::ENTER,
73
        'l'  => InputCharacter::LEFT,
74
        'm'  => InputCharacter::RIGHT,
75
    ];
76
77
    /**
78
     * @var array
79
     */
80
    protected $customControlMappings = [];
81
82
    /**
83
     * @var Frame
84
     */
85
    private $currentFrame;
86
87
    public function __construct(
88
        ?string $title,
89
        array $items,
90
        Terminal $terminal = null,
91
        MenuStyle $style = null
92
    ) {
93
        $this->title      = $title;
94
        $this->items      = $items;
95
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
96
        $this->style      = $style ?: new MenuStyle($this->terminal);
97
98
        $this->selectFirstItem();
99
    }
100
101
    /**
102
     * Configure the terminal to work with CliMenu
103
     */
104
    protected function configureTerminal() : void
105
    {
106
        $this->assertTerminalIsValidTTY();
107
108
        $this->terminal->disableCanonicalMode();
109
        $this->terminal->disableEchoBack();
110
        $this->terminal->disableCursor();
111
        $this->terminal->clear();
112
    }
113
114
    /**
115
     * Revert changes made to the terminal
116
     */
117
    protected function tearDownTerminal() : void
118
    {
119
        $this->terminal->restoreOriginalConfiguration();
120
        $this->terminal->enableCursor();
121
    }
122
123
    private function assertTerminalIsValidTTY() : void
124
    {
125
        if (!$this->terminal->isInteractive()) {
126
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
127
        }
128
    }
129
130
    public function setTitle(string $title) : void
131
    {
132
        $this->title = $title;
133
    }
134
135
    public function setParent(CliMenu $parent) : void
136
    {
137
        $this->parent = $parent;
138
    }
139
140
    public function getParent() : ?CliMenu
141
    {
142
        return $this->parent;
143
    }
144
145
    public function getTerminal() : Terminal
146
    {
147
        return $this->terminal;
148
    }
149
150
    public function isOpen() : bool
151
    {
152
        return $this->open;
153
    }
154
155
    /**
156
     * Add a new Item to the menu
157
     */
158
    public function addItem(MenuItemInterface $item) : void
159
    {
160
        $this->items[] = $item;
161
162
        $this->selectFirstItem();
163
    }
164
165
    /**
166
     * Add multiple Items to the menu
167
     */
168
    public function addItems(array $items) : void
169
    {
170
        foreach ($items as $item) {
171
            $this->items[] = $item;
172
        }
173
174
        $this->selectFirstItem();
175
    }
176
177
    /**
178
     * Set Items of the menu
179
     */
180
    public function setItems(array $items) : void
181
    {
182
        $this->selectedItem = null;
183
        $this->items = $items;
184
185
        $this->selectFirstItem();
186
    }
187
188
    /**
189
     * Set the selected pointer to the first selectable item
190
     */
191
    private function selectFirstItem() : void
192
    {
193
        if (null === $this->selectedItem) {
194
            foreach ($this->items as $key => $item) {
195
                if ($item->canSelect()) {
196
                    $this->selectedItem = $key;
197
                    break;
198
                }
199
            }
200
        }
201
    }
202
203
    /**
204
     * Disables the built-in VIM control mappings
205
     */
206
    public function disableDefaultControlMappings() : void
207
    {
208
        $this->defaultControlMappings = [];
209
    }
210
211
    /**
212
     * Set default control mappings
213
     */
214
    public function setDefaultControlMappings(array $defaultControlMappings) : void
215
    {
216
        $this->defaultControlMappings = $defaultControlMappings;
217
    }
218
219
    /**
220
     * Adds a custom control mapping
221
     */
222
    public function addCustomControlMapping(string $input, callable $callable) : void
223
    {
224
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
225
            throw new \InvalidArgumentException('Cannot rebind this input');
226
        }
227
228
        $this->customControlMappings[$input] = $callable;
229
    }
230
231
    /**
232
     * Shorthand function to add multiple custom control mapping at once
233
     */
234
    public function addCustomControlMappings(array $map) : void
235
    {
236
        foreach ($map as $input => $callable) {
237
            $this->addCustomControlMapping($input, $callable);
238
        }
239
    }
240
241
    /**
242
     * Removes a custom control mapping
243
     */
244
    public function removeCustomControlMapping(string $input) : void
245
    {
246
        if (!isset($this->customControlMappings[$input])) {
247
            throw new \InvalidArgumentException('This input is not registered');
248
        }
249
250
        unset($this->customControlMappings[$input]);
251
    }
252
253
    /**
254
     * Display menu and capture input
255
     */
256
    private function display() : void
257
    {
258
        $this->draw();
259
260
        $reader = new NonCanonicalReader($this->terminal);
261
        $reader->addControlMappings($this->defaultControlMappings);
262
263
        while ($this->isOpen()) {
264
            $char = $reader->readCharacter();
265
            if (!$char->isHandledControl()) {
266
                $rawChar = $char->get();
267
                if (isset($this->customControlMappings[$rawChar])) {
268
                    $this->customControlMappings[$rawChar]($this);
269
                }
270
                continue;
271
            }
272
273
            switch ($char->getControl()) {
274
                case InputCharacter::UP:
275
                case InputCharacter::DOWN:
276
                    $this->moveSelectionVertically($char->getControl());
277
                    $this->draw();
278
                    break;
279
                case InputCharacter::LEFT:
280
                case InputCharacter::RIGHT:
281
                    $this->moveSelectionHorizontally($char->getControl());
282
                    $this->draw();
283
                    break;
284
                case InputCharacter::ENTER:
285
                    $this->executeCurrentItem();
286
                    break;
287
            }
288
        }
289
    }
290
291
    /**
292
     * Move the selection in a given direction, up / down
293
     */
294
    protected function moveSelectionVertically(string $direction) : void
295
    {
296
        $itemKeys = array_keys($this->items);
297
298
        $increments = 0;
299
300
        do {
301
            $increments++;
302
303
            if ($increments > count($itemKeys)) {
304
                //full cycle detected, there must be no selected items
305
                //in the menu, so stop trying to select one.
306
                return;
307
            }
308
309
            $direction === 'UP'
310
                ? $this->selectedItem--
311
                : $this->selectedItem++;
312
313
            if ($this->selectedItem !== null && !array_key_exists($this->selectedItem, $this->items)) {
314
                $this->selectedItem  = $direction === 'UP'
315
                    ? end($itemKeys)
316
                    : reset($itemKeys);
317
            }
318
        } while (!$this->canSelect());
319
    }
320
321
    /**
322
     * Move the selection in a given direction, left / right
323
     */
324
    protected function moveSelectionHorizontally(string $direction) : void
325
    {
326
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
327
            return;
328
        }
329
330
        /** @var SplitItem $item */
331
        $item = $this->items[$this->selectedItem];
332
        $itemKeys = array_keys($item->getItems());
333
        $selectedItemIndex = $item->getSelectedItemIndex();
334
335
        do {
336
            $direction === 'LEFT'
337
                ? $selectedItemIndex--
338
                : $selectedItemIndex++;
339
340
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
341
                $selectedItemIndex = $direction === 'LEFT'
342
                    ? end($itemKeys)
343
                    : reset($itemKeys);
344
            }
345
        } while (!$item->canSelectIndex($selectedItemIndex));
346
        
347
        $item->setSelectedItemIndex($selectedItemIndex);
0 ignored issues
show
Bug introduced by
It seems like $selectedItemIndex can also be of type null; however, parameter $index of PhpSchool\CliMenu\MenuIt...:setSelectedItemIndex() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

347
        $item->setSelectedItemIndex(/** @scrutinizer ignore-type */ $selectedItemIndex);
Loading history...
348
    }
349
350
    /**
351
     * Can the currently selected item actually be selected?
352
     *
353
     * For example:
354
     *  selectable item -> yes
355
     *  static item -> no
356
     *  split item with only static items -> no
357
     *  split item with at least one selectable item -> yes
358
     *
359
     * @return bool
360
     */
361
    private function canSelect() : bool
362
    {
363
        return $this->items[$this->selectedItem]->canSelect();
364
    }
365
366
    /**
367
     * Retrieve the item the user actually selected
368
     *
369
     */
370
    public function getSelectedItem() : MenuItemInterface
371
    {
372
        if (null === $this->selectedItem) {
373
            throw new \RuntimeException('No selected item');
374
        }
375
376
        $item = $this->items[$this->selectedItem];
377
        return $item instanceof SplitItem
378
            ? $item->getSelectedItem()
379
            : $item;
380
    }
381
382
    public function setSelectedItem(MenuItemInterface $item) : void
383
    {
384
        $key = array_search($item, $this->items, true);
385
386
        if (false === $key) {
387
            throw new \InvalidArgumentException('Item does not exist in menu');
388
        }
389
390
        $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...
391
    }
392
393
    public function executeAsSelected(MenuItemInterface $item) : void
394
    {
395
        $current = $this->items[$this->selectedItem];
396
        $this->setSelectedItem($item);
397
        $this->executeCurrentItem();
398
        $this->setSelectedItem($current);
399
    }
400
401
    /**
402
     * Execute the current item
403
     */
404
    protected function executeCurrentItem() : void
405
    {
406
        $item = $this->getSelectedItem();
407
408
        if ($item->canSelect()) {
409
            $callable = $item->getSelectAction();
410
            if ($callable) {
411
                $callable($this);
412
            }
413
        }
414
    }
415
416
    /**
417
     * If true we clear the whole terminal screen, useful
418
     * for example when reducing the width of the menu, to not
419
     * leave leftovers of the previous wider menu.
420
     *
421
     * Redraw the menu
422
     */
423
    public function redraw(bool $clear = false) : void
424
    {
425
        if ($clear) {
426
            $this->terminal->clear();
427
        }
428
429
        $this->assertOpen();
430
        $this->draw();
431
    }
432
433
    private function assertOpen() : void
434
    {
435
        if (!$this->isOpen()) {
436
            throw new MenuNotOpenException;
437
        }
438
    }
439
440
    /**
441
     * Draw the menu to STDOUT
442
     */
443
    protected function draw() : void
444
    {
445
        $frame = new Frame;
446
447
        $frame->newLine(2);
448
449
        if ($this->style->getBorderTopWidth() > 0) {
450
            $frame->addRows($this->style->getBorderTopRows());
451
        }
452
453
        if ($this->style->getPaddingTopBottom() > 0) {
454
            $frame->addRows($this->style->getPaddingTopBottomRows());
455
        }
456
457
        if ($this->title) {
458
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
459
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
460
        }
461
462
        array_map(function ($item, $index) use ($frame) {
463
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
464
        }, $this->items, array_keys($this->items));
465
466
467
        if ($this->style->getPaddingTopBottom() > 0) {
468
            $frame->addRows($this->style->getPaddingTopBottomRows());
469
        }
470
471
        if ($this->style->getBorderBottomWidth() > 0) {
472
            $frame->addRows($this->style->getBorderBottomRows());
473
        }
474
475
        $frame->newLine(2);
476
477
        $this->terminal->moveCursorToTop();
478
        foreach ($frame->getRows() as $row) {
479
            if ($row == "\n") {
480
                $this->terminal->clearLine();
481
            }
482
            $this->terminal->write($row);
483
        }
484
        $this->terminal->clearDown();
485
486
        $this->currentFrame = $frame;
487
    }
488
489
    /**
490
     * Draw a menu item
491
     */
492
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
493
    {
494
        $rows = $item->getRows($this->style, $selected);
495
        
496
        if ($item instanceof SplitItem) {
497
            $selected = false;
498
        }
499
500
        $invertedColoursSetCode = $selected
501
            ? $this->style->getInvertedColoursSetCode()
502
            : '';
503
        $invertedColoursUnsetCode = $selected
504
            ? $this->style->getInvertedColoursUnsetCode()
505
            : '';
506
507
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
508
            $borderColour = $this->style->getBorderColourCode();
509
        } else {
510
            $borderColour = '';
511
        }
512
513
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
514
            return sprintf(
515
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
516
                str_repeat(' ', $this->style->getMargin()),
517
                $borderColour,
518
                str_repeat(' ', $this->style->getBorderLeftWidth()),
519
                $this->style->getColoursSetCode(),
520
                $invertedColoursSetCode,
521
                str_repeat(' ', $this->style->getPaddingLeftRight()),
522
                $row,
523
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
524
                $invertedColoursUnsetCode,
525
                $borderColour,
526
                str_repeat(' ', $this->style->getBorderRightWidth()),
527
                $this->style->getColoursResetCode()
528
            );
529
        }, $rows);
530
    }
531
532
    /**
533
     * @throws InvalidTerminalException
534
     */
535
    public function open() : void
536
    {
537
        if ($this->isOpen()) {
538
            return;
539
        }
540
        
541
        if (count($this->items) === 0) {
542
            throw new \RuntimeException('Menu must have at least 1 item before it can be opened');
543
        }
544
545
        $this->configureTerminal();
546
        $this->open = true;
547
        $this->display();
548
    }
549
550
    /**
551
     * Close the menu
552
     *
553
     * @throws InvalidTerminalException
554
     */
555
    public function close() : void
556
    {
557
        $menu = $this;
558
559
        do {
560
            $menu->closeThis();
561
            $menu = $menu->getParent();
562
        } while (null !== $menu);
563
564
        $this->tearDownTerminal();
565
    }
566
567
    public function closeThis() : void
568
    {
569
        $this->terminal->clean();
570
        $this->terminal->moveCursorToTop();
571
        $this->open = false;
572
    }
573
574
    /**
575
     * @return MenuItemInterface[]
576
     */
577
    public function getItems() : array
578
    {
579
        return $this->items;
580
    }
581
582
    public function removeItem(MenuItemInterface $item) : void
583
    {
584
        $key = array_search($item, $this->items, true);
585
586
        if (false === $key) {
587
            throw new \InvalidArgumentException('Item does not exist in menu');
588
        }
589
590
        unset($this->items[$key]);
591
        $this->items = array_values($this->items);
592
593
        if ($this->selectedItem === $key) {
594
            $this->selectedItem = null;
595
            $this->selectFirstItem();
596
        }
597
    }
598
599
    public function getStyle() : MenuStyle
600
    {
601
        return $this->style;
602
    }
603
604
    public function setStyle(MenuStyle $style) : void
605
    {
606
        $this->style = $style;
607
    }
608
609
    public function getCurrentFrame() : Frame
610
    {
611
        return $this->currentFrame;
612
    }
613
614
    public function flash(string $text, MenuStyle $style = null) : Flash
615
    {
616
        $this->guardSingleLine($text);
617
618
        $style = $style ?? (new MenuStyle($this->terminal))
619
            ->setBg('yellow')
620
            ->setFg('red');
621
622
        return new Flash($this, $style, $this->terminal, $text);
623
    }
624
625
    public function confirm(string $text, MenuStyle $style = null) : Confirm
626
    {
627
        $this->guardSingleLine($text);
628
629
        $style = $style ?? (new MenuStyle($this->terminal))
630
            ->setBg('yellow')
631
            ->setFg('red');
632
633
        return new Confirm($this, $style, $this->terminal, $text);
634
    }
635
636
    public function askNumber(MenuStyle $style = null) : Number
637
    {
638
        $this->assertOpen();
639
640
        $style = $style ?? (new MenuStyle($this->terminal))
641
            ->setBg('yellow')
642
            ->setFg('red');
643
644
        return new Number(new InputIO($this, $this->terminal), $style);
645
    }
646
647
    public function askText(MenuStyle $style = null) : Text
648
    {
649
        $this->assertOpen();
650
651
        $style = $style ?? (new MenuStyle($this->terminal))
652
            ->setBg('yellow')
653
            ->setFg('red');
654
655
        return new Text(new InputIO($this, $this->terminal), $style);
656
    }
657
658
    public function askPassword(MenuStyle $style = null) : Password
659
    {
660
        $this->assertOpen();
661
662
        $style = $style ?? (new MenuStyle($this->terminal))
663
            ->setBg('yellow')
664
            ->setFg('red');
665
666
        return new Password(new InputIO($this, $this->terminal), $style);
667
    }
668
669
    private function guardSingleLine($text) : void
670
    {
671
        if (strpos($text, "\n") !== false) {
672
            throw new \InvalidArgumentException;
673
        }
674
    }
675
}
676