Completed
Pull Request — master (#84)
by
unknown
01:53
created

CliMenu::addItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 4
nc 2
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\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
    }
125
126
    private function assertTerminalIsValidTTY() : void
127
    {
128
        if (!$this->terminal->isInteractive()) {
129
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
130
        }
131
    }
132
133
134
    public function setParent(CliMenu $parent) : void
135
    {
136
        $this->parent = $parent;
137
    }
138
139
    public function getParent() : ?CliMenu
140
    {
141
        return $this->parent;
142
    }
143
144
    public function getTerminal() : Terminal
145
    {
146
        return $this->terminal;
147
    }
148
149
    public function isOpen() : bool
150
    {
151
        return $this->open;
152
    }
153
154
    /**
155
     * Add a new Item to the menu
156
     */
157
    public function addItem(MenuItemInterface $item) : void
158
    {
159
        $this->items[] = $item;
160
        
161
        if (count($this->items) === 1) {
162
            $this->selectFirstItem();
163
        }
164
    }
165
166
    /**
167
     * Add multiple Items to the menu
168
     */
169
    public function addItems(array $items) : void
170
    {
171
        foreach ($items as $item) {
172
            $this->items[] = $item;
173
        }
174
175
        if (count($this->items) === count($items)) {
176
            $this->selectFirstItem();
177
        }
178
    }
179
180
    /**
181
     * Set Items of the menu
182
     */
183
    public function setItems(array $items) : void
184
    {
185
        $this->items = $items;
186
187
        $this->selectFirstItem();
188
    }
189
190
    /**
191
     * Set the selected pointer to the first selectable item
192
     */
193
    private function selectFirstItem() : void
194
    {
195
        foreach ($this->items as $key => $item) {
196
            if ($item->canSelect()) {
197
                $this->selectedItem = $key;
198
                break;
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
     * Removes a custom control mapping
217
     */
218
    public function removeCustomControlMapping(string $input) : void
219
    {
220
        if (!isset($this->customControlMappings[$input])) {
221
            throw new \InvalidArgumentException('This input is not registered');
222
        }
223
224
        unset($this->customControlMappings[$input]);
225
    }
226
227
    /**
228
     * Display menu and capture input
229
     */
230
    private function display() : void
231
    {
232
        $this->draw();
233
234
        $reader = new NonCanonicalReader($this->terminal);
235
        $reader->addControlMappings($this->defaultControlMappings);
236
237
        while ($this->isOpen() && $char = $reader->readCharacter()) {
238
            if (!$char->isHandledControl()) {
239
                $rawChar = $char->get();
240
                if (isset($this->customControlMappings[$rawChar])) {
241
                    $this->customControlMappings[$rawChar]($this);
242
                }
243
                continue;
244
            }
245
246
            switch ($char->getControl()) {
247
                case InputCharacter::UP:
248
                case InputCharacter::DOWN:
249
                case 'LEFT':
250
                case 'RIGHT':
251
                    $this->moveSelection($char->getControl());
252
                    $this->draw();
253
                    break;
254
                case InputCharacter::ENTER:
255
                    $this->executeCurrentItem();
256
                    break;
257
            }
258
        }
259
    }
260
261
    /**
262
     * Move the selection in a given direction, up / down
263
     */
264
    protected function moveSelection(string $direction) : void
265
    {
266
        do {
267
            if ($direction === 'UP' || $direction === 'DOWN') {
268
                $itemKeys = array_keys($this->items);
269
270
                $direction === 'UP'
271
                    ? $this->selectedItem--
272
                    : $this->selectedItem++;
273
274
                if (!array_key_exists($this->selectedItem, $this->items)) {
275
                    $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...
276
                        ? end($itemKeys)
277
                        : reset($itemKeys);
278
                } elseif ($this->getSelectedItem()->canSelect()) {
279
                    return;
280
                }
281
            } else {
282
                $item = $this->getSelectedItem(true);
283
                if (!$item instanceof SplitItem) {
284
                    return;
285
                }
286
287
                $itemKeys = array_keys($item->getItems());
288
                $selectedItemIndex = $item->getSelectedItemIndex();
289
                $direction === 'LEFT'
290
                    ? $selectedItemIndex--
291
                    : $selectedItemIndex++;
292
                $item->setSelectedItemIndex($selectedItemIndex);
293
                if (!array_key_exists($selectedItemIndex, $item->getItems())) {
294
                    $selectedItemIndex = $direction === 'LEFT'
295
                        ? end($itemKeys)
296
                        : reset($itemKeys);
297
                    $item->setSelectedItemIndex($selectedItemIndex);
298
                } elseif ($item->getItems()[$item->getSelectedItemIndex()]->canSelect()) {
299
                    return;
300
                }
301
            }
302
        } while (!$this->getSelectedItem()->canSelect());
303
    }
304
305
    public function getSelectedItem(bool $oneLevelDeep = false) : MenuItemInterface
306
    {
307
        if ($oneLevelDeep) {
308
            return $this->items[$this->selectedItem];
309
        } else {
310
            $item = $this->items[$this->selectedItem];
311
            if ($item instanceof SplitItem) {
312
                $item = $item->getItems()[$item->getSelectedItemIndex()];
313
            }
314
315
            return $item;
316
        }
317
    }
318
319
    /**
320
     * Execute the current item
321
     */
322
    protected function executeCurrentItem() : void
323
    {
324
        $item = $this->getSelectedItem();
325
326
        if ($item->canSelect()) {
327
            $callable = $item->getSelectAction();
328
            $callable($this);
329
        }
330
    }
331
332
    /**
333
     * Redraw the menu
334
     */
335
    public function redraw() : void
336
    {
337
        $this->assertOpen();
338
        $this->draw();
339
    }
340
341
    private function assertOpen() : void
342
    {
343
        if (!$this->isOpen()) {
344
            throw new MenuNotOpenException;
345
        }
346
    }
347
348
    /**
349
     * Draw the menu to STDOUT
350
     */
351
    protected function draw() : void
352
    {
353
        $frame = new Frame;
354
355
        $frame->newLine(2);
356
357
        if ($this->title) {
358
            $frame->addRows($this->drawMenuItem(new LineBreakItem()));
359
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
360
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
361
        }
362
363
        array_map(function ($item, $index) use ($frame) {
364
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
365
        }, $this->items, array_keys($this->items));
366
367
        $frame->addRows($this->drawMenuItem(new LineBreakItem()));
368
369
        $frame->newLine(2);
370
        
371
        $this->terminal->moveCursorToTop();
372
        foreach ($frame->getRows() as $row) {
373
            if ($row == "\n") {
374
                $this->terminal->write("\033[2K");
375
            }
376
            $this->terminal->write($row);
377
        }
378
        $this->terminal->write("\033[J");
379
380
        $this->currentFrame = $frame;
381
    }
382
383
    /**
384
     * Draw a menu item
385
     */
386
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
387
    {
388
        $rows = $item->getRows($this->style, $selected);
389
        
390
        if ($item instanceof SplitItem) {
391
            $selected = false;
392
        }
393
394
        $setColour = $selected
395
            ? $this->style->getSelectedSetCode()
396
            : $this->style->getUnselectedSetCode();
397
398
        $unsetColour = $selected
399
            ? $this->style->getSelectedUnsetCode()
400
            : $this->style->getUnselectedUnsetCode();
401
402
        return array_map(function ($row) use ($setColour, $unsetColour) {
403
            return sprintf(
404
                "%s%s%s%s%s%s%s\n",
405
                str_repeat(' ', $this->style->getMargin()),
406
                $setColour,
407
                str_repeat(' ', $this->style->getPadding()),
408
                $row,
409
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
410
                $unsetColour,
411
                str_repeat(' ', $this->style->getMargin())
412
            );
413
        }, $rows);
414
    }
415
416
    /**
417
     * @throws InvalidTerminalException
418
     */
419
    public function open() : void
420
    {
421
        if ($this->isOpen()) {
422
            return;
423
        }
424
425
        $this->configureTerminal();
426
        $this->open = true;
427
        $this->display();
428
    }
429
430
    /**
431
     * Close the menu
432
     *
433
     * @throws InvalidTerminalException
434
     */
435
    public function close() : void
436
    {
437
        $menu = $this;
438
439
        do {
440
            $menu->closeThis();
441
            $menu = $menu->getParent();
442
        } while (null !== $menu);
443
        
444
        $this->tearDownTerminal();
445
    }
446
447
    public function closeThis() : void
448
    {
449
        $this->terminal->clean();
450
        $this->terminal->moveCursorToTop();
451
        $this->open = false;
452
    }
453
454
    /**
455
     * @return MenuItemInterface[]
456
     */
457
    public function getItems() : array
458
    {
459
        return $this->items;
460
    }
461
462
    public function removeItem(MenuItemInterface $item) : void
463
    {
464
        $key = array_search($item, $this->items, true);
465
466
        if (false === $key) {
467
            throw new \InvalidArgumentException('Item does not exist in menu');
468
        }
469
470
        unset($this->items[$key]);
471
        $this->items = array_values($this->items);
472
    }
473
474
    public function getStyle() : MenuStyle
475
    {
476
        return $this->style;
477
    }
478
479
    public function getCurrentFrame() : Frame
480
    {
481
        return $this->currentFrame;
482
    }
483
484
    public function flash(string $text) : Flash
485
    {
486
        $this->guardSingleLine($text);
487
488
        $style = (new MenuStyle($this->terminal))
489
            ->setBg('yellow')
490
            ->setFg('red');
491
492
        return new Flash($this, $style, $this->terminal, $text);
493
    }
494
495
    public function confirm($text) : Confirm
496
    {
497
        $this->guardSingleLine($text);
498
499
        $style = (new MenuStyle($this->terminal))
500
            ->setBg('yellow')
501
            ->setFg('red');
502
503
        return new Confirm($this, $style, $this->terminal, $text);
504
    }
505
506
    public function askNumber() : Number
507
    {
508
        $this->assertOpen();
509
510
        $style = (new MenuStyle($this->terminal))
511
            ->setBg('yellow')
512
            ->setFg('red');
513
514
        return new Number(new InputIO($this, $this->terminal), $style);
515
    }
516
517
    public function askText() : Text
518
    {
519
        $this->assertOpen();
520
521
        $style = (new MenuStyle($this->terminal))
522
            ->setBg('yellow')
523
            ->setFg('red');
524
525
        return new Text(new InputIO($this, $this->terminal), $style);
526
    }
527
528
    public function askPassword() : Password
529
    {
530
        $this->assertOpen();
531
532
        $style = (new MenuStyle($this->terminal))
533
            ->setBg('yellow')
534
            ->setFg('red');
535
536
        return new Password(new InputIO($this, $this->terminal), $style);
537
    }
538
539
    private function guardSingleLine($text)
540
    {
541
        if (strpos($text, "\n") !== false) {
542
            throw new \InvalidArgumentException;
543
        }
544
    }
545
}
546