Completed
Pull Request — master (#127)
by Aydin
01:56
created

CliMenu::configureTerminal()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
cc 1
eloc 6
nc 1
nop 0
1
<?php
2
3
namespace PhpSchool\CliMenu;
4
5
use PhpSchool\CliMenu\Dialogue\NumberInput;
6
use PhpSchool\CliMenu\Exception\InvalidInstantiationException;
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\SplitItem;
16
use PhpSchool\CliMenu\MenuItem\StaticItem;
17
use PhpSchool\CliMenu\Dialogue\Confirm;
18
use PhpSchool\CliMenu\Dialogue\Flash;
19
use PhpSchool\CliMenu\Terminal\TerminalFactory;
20
use PhpSchool\CliMenu\Util\StringUtil as s;
21
use PhpSchool\Terminal\Exception\NotInteractiveTerminal;
22
use PhpSchool\Terminal\InputCharacter;
23
use PhpSchool\Terminal\NonCanonicalReader;
24
use PhpSchool\Terminal\Terminal;
25
use PhpSchool\Terminal\TerminalReader;
26
27
/**
28
 * @author Michael Woodward <[email protected]>
29
 */
30
class CliMenu
31
{
32
    /**
33
     * @var Terminal
34
     */
35
    protected $terminal;
36
37
    /**
38
     * @var MenuStyle
39
     */
40
    protected $style;
41
42
    /**
43
     * @var ?string
44
     */
45
    protected $title;
46
47
    /**
48
     * @var MenuItemInterface[]
49
     */
50
    protected $items = [];
51
52
    /**
53
     * @var int
54
     */
55
    protected $selectedItem;
56
57
    /**
58
     * @var bool
59
     */
60
    protected $open = false;
61
62
    /**
63
     * @var CliMenu|null
64
     */
65
    protected $parent;
66
67
    /**
68
     * @var array
69
     */
70
    protected $defaultControlMappings = [
71
        '^P' => InputCharacter::UP,
72
        'k'  => InputCharacter::UP,
73
        '^K' => InputCharacter::DOWN,
74
        'j'  => InputCharacter::DOWN,
75
        "\r" => InputCharacter::ENTER,
76
        ' '  => InputCharacter::ENTER,
77
        'l'  => InputCharacter::LEFT,
78
        'm'  => InputCharacter::RIGHT,
79
    ];
80
81
    /**
82
     * @var array
83
     */
84
    protected $customControlMappings = [];
85
86
    /**
87
     * @var Frame
88
     */
89
    private $currentFrame;
90
91
    public function __construct(
92
        ?string $title,
93
        array $items,
94
        Terminal $terminal = null,
95
        MenuStyle $style = null
96
    ) {
97
        $this->title      = $title;
98
        $this->items      = $items;
99
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
100
        $this->style      = $style ?: new MenuStyle($this->terminal);
101
102
        $this->selectFirstItem();
103
    }
104
105
    /**
106
     * Configure the terminal to work with CliMenu
107
     */
108
    protected function configureTerminal() : void
109
    {
110
        $this->assertTerminalIsValidTTY();
111
112
        $this->terminal->disableCanonicalMode();
113
        $this->terminal->disableEchoBack();
114
        $this->terminal->disableCursor();
115
        $this->terminal->clear();
116
    }
117
118
    /**
119
     * Revert changes made to the terminal
120
     */
121
    protected function tearDownTerminal() : void
122
    {
123
        $this->terminal->restoreOriginalConfiguration();
124
        $this->terminal->enableCursor();
125
    }
126
127
    private function assertTerminalIsValidTTY() : void
128
    {
129
        if (!$this->terminal->isInteractive()) {
130
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
131
        }
132
    }
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
        if (count($this->items) === 1) {
163
            $this->selectFirstItem();
164
        }
165
    }
166
167
    /**
168
     * Add multiple Items to the menu
169
     */
170
    public function addItems(array $items) : void
171
    {
172
        foreach ($items as $item) {
173
            $this->items[] = $item;
174
        }
175
176
        if (count($this->items) === count($items)) {
177
            $this->selectFirstItem();
178
        }
179
    }
180
181
    /**
182
     * Set Items of the menu
183
     */
184
    public function setItems(array $items) : void
185
    {
186
        $this->items = $items;
187
188
        $this->selectFirstItem();
189
    }
190
191
    /**
192
     * Set the selected pointer to the first selectable item
193
     */
194
    private function selectFirstItem() : void
195
    {
196
        foreach ($this->items as $key => $item) {
197
            if ($item->canSelect()) {
198
                $this->selectedItem = $key;
199
                break;
200
            }
201
        }
202
    }
203
204
    /**
205
     * Adds a custom control mapping
206
     */
207
    public function addCustomControlMapping(string $input, callable $callable) : void
208
    {
209
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
210
            throw new \InvalidArgumentException('Cannot rebind this input');
211
        }
212
213
        $this->customControlMappings[$input] = $callable;
214
    }
215
216
    /**
217
     * Shorthand function to add multiple custom control mapping at once
218
     */
219
    public function addCustomControlMappings(array $map) : void
220
    {
221
        foreach ($map as $input => $callable) {
222
            $this->addCustomControlMapping($input, $callable);
223
        }
224
    }
225
226
    /**
227
     * Removes a custom control mapping
228
     */
229
    public function removeCustomControlMapping(string $input) : void
230
    {
231
        if (!isset($this->customControlMappings[$input])) {
232
            throw new \InvalidArgumentException('This input is not registered');
233
        }
234
235
        unset($this->customControlMappings[$input]);
236
    }
237
238
    /**
239
     * Display menu and capture input
240
     */
241
    private function display() : void
242
    {
243
        $this->draw();
244
245
        $reader = new NonCanonicalReader($this->terminal);
246
        $reader->addControlMappings($this->defaultControlMappings);
247
248
        while ($this->isOpen() && $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
        do {
283
            $direction === 'UP'
284
                ? $this->selectedItem--
285
                : $this->selectedItem++;
286
287
            if (!array_key_exists($this->selectedItem, $this->items)) {
288
                $this->selectedItem  = $direction === 'UP'
289
                    ? end($itemKeys)
290
                    : reset($itemKeys);
291
            }
292
        } while (!$this->canSelect());
293
    }
294
295
    /**
296
     * Move the selection in a given direction, left / right
297
     */
298
    protected function moveSelectionHorizontally(string $direction) : void
299
    {
300
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
301
            return;
302
        }
303
304
        /** @var SplitItem $item */
305
        $item = $this->items[$this->selectedItem];
306
        $itemKeys = array_keys($item->getItems());
307
        $selectedItemIndex = $item->getSelectedItemIndex();
308
309
        do {
310
            $direction === 'LEFT'
311
                ? $selectedItemIndex--
312
                : $selectedItemIndex++;
313
314
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
315
                $selectedItemIndex = $direction === 'LEFT'
316
                    ? end($itemKeys)
317
                    : reset($itemKeys);
318
            }
319
        } while (!$item->canSelectIndex($selectedItemIndex));
320
        
321
        $item->setSelectedItemIndex($selectedItemIndex);
322
    }
323
324
    /**
325
     * Can the currently selected item actually be selected?
326
     *
327
     * For example:
328
     *  selectable item -> yes
329
     *  static item -> no
330
     *  split item with only static items -> no
331
     *  split item with at least one selectable item -> yes
332
     *
333
     * @return bool
334
     */
335
    private function canSelect() : bool
336
    {
337
        return $this->items[$this->selectedItem]->canSelect();
338
    }
339
340
    /**
341
     * Retrieve the item the user actually selected
342
     *
343
     */
344
    public function getSelectedItem() : MenuItemInterface
345
    {
346
        $item = $this->items[$this->selectedItem];
347
        return $item instanceof SplitItem
348
            ? $item->getSelectedItem()
349
            : $item;
350
    }
351
352
    /**
353
     * Execute the current item
354
     */
355
    protected function executeCurrentItem() : void
356
    {
357
        $item = $this->getSelectedItem();
358
359
        if ($item->canSelect()) {
360
            $callable = $item->getSelectAction();
361
            $callable($this);
362
        }
363
    }
364
365
    /**
366
     * If true we clear the whole terminal screen, useful
367
     * for example when reducing the width of the menu, to not
368
     * leave leftovers of the previous wider menu.
369
     *
370
     * Redraw the menu
371
     */
372
    public function redraw(bool $clear = false) : void
373
    {
374
        if ($clear) {
375
            $this->terminal->clear();
376
        }
377
378
        $this->assertOpen();
379
        $this->draw();
380
    }
381
382
    private function assertOpen() : void
383
    {
384
        if (!$this->isOpen()) {
385
            throw new MenuNotOpenException;
386
        }
387
    }
388
389
    /**
390
     * Draw the menu to STDOUT
391
     */
392
    protected function draw() : void
393
    {
394
        $frame = new Frame;
395
396
        $frame->newLine(2);
397
398
        if ($this->style->getBorderTopWidth() > 0) {
399
            $frame->addRows($this->style->getBorderTopRows());
400
        }
401
402
        if ($this->style->getPaddingTopBottom() > 0) {
403
            $frame->addRows($this->style->getPaddingTopBottomRows());
404
        }
405
406
        if ($this->title) {
407
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
408
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
409
        }
410
411
        array_map(function ($item, $index) use ($frame) {
412
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
413
        }, $this->items, array_keys($this->items));
414
415
416
        if ($this->style->getPaddingTopBottom() > 0) {
417
            $frame->addRows($this->style->getPaddingTopBottomRows());
418
        }
419
420
        if ($this->style->getBorderBottomWidth() > 0) {
421
            $frame->addRows($this->style->getBorderBottomRows());
422
        }
423
424
        $frame->newLine(2);
425
426
        $this->terminal->moveCursorToTop();
427
        foreach ($frame->getRows() as $row) {
428
            if ($row == "\n") {
429
                $this->terminal->clearLine();
430
            }
431
            $this->terminal->write($row);
432
        }
433
        $this->terminal->clearDown();
434
435
        $this->currentFrame = $frame;
436
    }
437
438
    /**
439
     * Draw a menu item
440
     */
441
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
442
    {
443
        $rows = $item->getRows($this->style, $selected);
444
        
445
        if ($item instanceof SplitItem) {
446
            $selected = false;
447
        }
448
449
        $invertedColoursSetCode = $selected
450
            ? $this->style->getInvertedColoursSetCode()
451
            : '';
452
        $invertedColoursUnsetCode = $selected
453
            ? $this->style->getInvertedColoursUnsetCode()
454
            : '';
455
456
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
457
            $borderColour = $this->style->getBorderColourCode();
458
        } else {
459
            $borderColour = '';
460
        }
461
462
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
463
            return sprintf(
464
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
465
                str_repeat(' ', $this->style->getMargin()),
466
                $borderColour,
467
                str_repeat(' ', $this->style->getBorderLeftWidth()),
468
                $this->style->getColoursSetCode(),
469
                $invertedColoursSetCode,
470
                str_repeat(' ', $this->style->getPaddingLeftRight()),
471
                $row,
472
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
473
                $invertedColoursUnsetCode,
474
                $borderColour,
475
                str_repeat(' ', $this->style->getBorderRightWidth()),
476
                $this->style->getColoursResetCode()
477
            );
478
        }, $rows);
479
    }
480
481
    /**
482
     * @throws InvalidTerminalException
483
     */
484
    public function open() : void
485
    {
486
        if ($this->isOpen()) {
487
            return;
488
        }
489
490
        $this->configureTerminal();
491
        $this->open = true;
492
        $this->display();
493
    }
494
495
    /**
496
     * Close the menu
497
     *
498
     * @throws InvalidTerminalException
499
     */
500
    public function close() : void
501
    {
502
        $menu = $this;
503
504
        do {
505
            $menu->closeThis();
506
            $menu = $menu->getParent();
507
        } while (null !== $menu);
508
509
        $this->tearDownTerminal();
510
    }
511
512
    public function closeThis() : void
513
    {
514
        $this->terminal->clean();
515
        $this->terminal->moveCursorToTop();
516
        $this->open = false;
517
    }
518
519
    /**
520
     * @return MenuItemInterface[]
521
     */
522
    public function getItems() : array
523
    {
524
        return $this->items;
525
    }
526
527
    public function removeItem(MenuItemInterface $item) : void
528
    {
529
        $key = array_search($item, $this->items, true);
530
531
        if (false === $key) {
532
            throw new \InvalidArgumentException('Item does not exist in menu');
533
        }
534
535
        unset($this->items[$key]);
536
        $this->items = array_values($this->items);
537
    }
538
539
    public function getStyle() : MenuStyle
540
    {
541
        return $this->style;
542
    }
543
544
    public function getCurrentFrame() : Frame
545
    {
546
        return $this->currentFrame;
547
    }
548
549
    public function flash(string $text, MenuStyle $style = null) : Flash
550
    {
551
        $this->guardSingleLine($text);
552
553
        $style = $style ?? (new MenuStyle($this->terminal))
554
            ->setBg('yellow')
555
            ->setFg('red');
556
557
        return new Flash($this, $style, $this->terminal, $text);
558
    }
559
560
    public function confirm(string $text, MenuStyle $style = null) : Confirm
561
    {
562
        $this->guardSingleLine($text);
563
564
        $style = $style ?? (new MenuStyle($this->terminal))
565
            ->setBg('yellow')
566
            ->setFg('red');
567
568
        return new Confirm($this, $style, $this->terminal, $text);
569
    }
570
571
    public function askNumber(MenuStyle $style = null) : Number
572
    {
573
        $this->assertOpen();
574
575
        $style = $style ?? (new MenuStyle($this->terminal))
576
            ->setBg('yellow')
577
            ->setFg('red');
578
579
        return new Number(new InputIO($this, $this->terminal), $style);
580
    }
581
582
    public function askText(MenuStyle $style = null) : Text
583
    {
584
        $this->assertOpen();
585
586
        $style = $style ?? (new MenuStyle($this->terminal))
587
            ->setBg('yellow')
588
            ->setFg('red');
589
590
        return new Text(new InputIO($this, $this->terminal), $style);
591
    }
592
593
    public function askPassword(MenuStyle $style = null) : Password
594
    {
595
        $this->assertOpen();
596
597
        $style = $style ?? (new MenuStyle($this->terminal))
598
            ->setBg('yellow')
599
            ->setFg('red');
600
601
        return new Password(new InputIO($this, $this->terminal), $style);
602
    }
603
604
    private function guardSingleLine($text) : void
605
    {
606
        if (strpos($text, "\n") !== false) {
607
            throw new \InvalidArgumentException;
608
        }
609
    }
610
}
611