Completed
Pull Request — master (#194)
by
unknown
02:23
created

CliMenu::guardSingleLine()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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