Completed
Pull Request — master (#87)
by
unknown
02:11
created

CliMenu::addCustomControlMapping()   A

Complexity

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