Completed
Push — master ( b59108...82796c )
by Aydin
05:30
created

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