Completed
Push — master ( 5909ec...c2adaf )
by Aydin
13s
created

CliMenu::setStyle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 1
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\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;
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
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
        if (count($this->items) === 1) {
168
            $this->selectFirstItem();
169
        }
170
    }
171
172
    /**
173
     * Add multiple Items to the menu
174
     */
175
    public function addItems(array $items) : void
176
    {
177
        foreach ($items as $item) {
178
            $this->items[] = $item;
179
        }
180
181
        if (count($this->items) === count($items)) {
182
            $this->selectFirstItem();
183
        }
184
    }
185
186
    /**
187
     * Set Items of the menu
188
     */
189
    public function setItems(array $items) : void
190
    {
191
        $this->items = $items;
192
193
        $this->selectFirstItem();
194
    }
195
196
    /**
197
     * Set the selected pointer to the first selectable item
198
     */
199
    private function selectFirstItem() : void
200
    {
201
        foreach ($this->items as $key => $item) {
202
            if ($item->canSelect()) {
203
                $this->selectedItem = $key;
204
                break;
205
            }
206
        }
207
    }
208
209
    /**
210
     * Adds a custom control mapping
211
     */
212
    public function addCustomControlMapping(string $input, callable $callable) : void
213
    {
214
        if (isset($this->defaultControlMappings[$input]) || isset($this->customControlMappings[$input])) {
215
            throw new \InvalidArgumentException('Cannot rebind this input');
216
        }
217
218
        $this->customControlMappings[$input] = $callable;
219
    }
220
221
    /**
222
     * Shorthand function to add multiple custom control mapping at once
223
     */
224
    public function addCustomControlMappings(array $map) : void
225
    {
226
        foreach ($map as $input => $callable) {
227
            $this->addCustomControlMapping($input, $callable);
228
        }
229
    }
230
231
    /**
232
     * Removes a custom control mapping
233
     */
234
    public function removeCustomControlMapping(string $input) : void
235
    {
236
        if (!isset($this->customControlMappings[$input])) {
237
            throw new \InvalidArgumentException('This input is not registered');
238
        }
239
240
        unset($this->customControlMappings[$input]);
241
    }
242
243
    /**
244
     * Display menu and capture input
245
     */
246
    private function display() : void
247
    {
248
        $this->draw();
249
250
        $reader = new NonCanonicalReader($this->terminal);
251
        $reader->addControlMappings($this->defaultControlMappings);
252
253
        while ($this->isOpen() && $char = $reader->readCharacter()) {
254
            if (!$char->isHandledControl()) {
255
                $rawChar = $char->get();
256
                if (isset($this->customControlMappings[$rawChar])) {
257
                    $this->customControlMappings[$rawChar]($this);
258
                }
259
                continue;
260
            }
261
262
            switch ($char->getControl()) {
263
                case InputCharacter::UP:
264
                case InputCharacter::DOWN:
265
                    $this->moveSelectionVertically($char->getControl());
266
                    $this->draw();
267
                    break;
268
                case InputCharacter::LEFT:
269
                case InputCharacter::RIGHT:
270
                    $this->moveSelectionHorizontally($char->getControl());
271
                    $this->draw();
272
                    break;
273
                case InputCharacter::ENTER:
274
                    $this->executeCurrentItem();
275
                    break;
276
            }
277
        }
278
    }
279
280
    /**
281
     * Move the selection in a given direction, up / down
282
     */
283
    protected function moveSelectionVertically(string $direction) : void
284
    {
285
        $itemKeys = array_keys($this->items);
286
287
        do {
288
            $direction === 'UP'
289
                ? $this->selectedItem--
290
                : $this->selectedItem++;
291
292
            if (!array_key_exists($this->selectedItem, $this->items)) {
293
                $this->selectedItem  = $direction === 'UP'
294
                    ? end($itemKeys)
295
                    : reset($itemKeys);
296
            }
297
        } while (!$this->canSelect());
298
    }
299
300
    /**
301
     * Move the selection in a given direction, left / right
302
     */
303
    protected function moveSelectionHorizontally(string $direction) : void
304
    {
305
        if (!$this->items[$this->selectedItem] instanceof SplitItem) {
306
            return;
307
        }
308
309
        /** @var SplitItem $item */
310
        $item = $this->items[$this->selectedItem];
311
        $itemKeys = array_keys($item->getItems());
312
        $selectedItemIndex = $item->getSelectedItemIndex();
313
314
        do {
315
            $direction === 'LEFT'
316
                ? $selectedItemIndex--
317
                : $selectedItemIndex++;
318
319
            if (!array_key_exists($selectedItemIndex, $item->getItems())) {
320
                $selectedItemIndex = $direction === 'LEFT'
321
                    ? end($itemKeys)
322
                    : reset($itemKeys);
323
            }
324
        } while (!$item->canSelectIndex($selectedItemIndex));
325
        
326
        $item->setSelectedItemIndex($selectedItemIndex);
327
    }
328
329
    /**
330
     * Can the currently selected item actually be selected?
331
     *
332
     * For example:
333
     *  selectable item -> yes
334
     *  static item -> no
335
     *  split item with only static items -> no
336
     *  split item with at least one selectable item -> yes
337
     *
338
     * @return bool
339
     */
340
    private function canSelect() : bool
341
    {
342
        return $this->items[$this->selectedItem]->canSelect();
343
    }
344
345
    /**
346
     * Retrieve the item the user actually selected
347
     *
348
     */
349
    public function getSelectedItem() : MenuItemInterface
350
    {
351
        $item = $this->items[$this->selectedItem];
352
        return $item instanceof SplitItem
353
            ? $item->getSelectedItem()
354
            : $item;
355
    }
356
357
    /**
358
     * Execute the current item
359
     */
360
    protected function executeCurrentItem() : void
361
    {
362
        $item = $this->getSelectedItem();
363
364
        if ($item->canSelect()) {
365
            $callable = $item->getSelectAction();
366
            $callable($this);
367
        }
368
    }
369
370
    /**
371
     * If true we clear the whole terminal screen, useful
372
     * for example when reducing the width of the menu, to not
373
     * leave leftovers of the previous wider menu.
374
     *
375
     * Redraw the menu
376
     */
377
    public function redraw(bool $clear = false) : void
378
    {
379
        if ($clear) {
380
            $this->terminal->clear();
381
        }
382
383
        $this->assertOpen();
384
        $this->draw();
385
    }
386
387
    private function assertOpen() : void
388
    {
389
        if (!$this->isOpen()) {
390
            throw new MenuNotOpenException;
391
        }
392
    }
393
394
    /**
395
     * Draw the menu to STDOUT
396
     */
397
    protected function draw() : void
398
    {
399
        $frame = new Frame;
400
401
        $frame->newLine(2);
402
403
        if ($this->style->getBorderTopWidth() > 0) {
404
            $frame->addRows($this->style->getBorderTopRows());
405
        }
406
407
        if ($this->style->getPaddingTopBottom() > 0) {
408
            $frame->addRows($this->style->getPaddingTopBottomRows());
409
        }
410
411
        if ($this->title) {
412
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
413
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
414
        }
415
416
        array_map(function ($item, $index) use ($frame) {
417
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
418
        }, $this->items, array_keys($this->items));
419
420
421
        if ($this->style->getPaddingTopBottom() > 0) {
422
            $frame->addRows($this->style->getPaddingTopBottomRows());
423
        }
424
425
        if ($this->style->getBorderBottomWidth() > 0) {
426
            $frame->addRows($this->style->getBorderBottomRows());
427
        }
428
429
        $frame->newLine(2);
430
431
        $this->terminal->moveCursorToTop();
432
        foreach ($frame->getRows() as $row) {
433
            if ($row == "\n") {
434
                $this->terminal->clearLine();
435
            }
436
            $this->terminal->write($row);
437
        }
438
        $this->terminal->clearDown();
439
440
        $this->currentFrame = $frame;
441
    }
442
443
    /**
444
     * Draw a menu item
445
     */
446
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
447
    {
448
        $rows = $item->getRows($this->style, $selected);
449
        
450
        if ($item instanceof SplitItem) {
451
            $selected = false;
452
        }
453
454
        $invertedColoursSetCode = $selected
455
            ? $this->style->getInvertedColoursSetCode()
456
            : '';
457
        $invertedColoursUnsetCode = $selected
458
            ? $this->style->getInvertedColoursUnsetCode()
459
            : '';
460
461
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
462
            $borderColour = $this->style->getBorderColourCode();
463
        } else {
464
            $borderColour = '';
465
        }
466
467
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
468
            return sprintf(
469
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
470
                str_repeat(' ', $this->style->getMargin()),
471
                $borderColour,
472
                str_repeat(' ', $this->style->getBorderLeftWidth()),
473
                $this->style->getColoursSetCode(),
474
                $invertedColoursSetCode,
475
                str_repeat(' ', $this->style->getPaddingLeftRight()),
476
                $row,
477
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
478
                $invertedColoursUnsetCode,
479
                $borderColour,
480
                str_repeat(' ', $this->style->getBorderRightWidth()),
481
                $this->style->getColoursResetCode()
482
            );
483
        }, $rows);
484
    }
485
486
    /**
487
     * @throws InvalidTerminalException
488
     */
489
    public function open() : void
490
    {
491
        if ($this->isOpen()) {
492
            return;
493
        }
494
        
495
        if (count($this->items) === 0) {
496
            throw new \RuntimeException('Menu must have at least 1 item before it can be opened');
497
        }
498
499
        $this->configureTerminal();
500
        $this->open = true;
501
        $this->display();
502
    }
503
504
    /**
505
     * Close the menu
506
     *
507
     * @throws InvalidTerminalException
508
     */
509
    public function close() : void
510
    {
511
        $menu = $this;
512
513
        do {
514
            $menu->closeThis();
515
            $menu = $menu->getParent();
516
        } while (null !== $menu);
517
518
        $this->tearDownTerminal();
519
    }
520
521
    public function closeThis() : void
522
    {
523
        $this->terminal->clean();
524
        $this->terminal->moveCursorToTop();
525
        $this->open = false;
526
    }
527
528
    /**
529
     * @return MenuItemInterface[]
530
     */
531
    public function getItems() : array
532
    {
533
        return $this->items;
534
    }
535
536
    public function removeItem(MenuItemInterface $item) : void
537
    {
538
        $key = array_search($item, $this->items, true);
539
540
        if (false === $key) {
541
            throw new \InvalidArgumentException('Item does not exist in menu');
542
        }
543
544
        unset($this->items[$key]);
545
        $this->items = array_values($this->items);
546
    }
547
548
    public function getStyle() : MenuStyle
549
    {
550
        return $this->style;
551
    }
552
553
    public function setStyle(MenuStyle $style) : void
554
    {
555
        $this->style = $style;
556
    }
557
558
    public function getCurrentFrame() : Frame
559
    {
560
        return $this->currentFrame;
561
    }
562
563
    public function flash(string $text, MenuStyle $style = null) : Flash
564
    {
565
        $this->guardSingleLine($text);
566
567
        $style = $style ?? (new MenuStyle($this->terminal))
568
            ->setBg('yellow')
569
            ->setFg('red');
570
571
        return new Flash($this, $style, $this->terminal, $text);
572
    }
573
574
    public function confirm(string $text, MenuStyle $style = null) : Confirm
575
    {
576
        $this->guardSingleLine($text);
577
578
        $style = $style ?? (new MenuStyle($this->terminal))
579
            ->setBg('yellow')
580
            ->setFg('red');
581
582
        return new Confirm($this, $style, $this->terminal, $text);
583
    }
584
585
    public function askNumber(MenuStyle $style = null) : Number
586
    {
587
        $this->assertOpen();
588
589
        $style = $style ?? (new MenuStyle($this->terminal))
590
            ->setBg('yellow')
591
            ->setFg('red');
592
593
        return new Number(new InputIO($this, $this->terminal), $style);
594
    }
595
596
    public function askText(MenuStyle $style = null) : Text
597
    {
598
        $this->assertOpen();
599
600
        $style = $style ?? (new MenuStyle($this->terminal))
601
            ->setBg('yellow')
602
            ->setFg('red');
603
604
        return new Text(new InputIO($this, $this->terminal), $style);
605
    }
606
607
    public function askPassword(MenuStyle $style = null) : Password
608
    {
609
        $this->assertOpen();
610
611
        $style = $style ?? (new MenuStyle($this->terminal))
612
            ->setBg('yellow')
613
            ->setFg('red');
614
615
        return new Password(new InputIO($this, $this->terminal), $style);
616
    }
617
618
    private function guardSingleLine($text) : void
619
    {
620
        if (strpos($text, "\n") !== false) {
621
            throw new \InvalidArgumentException;
622
        }
623
    }
624
}
625