Completed
Push — master ( e920ad...bc045d )
by Aydin
01:58
created

src/CliMenu.php (3 issues)

1
<?php
2
3
namespace PhpSchool\CliMenu;
4
5
use PhpSchool\CliMenu\Dialogue\NumberInput;
0 ignored issues
show
The type PhpSchool\CliMenu\Dialogue\NumberInput was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
6
use PhpSchool\CliMenu\Exception\InvalidInstantiationException;
0 ignored issues
show
The type PhpSchool\CliMenu\Except...dInstantiationException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
7
use PhpSchool\CliMenu\Exception\InvalidTerminalException;
8
use PhpSchool\CliMenu\Exception\MenuNotOpenException;
9
use PhpSchool\CliMenu\Input\InputIO;
10
use PhpSchool\CliMenu\Input\Number;
11
use PhpSchool\CliMenu\Input\Password;
12
use PhpSchool\CliMenu\Input\Text;
13
use PhpSchool\CliMenu\MenuItem\LineBreakItem;
14
use PhpSchool\CliMenu\MenuItem\MenuItemInterface;
15
use PhpSchool\CliMenu\MenuItem\MenuMenuItem;
16
use PhpSchool\CliMenu\MenuItem\SplitItem;
17
use PhpSchool\CliMenu\MenuItem\StaticItem;
18
use PhpSchool\CliMenu\Dialogue\Confirm;
19
use PhpSchool\CliMenu\Dialogue\Flash;
20
use PhpSchool\CliMenu\Terminal\TerminalFactory;
21
use PhpSchool\CliMenu\Util\StringUtil as s;
22
use PhpSchool\Terminal\Exception\NotInteractiveTerminal;
23
use PhpSchool\Terminal\InputCharacter;
24
use PhpSchool\Terminal\NonCanonicalReader;
25
use PhpSchool\Terminal\Terminal;
26
use PhpSchool\Terminal\TerminalReader;
0 ignored issues
show
The type PhpSchool\Terminal\TerminalReader was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
27
28
/**
29
 * @author Michael Woodward <[email protected]>
30
 */
31
class CliMenu
32
{
33
    /**
34
     * @var Terminal
35
     */
36
    protected $terminal;
37
38
    /**
39
     * @var MenuStyle
40
     */
41
    protected $style;
42
43
    /**
44
     * @var ?string
45
     */
46
    protected $title;
47
48
    /**
49
     * @var MenuItemInterface[]
50
     */
51
    protected $items = [];
52
53
    /**
54
     * @var int|null
55
     */
56
    protected $selectedItem;
57
58
    /**
59
     * @var bool
60
     */
61
    protected $open = false;
62
63
    /**
64
     * @var CliMenu|null
65
     */
66
    protected $parent;
67
68
    /**
69
     * @var array
70
     */
71
    protected $defaultControlMappings = [
72
        '^P' => InputCharacter::UP,
73
        'k'  => InputCharacter::UP,
74
        '^K' => InputCharacter::DOWN,
75
        'j'  => InputCharacter::DOWN,
76
        "\r" => InputCharacter::ENTER,
77
        ' '  => InputCharacter::ENTER,
78
        'l'  => InputCharacter::LEFT,
79
        'm'  => InputCharacter::RIGHT,
80
    ];
81
82
    /**
83
     * @var array
84
     */
85
    protected $customControlMappings = [];
86
87
    /**
88
     * @var Frame
89
     */
90
    private $currentFrame;
91
92
    public function __construct(
93
        ?string $title,
94
        array $items,
95
        Terminal $terminal = null,
96
        MenuStyle $style = null
97
    ) {
98
        $this->title      = $title;
99
        $this->items      = $items;
100
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
101
        $this->style      = $style ?: new MenuStyle($this->terminal);
102
103
        $this->selectFirstItem();
104
    }
105
106
    /**
107
     * Configure the terminal to work with CliMenu
108
     */
109
    protected function configureTerminal() : void
110
    {
111
        $this->assertTerminalIsValidTTY();
112
113
        $this->terminal->disableCanonicalMode();
114
        $this->terminal->disableEchoBack();
115
        $this->terminal->disableCursor();
116
        $this->terminal->clear();
117
    }
118
119
    /**
120
     * Revert changes made to the terminal
121
     */
122
    protected function tearDownTerminal() : void
123
    {
124
        $this->terminal->restoreOriginalConfiguration();
125
        $this->terminal->enableCursor();
126
    }
127
128
    private function assertTerminalIsValidTTY() : void
129
    {
130
        if (!$this->terminal->isInteractive()) {
131
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
132
        }
133
    }
134
135
    public function setTitle(string $title) : void
136
    {
137
        $this->title = $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
     * Adds a custom control mapping
210
     */
211
    public function addCustomControlMapping(string $input, callable $callable) : void
212
    {
213
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
214
            throw new \InvalidArgumentException('Cannot rebind this input');
215
        }
216
217
        $this->customControlMappings[$input] = $callable;
218
    }
219
220
    /**
221
     * Shorthand function to add multiple custom control mapping at once
222
     */
223
    public function addCustomControlMappings(array $map) : void
224
    {
225
        foreach ($map as $input => $callable) {
226
            $this->addCustomControlMapping($input, $callable);
227
        }
228
    }
229
230
    /**
231
     * Removes a custom control mapping
232
     */
233
    public function removeCustomControlMapping(string $input) : void
234
    {
235
        if (!isset($this->customControlMappings[$input])) {
236
            throw new \InvalidArgumentException('This input is not registered');
237
        }
238
239
        unset($this->customControlMappings[$input]);
240
    }
241
242
    /**
243
     * Display menu and capture input
244
     */
245
    private function display() : void
246
    {
247
        $this->draw();
248
249
        $reader = new NonCanonicalReader($this->terminal);
250
        $reader->addControlMappings($this->defaultControlMappings);
251
252
        while ($this->isOpen() && $char = $reader->readCharacter()) {
253
            if (!$char->isHandledControl()) {
254
                $rawChar = $char->get();
255
                if (isset($this->customControlMappings[$rawChar])) {
256
                    $this->customControlMappings[$rawChar]($this);
257
                }
258
                continue;
259
            }
260
261
            switch ($char->getControl()) {
262
                case InputCharacter::UP:
263
                case InputCharacter::DOWN:
264
                    $this->moveSelectionVertically($char->getControl());
265
                    $this->draw();
266
                    break;
267
                case InputCharacter::LEFT:
268
                case InputCharacter::RIGHT:
269
                    $this->moveSelectionHorizontally($char->getControl());
270
                    $this->draw();
271
                    break;
272
                case InputCharacter::ENTER:
273
                    $this->executeCurrentItem();
274
                    break;
275
            }
276
        }
277
    }
278
279
    /**
280
     * Move the selection in a given direction, up / down
281
     */
282
    protected function moveSelectionVertically(string $direction) : void
283
    {
284
        $itemKeys = array_keys($this->items);
285
286
        $increments = 0;
287
288
        do {
289
            $increments++;
290
291
            if ($increments > count($itemKeys)) {
292
                //full cycle detected, there must be no selected items
293
                //in the menu, so stop trying to select one.
294
                return;
295
            }
296
297
            $direction === 'UP'
298
                ? $this->selectedItem--
299
                : $this->selectedItem++;
300
301
            if (!array_key_exists($this->selectedItem, $this->items)) {
302
                $this->selectedItem  = $direction === 'UP'
303
                    ? end($itemKeys)
304
                    : reset($itemKeys);
305
            }
306
        } while (!$this->canSelect());
307
    }
308
309
    /**
310
     * Move the selection in a given direction, left / right
311
     */
312
    protected function moveSelectionHorizontally(string $direction) : void
313
    {
314
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
315
            return;
316
        }
317
318
        /** @var SplitItem $item */
319
        $item = $this->items[$this->selectedItem];
320
        $itemKeys = array_keys($item->getItems());
321
        $selectedItemIndex = $item->getSelectedItemIndex();
322
323
        do {
324
            $direction === 'LEFT'
325
                ? $selectedItemIndex--
326
                : $selectedItemIndex++;
327
328
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
329
                $selectedItemIndex = $direction === 'LEFT'
330
                    ? end($itemKeys)
331
                    : reset($itemKeys);
332
            }
333
        } while (!$item->canSelectIndex($selectedItemIndex));
334
        
335
        $item->setSelectedItemIndex($selectedItemIndex);
336
    }
337
338
    /**
339
     * Can the currently selected item actually be selected?
340
     *
341
     * For example:
342
     *  selectable item -> yes
343
     *  static item -> no
344
     *  split item with only static items -> no
345
     *  split item with at least one selectable item -> yes
346
     *
347
     * @return bool
348
     */
349
    private function canSelect() : bool
350
    {
351
        return $this->items[$this->selectedItem]->canSelect();
352
    }
353
354
    /**
355
     * Retrieve the item the user actually selected
356
     *
357
     */
358
    public function getSelectedItem() : MenuItemInterface
359
    {
360
        if (null === $this->selectedItem) {
361
            throw new \RuntimeException('No selected item');
362
        }
363
364
        $item = $this->items[$this->selectedItem];
365
        return $item instanceof SplitItem
366
            ? $item->getSelectedItem()
367
            : $item;
368
    }
369
370
    /**
371
     * Execute the current item
372
     */
373
    protected function executeCurrentItem() : void
374
    {
375
        $item = $this->getSelectedItem();
376
377
        if ($item->canSelect()) {
378
            $callable = $item->getSelectAction();
379
            $callable($this);
380
        }
381
    }
382
383
    /**
384
     * If true we clear the whole terminal screen, useful
385
     * for example when reducing the width of the menu, to not
386
     * leave leftovers of the previous wider menu.
387
     *
388
     * Redraw the menu
389
     */
390
    public function redraw(bool $clear = false) : void
391
    {
392
        if ($clear) {
393
            $this->terminal->clear();
394
        }
395
396
        $this->assertOpen();
397
        $this->draw();
398
    }
399
400
    private function assertOpen() : void
401
    {
402
        if (!$this->isOpen()) {
403
            throw new MenuNotOpenException;
404
        }
405
    }
406
407
    /**
408
     * Draw the menu to STDOUT
409
     */
410
    protected function draw() : void
411
    {
412
        $frame = new Frame;
413
414
        $frame->newLine(2);
415
416
        if ($this->style->getBorderTopWidth() > 0) {
417
            $frame->addRows($this->style->getBorderTopRows());
418
        }
419
420
        if ($this->style->getPaddingTopBottom() > 0) {
421
            $frame->addRows($this->style->getPaddingTopBottomRows());
422
        }
423
424
        if ($this->title) {
425
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
426
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
427
        }
428
429
        array_map(function ($item, $index) use ($frame) {
430
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
431
        }, $this->items, array_keys($this->items));
432
433
434
        if ($this->style->getPaddingTopBottom() > 0) {
435
            $frame->addRows($this->style->getPaddingTopBottomRows());
436
        }
437
438
        if ($this->style->getBorderBottomWidth() > 0) {
439
            $frame->addRows($this->style->getBorderBottomRows());
440
        }
441
442
        $frame->newLine(2);
443
444
        $this->terminal->moveCursorToTop();
445
        foreach ($frame->getRows() as $row) {
446
            if ($row == "\n") {
447
                $this->terminal->clearLine();
448
            }
449
            $this->terminal->write($row);
450
        }
451
        $this->terminal->clearDown();
452
453
        $this->currentFrame = $frame;
454
    }
455
456
    /**
457
     * Draw a menu item
458
     */
459
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
460
    {
461
        $rows = $item->getRows($this->style, $selected);
462
        
463
        if ($item instanceof SplitItem) {
464
            $selected = false;
465
        }
466
467
        $invertedColoursSetCode = $selected
468
            ? $this->style->getInvertedColoursSetCode()
469
            : '';
470
        $invertedColoursUnsetCode = $selected
471
            ? $this->style->getInvertedColoursUnsetCode()
472
            : '';
473
474
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
475
            $borderColour = $this->style->getBorderColourCode();
476
        } else {
477
            $borderColour = '';
478
        }
479
480
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
481
            return sprintf(
482
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
483
                str_repeat(' ', $this->style->getMargin()),
484
                $borderColour,
485
                str_repeat(' ', $this->style->getBorderLeftWidth()),
486
                $this->style->getColoursSetCode(),
487
                $invertedColoursSetCode,
488
                str_repeat(' ', $this->style->getPaddingLeftRight()),
489
                $row,
490
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
491
                $invertedColoursUnsetCode,
492
                $borderColour,
493
                str_repeat(' ', $this->style->getBorderRightWidth()),
494
                $this->style->getColoursResetCode()
495
            );
496
        }, $rows);
497
    }
498
499
    /**
500
     * @throws InvalidTerminalException
501
     */
502
    public function open() : void
503
    {
504
        if ($this->isOpen()) {
505
            return;
506
        }
507
        
508
        if (count($this->items) === 0) {
509
            throw new \RuntimeException('Menu must have at least 1 item before it can be opened');
510
        }
511
512
        $this->configureTerminal();
513
        $this->open = true;
514
        $this->display();
515
    }
516
517
    /**
518
     * Close the menu
519
     *
520
     * @throws InvalidTerminalException
521
     */
522
    public function close() : void
523
    {
524
        $menu = $this;
525
526
        do {
527
            $menu->closeThis();
528
            $menu = $menu->getParent();
529
        } while (null !== $menu);
530
531
        $this->tearDownTerminal();
532
    }
533
534
    public function closeThis() : void
535
    {
536
        $this->terminal->clean();
537
        $this->terminal->moveCursorToTop();
538
        $this->open = false;
539
    }
540
541
    /**
542
     * @return MenuItemInterface[]
543
     */
544
    public function getItems() : array
545
    {
546
        return $this->items;
547
    }
548
549
    public function removeItem(MenuItemInterface $item) : void
550
    {
551
        $key = array_search($item, $this->items, true);
552
553
        if (false === $key) {
554
            throw new \InvalidArgumentException('Item does not exist in menu');
555
        }
556
557
        unset($this->items[$key]);
558
        $this->items = array_values($this->items);
559
560
        if ($this->selectedItem === $key) {
561
            $this->selectedItem = null;
562
            $this->selectFirstItem();
563
        }
564
    }
565
566
    public function getStyle() : MenuStyle
567
    {
568
        return $this->style;
569
    }
570
571
    public function setStyle(MenuStyle $style) : void
572
    {
573
        $this->style = $style;
574
    }
575
576
    public function getCurrentFrame() : Frame
577
    {
578
        return $this->currentFrame;
579
    }
580
581
    public function flash(string $text, MenuStyle $style = null) : Flash
582
    {
583
        $this->guardSingleLine($text);
584
585
        $style = $style ?? (new MenuStyle($this->terminal))
586
            ->setBg('yellow')
587
            ->setFg('red');
588
589
        return new Flash($this, $style, $this->terminal, $text);
590
    }
591
592
    public function confirm(string $text, MenuStyle $style = null) : Confirm
593
    {
594
        $this->guardSingleLine($text);
595
596
        $style = $style ?? (new MenuStyle($this->terminal))
597
            ->setBg('yellow')
598
            ->setFg('red');
599
600
        return new Confirm($this, $style, $this->terminal, $text);
601
    }
602
603
    public function askNumber(MenuStyle $style = null) : Number
604
    {
605
        $this->assertOpen();
606
607
        $style = $style ?? (new MenuStyle($this->terminal))
608
            ->setBg('yellow')
609
            ->setFg('red');
610
611
        return new Number(new InputIO($this, $this->terminal), $style);
612
    }
613
614
    public function askText(MenuStyle $style = null) : Text
615
    {
616
        $this->assertOpen();
617
618
        $style = $style ?? (new MenuStyle($this->terminal))
619
            ->setBg('yellow')
620
            ->setFg('red');
621
622
        return new Text(new InputIO($this, $this->terminal), $style);
623
    }
624
625
    public function askPassword(MenuStyle $style = null) : Password
626
    {
627
        $this->assertOpen();
628
629
        $style = $style ?? (new MenuStyle($this->terminal))
630
            ->setBg('yellow')
631
            ->setFg('red');
632
633
        return new Password(new InputIO($this, $this->terminal), $style);
634
    }
635
636
    private function guardSingleLine($text) : void
637
    {
638
        if (strpos($text, "\n") !== false) {
639
            throw new \InvalidArgumentException;
640
        }
641
    }
642
}
643