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

CliMenu::closeThis()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 4
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
                case InputCharacter::LEFT:
261
                case InputCharacter::RIGHT:
262
                    $this->moveSelection($char->getControl());
263
                    $this->draw();
264
                    break;
265
                case InputCharacter::ENTER:
266
                    $this->executeCurrentItem();
267
                    break;
268
            }
269
        }
270
    }
271
272
    /**
273
     * Move the selection in a given direction, up / down
274
     */
275
    protected function moveSelection(string $direction) : void
276
    {
277
        do {
278
            if ($direction === 'UP' || $direction === 'DOWN') {
279
                $itemKeys = array_keys($this->items);
280
281
                $direction === 'UP'
282
                    ? $this->selectedItem--
283
                    : $this->selectedItem++;
284
285
                if (!array_key_exists($this->selectedItem, $this->items)) {
286
                    $this->selectedItem  = $direction === 'UP'
0 ignored issues
show
Documentation Bug introduced by
It seems like $direction === 'UP' ? en...eys) : reset($itemKeys) can also be of type false. However, the property $selectedItem is declared as type integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
287
                        ? end($itemKeys)
288
                        : reset($itemKeys);
289
                } elseif ($this->getSelectedItem()->canSelect()) {
290
                    return;
291
                }
292
            } else {
293
                $item = $this->getSelectedItem(true);
294
                if (!$item instanceof SplitItem) {
295
                    return;
296
                }
297
298
                $itemKeys = array_keys($item->getItems());
299
                $selectedItemIndex = $item->getSelectedItemIndex();
300
                $direction === 'LEFT'
301
                    ? $selectedItemIndex--
302
                    : $selectedItemIndex++;
303
                $item->setSelectedItemIndex($selectedItemIndex);
304
305
                if (!array_key_exists($selectedItemIndex, $item->getItems())) {
306
                    $selectedItemIndex = $direction === 'LEFT'
307
                        ? end($itemKeys)
308
                        : reset($itemKeys);
309
                    $item->setSelectedItemIndex($selectedItemIndex);
310
                } elseif ($item->getItems()[$item->getSelectedItemIndex()]->canSelect()) {
311
                    return;
312
                }
313
            }
314
        } while (!$this->getSelectedItem()->canSelect());
315
    }
316
317
    public function getSelectedItem(bool $oneLevelDeep = false) : MenuItemInterface
318
    {
319
        if ($oneLevelDeep) {
320
            return $this->items[$this->selectedItem];
321
        } else {
322
            $item = $this->items[$this->selectedItem];
323
            if ($item instanceof SplitItem) {
324
                $item = $item->getItems()[$item->getSelectedItemIndex()];
325
            }
326
327
            return $item;
328
        }
329
    }
330
331
    /**
332
     * Execute the current item
333
     */
334
    protected function executeCurrentItem() : void
335
    {
336
        $item = $this->getSelectedItem();
337
338
        if ($item->canSelect()) {
339
            $callable = $item->getSelectAction();
340
            $callable($this);
341
        }
342
    }
343
344
    /**
345
     * If true we clear the whole terminal screen, useful
346
     * for example when reducing the width of the menu, to not
347
     * leave leftovers of the previous wider menu.
348
     *
349
     * Redraw the menu
350
     */
351
    public function redraw(bool $clear = false) : void
352
    {
353
        if ($clear) {
354
            $this->terminal->clear();
355
        }
356
357
        $this->assertOpen();
358
        $this->draw();
359
    }
360
361
    private function assertOpen() : void
362
    {
363
        if (!$this->isOpen()) {
364
            throw new MenuNotOpenException;
365
        }
366
    }
367
368
    /**
369
     * Draw the menu to STDOUT
370
     */
371
    protected function draw() : void
372
    {
373
        $frame = new Frame;
374
375
        $frame->newLine(2);
376
377
        if ($this->style->getBorderTopWidth() > 0) {
378
            $frame->addRows($this->style->getBorderTopRows());
379
        }
380
381
        if ($this->style->getPaddingTopBottom() > 0) {
382
            $frame->addRows($this->style->getPaddingTopBottomRows());
383
        }
384
385
        if ($this->title) {
386
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
387
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
388
        }
389
390
        array_map(function ($item, $index) use ($frame) {
391
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
392
        }, $this->items, array_keys($this->items));
393
394
395
        if ($this->style->getPaddingTopBottom() > 0) {
396
            $frame->addRows($this->style->getPaddingTopBottomRows());
397
        }
398
399
        if ($this->style->getBorderBottomWidth() > 0) {
400
            $frame->addRows($this->style->getBorderBottomRows());
401
        }
402
403
        $frame->newLine(2);
404
405
        $this->terminal->moveCursorToTop();
406
        foreach ($frame->getRows() as $row) {
407
            if ($row == "\n") {
408
                $this->terminal->clearLine();
409
            }
410
            $this->terminal->write($row);
411
        }
412
        $this->terminal->clearDown();
413
414
        $this->currentFrame = $frame;
415
    }
416
417
    /**
418
     * Draw a menu item
419
     */
420
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
421
    {
422
        $rows = $item->getRows($this->style, $selected);
423
        
424
        if ($item instanceof SplitItem) {
425
            $selected = false;
426
        }
427
428
        $invertedColoursSetCode = $selected
429
            ? $this->style->getInvertedColoursSetCode()
430
            : '';
431
        $invertedColoursUnsetCode = $selected
432
            ? $this->style->getInvertedColoursUnsetCode()
433
            : '';
434
435
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
436
            $borderColour = $this->style->getBorderColourCode();
437
        } else {
438
            $borderColour = '';
439
        }
440
441
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
442
            return sprintf(
443
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
444
                str_repeat(' ', $this->style->getMargin()),
445
                $borderColour,
446
                str_repeat(' ', $this->style->getBorderLeftWidth()),
447
                $this->style->getColoursSetCode(),
448
                $invertedColoursSetCode,
449
                str_repeat(' ', $this->style->getPaddingLeftRight()),
450
                $row,
451
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
452
                $invertedColoursUnsetCode,
453
                $borderColour,
454
                str_repeat(' ', $this->style->getBorderRightWidth()),
455
                $this->style->getColoursResetCode()
456
            );
457
        }, $rows);
458
    }
459
460
    /**
461
     * @throws InvalidTerminalException
462
     */
463
    public function open() : void
464
    {
465
        if ($this->isOpen()) {
466
            return;
467
        }
468
469
        $this->configureTerminal();
470
        $this->open = true;
471
        $this->display();
472
    }
473
474
    /**
475
     * Close the menu
476
     *
477
     * @throws InvalidTerminalException
478
     */
479
    public function close() : void
480
    {
481
        $menu = $this;
482
483
        do {
484
            $menu->closeThis();
485
            $menu = $menu->getParent();
486
        } while (null !== $menu);
487
488
        $this->tearDownTerminal();
489
    }
490
491
    public function closeThis() : void
492
    {
493
        $this->terminal->clean();
494
        $this->terminal->moveCursorToTop();
495
        $this->open = false;
496
    }
497
498
    /**
499
     * @return MenuItemInterface[]
500
     */
501
    public function getItems() : array
502
    {
503
        return $this->items;
504
    }
505
506
    public function removeItem(MenuItemInterface $item) : void
507
    {
508
        $key = array_search($item, $this->items, true);
509
510
        if (false === $key) {
511
            throw new \InvalidArgumentException('Item does not exist in menu');
512
        }
513
514
        unset($this->items[$key]);
515
        $this->items = array_values($this->items);
516
    }
517
518
    public function getStyle() : MenuStyle
519
    {
520
        return $this->style;
521
    }
522
523
    public function getCurrentFrame() : Frame
524
    {
525
        return $this->currentFrame;
526
    }
527
528
    public function flash(string $text, MenuStyle $style = null) : Flash
529
    {
530
        $this->guardSingleLine($text);
531
532
        $style = $style ?? (new MenuStyle($this->terminal))
533
            ->setBg('yellow')
534
            ->setFg('red');
535
536
        return new Flash($this, $style, $this->terminal, $text);
537
    }
538
539
    public function confirm(string $text, MenuStyle $style = null) : Confirm
540
    {
541
        $this->guardSingleLine($text);
542
543
        $style = $style ?? (new MenuStyle($this->terminal))
544
            ->setBg('yellow')
545
            ->setFg('red');
546
547
        return new Confirm($this, $style, $this->terminal, $text);
548
    }
549
550
    public function askNumber(MenuStyle $style = null) : Number
551
    {
552
        $this->assertOpen();
553
554
        $style = $style ?? (new MenuStyle($this->terminal))
555
            ->setBg('yellow')
556
            ->setFg('red');
557
558
        return new Number(new InputIO($this, $this->terminal), $style);
559
    }
560
561
    public function askText(MenuStyle $style = null) : Text
562
    {
563
        $this->assertOpen();
564
565
        $style = $style ?? (new MenuStyle($this->terminal))
566
            ->setBg('yellow')
567
            ->setFg('red');
568
569
        return new Text(new InputIO($this, $this->terminal), $style);
570
    }
571
572
    public function askPassword(MenuStyle $style = null) : Password
573
    {
574
        $this->assertOpen();
575
576
        $style = $style ?? (new MenuStyle($this->terminal))
577
            ->setBg('yellow')
578
            ->setFg('red');
579
580
        return new Password(new InputIO($this, $this->terminal), $style);
581
    }
582
583
    private function guardSingleLine($text) : void
584
    {
585
        if (strpos($text, "\n") !== false) {
586
            throw new \InvalidArgumentException;
587
        }
588
    }
589
}
590