Completed
Push — master ( 29ebfa...33ac5c )
by Aydin
15s queued 11s
created

CliMenu::disableDefaultControlMappings()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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