Passed
Push — master ( 9e3d9f...33a1a9 )
by Aydin
01:50
created

CliMenu::moveSelectionVertically()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 14
nc 7
nop 1
dl 0
loc 23
rs 8.8333
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
     * Adds a custom control mapping
205
     */
206
    public function addCustomControlMapping(string $input, callable $callable) : void
207
    {
208
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
209
            throw new \InvalidArgumentException('Cannot rebind this input');
210
        }
211
212
        $this->customControlMappings[$input] = $callable;
213
    }
214
215
    /**
216
     * Shorthand function to add multiple custom control mapping at once
217
     */
218
    public function addCustomControlMappings(array $map) : void
219
    {
220
        foreach ($map as $input => $callable) {
221
            $this->addCustomControlMapping($input, $callable);
222
        }
223
    }
224
225
    /**
226
     * Removes a custom control mapping
227
     */
228
    public function removeCustomControlMapping(string $input) : void
229
    {
230
        if (!isset($this->customControlMappings[$input])) {
231
            throw new \InvalidArgumentException('This input is not registered');
232
        }
233
234
        unset($this->customControlMappings[$input]);
235
    }
236
237
    /**
238
     * Display menu and capture input
239
     */
240
    private function display() : void
241
    {
242
        $this->draw();
243
244
        $reader = new NonCanonicalReader($this->terminal);
245
        $reader->addControlMappings($this->defaultControlMappings);
246
247
        while ($this->isOpen()) {
248
            $char = $reader->readCharacter();
249
            if (!$char->isHandledControl()) {
250
                $rawChar = $char->get();
251
                if (isset($this->customControlMappings[$rawChar])) {
252
                    $this->customControlMappings[$rawChar]($this);
253
                }
254
                continue;
255
            }
256
257
            switch ($char->getControl()) {
258
                case InputCharacter::UP:
259
                case InputCharacter::DOWN:
260
                    $this->moveSelectionVertically($char->getControl());
261
                    $this->draw();
262
                    break;
263
                case InputCharacter::LEFT:
264
                case InputCharacter::RIGHT:
265
                    $this->moveSelectionHorizontally($char->getControl());
266
                    $this->draw();
267
                    break;
268
                case InputCharacter::ENTER:
269
                    $this->executeCurrentItem();
270
                    break;
271
            }
272
        }
273
    }
274
275
    /**
276
     * Move the selection in a given direction, up / down
277
     */
278
    protected function moveSelectionVertically(string $direction) : void
279
    {
280
        $itemKeys = array_keys($this->items);
281
282
        $increments = 0;
283
284
        do {
285
            $increments++;
286
287
            if ($increments > count($itemKeys)) {
288
                //full cycle detected, there must be no selected items
289
                //in the menu, so stop trying to select one.
290
                return;
291
            }
292
293
            $direction === 'UP'
294
                ? $this->selectedItem--
295
                : $this->selectedItem++;
296
297
            if ($this->selectedItem !== null && !array_key_exists($this->selectedItem, $this->items)) {
298
                $this->selectedItem  = $direction === 'UP'
299
                    ? end($itemKeys)
300
                    : reset($itemKeys);
301
            }
302
        } while (!$this->canSelect());
303
    }
304
305
    /**
306
     * Move the selection in a given direction, left / right
307
     */
308
    protected function moveSelectionHorizontally(string $direction) : void
309
    {
310
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
311
            return;
312
        }
313
314
        /** @var SplitItem $item */
315
        $item = $this->items[$this->selectedItem];
316
        $itemKeys = array_keys($item->getItems());
317
        $selectedItemIndex = $item->getSelectedItemIndex();
318
319
        do {
320
            $direction === 'LEFT'
321
                ? $selectedItemIndex--
322
                : $selectedItemIndex++;
323
324
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
325
                $selectedItemIndex = $direction === 'LEFT'
326
                    ? end($itemKeys)
327
                    : reset($itemKeys);
328
            }
329
        } while (!$item->canSelectIndex($selectedItemIndex));
330
        
331
        $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

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