Completed
Push — master ( 9b16a2...d80c74 )
by Aydin
30s queued 15s
created

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