Completed
Push — master ( 528484...9b16a2 )
by Aydin
15s queued 11s
created

CliMenu::moveSelectionHorizontally()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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