Completed
Push — master ( 1a3e42...97058e )
by Aydin
9s
created

CliMenu::isOpen()   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 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\StaticItem;
16
use PhpSchool\CliMenu\Dialogue\Confirm;
17
use PhpSchool\CliMenu\Dialogue\Flash;
18
use PhpSchool\CliMenu\Terminal\TerminalFactory;
19
use PhpSchool\CliMenu\Util\StringUtil as s;
20
use PhpSchool\Terminal\Exception\NotInteractiveTerminal;
21
use PhpSchool\Terminal\InputCharacter;
22
use PhpSchool\Terminal\NonCanonicalReader;
23
use PhpSchool\Terminal\Terminal;
24
use PhpSchool\Terminal\TerminalReader;
25
26
/**
27
 * @author Michael Woodward <[email protected]>
28
 */
29
class CliMenu
30
{
31
    /**
32
     * @var Terminal
33
     */
34
    protected $terminal;
35
36
    /**
37
     * @var MenuStyle
38
     */
39
    protected $style;
40
41
    /**
42
     * @var ?string
43
     */
44
    protected $title;
45
46
    /**
47
     * @var MenuItemInterface[]
48
     */
49
    protected $items = [];
50
51
    /**
52
     * @var int
53
     */
54
    protected $selectedItem;
55
56
    /**
57
     * @var bool
58
     */
59
    protected $open = false;
60
61
    /**
62
     * @var CliMenu|null
63
     */
64
    protected $parent;
65
66
    /**
67
     * @var array
68
     */
69
    protected $defaultControlMappings = [
70
        '^P' => InputCharacter::UP,
71
        'k'  => InputCharacter::UP,
72
        '^K' => InputCharacter::DOWN,
73
        'j'  => InputCharacter::DOWN,
74
        "\r" => InputCharacter::ENTER,
75
        ' '  => InputCharacter::ENTER,
76
        'l'  => InputCharacter::LEFT,
77
        'm'  => InputCharacter::RIGHT,
78
    ];
79
80
    /**
81
     * @var array
82
     */
83
    protected $customControlMappings = [];
84
85
    /**
86
     * @var Frame
87
     */
88
    private $currentFrame;
89
90
    public function __construct(
91
        ?string $title,
92
        array $items,
93
        Terminal $terminal = null,
94
        MenuStyle $style = null
95
    ) {
96
        $this->title      = $title;
97
        $this->items      = $items;
98
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
99
        $this->style      = $style ?: new MenuStyle($this->terminal);
100
101
        $this->selectFirstItem();
102
    }
103
104
    /**
105
     * Configure the terminal to work with CliMenu
106
     */
107
    protected function configureTerminal() : void
108
    {
109
        $this->assertTerminalIsValidTTY();
110
111
        $this->terminal->disableCanonicalMode();
112
        $this->terminal->disableEchoBack();
113
        $this->terminal->disableCursor();
114
        $this->terminal->clear();
115
    }
116
117
    /**
118
     * Revert changes made to the terminal
119
     */
120
    protected function tearDownTerminal() : void
121
    {
122
        $this->terminal->restoreOriginalConfiguration();
123
        $this->terminal->enableCursor();
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
     * Shorthand function to add multiple custom control mapping at once
217
     */
218
    public function addCustomControlMappings(array $map) : void
219
    {
220
        foreach ($map as $input => $callable) {
221
            $this->addCustomControlMapping($input, $callable);
222
        }
223
    }
224
225
    /**
226
     * Removes a custom control mapping
227
     */
228
    public function removeCustomControlMapping(string $input) : void
229
    {
230
        if (!isset($this->customControlMappings[$input])) {
231
            throw new \InvalidArgumentException('This input is not registered');
232
        }
233
234
        unset($this->customControlMappings[$input]);
235
    }
236
237
    /**
238
     * Display menu and capture input
239
     */
240
    private function display() : void
241
    {
242
        $this->draw();
243
244
        $reader = new NonCanonicalReader($this->terminal);
245
        $reader->addControlMappings($this->defaultControlMappings);
246
247
        while ($this->isOpen() && $char = $reader->readCharacter()) {
248
            if (!$char->isHandledControl()) {
249
                $rawChar = $char->get();
250
                if (isset($this->customControlMappings[$rawChar])) {
251
                    $this->customControlMappings[$rawChar]($this);
252
                }
253
                continue;
254
            }
255
256
            switch ($char->getControl()) {
257
                case InputCharacter::UP:
258
                case InputCharacter::DOWN:
259
                    $this->moveSelection($char->getControl());
260
                    $this->draw();
261
                    break;
262
                case InputCharacter::ENTER:
263
                    $this->executeCurrentItem();
264
                    break;
265
            }
266
        }
267
    }
268
269
    /**
270
     * Move the selection in a given direction, up / down
271
     */
272
    protected function moveSelection(string $direction) : void
273
    {
274
        do {
275
            $itemKeys = array_keys($this->items);
276
277
            $direction === 'UP'
278
                ? $this->selectedItem--
279
                : $this->selectedItem++;
280
281
            if (!array_key_exists($this->selectedItem, $this->items)) {
282
                $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...
283
                    ? end($itemKeys)
284
                    : reset($itemKeys);
285
            } elseif ($this->getSelectedItem()->canSelect()) {
286
                return;
287
            }
288
        } while (!$this->getSelectedItem()->canSelect());
289
    }
290
291
    public function getSelectedItem() : MenuItemInterface
292
    {
293
        return $this->items[$this->selectedItem];
294
    }
295
296
    /**
297
     * Execute the current item
298
     */
299
    protected function executeCurrentItem() : void
300
    {
301
        $item = $this->getSelectedItem();
302
303
        if ($item->canSelect()) {
304
            $callable = $item->getSelectAction();
305
            $callable($this);
306
        }
307
    }
308
309
    /**
310
     * If true we clear the whole terminal screen, useful
311
     * for example when reducing the width of the menu, to not
312
     * leave leftovers of the previous wider menu.
313
     *
314
     * Redraw the menu
315
     */
316
    public function redraw(bool $clear = false) : void
317
    {
318
        if ($clear) {
319
            $this->terminal->clear();
320
        }
321
322
        $this->assertOpen();
323
        $this->draw();
324
    }
325
326
    private function assertOpen() : void
327
    {
328
        if (!$this->isOpen()) {
329
            throw new MenuNotOpenException;
330
        }
331
    }
332
333
    /**
334
     * Draw the menu to STDOUT
335
     */
336
    protected function draw() : void
337
    {
338
        $frame = new Frame;
339
340
        $frame->newLine(2);
341
342
        if ($this->style->getBorderTopWidth() > 0) {
343
            $frame->addRows($this->style->getBorderTopRows());
344
        }
345
346
        if ($this->style->getPaddingTopBottom() > 0) {
347
            $frame->addRows($this->style->getPaddingTopBottomRows());
348
        }
349
350
        if ($this->title) {
351
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
352
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
353
        }
354
355
        array_map(function ($item, $index) use ($frame) {
356
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
357
        }, $this->items, array_keys($this->items));
358
359
360
        if ($this->style->getPaddingTopBottom() > 0) {
361
            $frame->addRows($this->style->getPaddingTopBottomRows());
362
        }
363
364
        if ($this->style->getBorderBottomWidth() > 0) {
365
            $frame->addRows($this->style->getBorderBottomRows());
366
        }
367
368
        $frame->newLine(2);
369
370
        $this->terminal->moveCursorToTop();
371
        foreach ($frame->getRows() as $row) {
372
            if ($row == "\n") {
373
                $this->terminal->clearLine();
374
            }
375
            $this->terminal->write($row);
376
        }
377
        $this->terminal->clearDown();
378
379
        $this->currentFrame = $frame;
380
    }
381
382
    /**
383
     * Draw a menu item
384
     */
385
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
386
    {
387
        $rows = $item->getRows($this->style, $selected);
388
389
        $invertedColoursSetCode = $selected
390
            ? $this->style->getInvertedColoursSetCode()
391
            : '';
392
        $invertedColoursUnsetCode = $selected
393
            ? $this->style->getInvertedColoursUnsetCode()
394
            : '';
395
396
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
397
            $borderColour = $this->style->getBorderColourCode();
398
        } else {
399
            $borderColour = '';
400
        }
401
402
        return array_map(function ($row) use ($invertedColoursSetCode, $invertedColoursUnsetCode, $borderColour) {
403
            return sprintf(
404
                "%s%s%s%s%s%s%s%s%s%s%s%s\n",
405
                str_repeat(' ', $this->style->getMargin()),
406
                $borderColour,
407
                str_repeat(' ', $this->style->getBorderLeftWidth()),
408
                $this->style->getColoursSetCode(),
409
                $invertedColoursSetCode,
410
                str_repeat(' ', $this->style->getPaddingLeftRight()),
411
                $row,
412
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
413
                $invertedColoursUnsetCode,
414
                $borderColour,
415
                str_repeat(' ', $this->style->getBorderRightWidth()),
416
                $this->style->getColoursResetCode()
417
            );
418
        }, $rows);
419
    }
420
421
    /**
422
     * @throws InvalidTerminalException
423
     */
424
    public function open() : void
425
    {
426
        if ($this->isOpen()) {
427
            return;
428
        }
429
430
        $this->configureTerminal();
431
        $this->open = true;
432
        $this->display();
433
    }
434
435
    /**
436
     * Close the menu
437
     *
438
     * @throws InvalidTerminalException
439
     */
440
    public function close() : void
441
    {
442
        $menu = $this;
443
444
        do {
445
            $menu->closeThis();
446
            $menu = $menu->getParent();
447
        } while (null !== $menu);
448
449
        $this->tearDownTerminal();
450
    }
451
452
    public function closeThis() : void
453
    {
454
        $this->terminal->clean();
455
        $this->terminal->moveCursorToTop();
456
        $this->open = false;
457
    }
458
459
    /**
460
     * @return MenuItemInterface[]
461
     */
462
    public function getItems() : array
463
    {
464
        return $this->items;
465
    }
466
467
    public function removeItem(MenuItemInterface $item) : void
468
    {
469
        $key = array_search($item, $this->items, true);
470
471
        if (false === $key) {
472
            throw new \InvalidArgumentException('Item does not exist in menu');
473
        }
474
475
        unset($this->items[$key]);
476
        $this->items = array_values($this->items);
477
    }
478
479
    public function getStyle() : MenuStyle
480
    {
481
        return $this->style;
482
    }
483
484
    public function getCurrentFrame() : Frame
485
    {
486
        return $this->currentFrame;
487
    }
488
489
    public function flash(string $text, MenuStyle $style = null) : Flash
490
    {
491
        $this->guardSingleLine($text);
492
493
        $style = $style ?? (new MenuStyle($this->terminal))
494
            ->setBg('yellow')
495
            ->setFg('red');
496
497
        return new Flash($this, $style, $this->terminal, $text);
498
    }
499
500
    public function confirm(string $text, MenuStyle $style = null) : Confirm
501
    {
502
        $this->guardSingleLine($text);
503
504
        $style = $style ?? (new MenuStyle($this->terminal))
505
            ->setBg('yellow')
506
            ->setFg('red');
507
508
        return new Confirm($this, $style, $this->terminal, $text);
509
    }
510
511
    public function askNumber(MenuStyle $style = null) : Number
512
    {
513
        $this->assertOpen();
514
515
        $style = $style ?? (new MenuStyle($this->terminal))
516
            ->setBg('yellow')
517
            ->setFg('red');
518
519
        return new Number(new InputIO($this, $this->terminal), $style);
520
    }
521
522
    public function askText(MenuStyle $style = null) : Text
523
    {
524
        $this->assertOpen();
525
526
        $style = $style ?? (new MenuStyle($this->terminal))
527
            ->setBg('yellow')
528
            ->setFg('red');
529
530
        return new Text(new InputIO($this, $this->terminal), $style);
531
    }
532
533
    public function askPassword(MenuStyle $style = null) : Password
534
    {
535
        $this->assertOpen();
536
537
        $style = $style ?? (new MenuStyle($this->terminal))
538
            ->setBg('yellow')
539
            ->setFg('red');
540
541
        return new Password(new InputIO($this, $this->terminal), $style);
542
    }
543
544
    private function guardSingleLine($text)
545
    {
546
        if (strpos($text, "\n") !== false) {
547
            throw new \InvalidArgumentException;
548
        }
549
    }
550
}
551