Completed
Pull Request — master (#84)
by
unknown
02:03
created

CliMenu::moveSelection()   C

Complexity

Conditions 13
Paths 16

Size

Total Lines 40
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 40
rs 5.1234
c 0
b 0
f 0
cc 13
eloc 31
nc 16
nop 1

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 Frame
69
     */
70
    private $currentFrame;
71
72
    public function __construct(
73
        ?string $title,
74
        array $items,
75
        Terminal $terminal = null,
76
        MenuStyle $style = null
77
    ) {
78
        $this->title      = $title;
79
        $this->items      = $items;
80
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
81
        $this->style      = $style ?: new MenuStyle($this->terminal);
82
83
        $this->selectFirstItem();
84
    }
85
86
    /**
87
     * Configure the terminal to work with CliMenu
88
     */
89
    protected function configureTerminal() : void
90
    {
91
        $this->assertTerminalIsValidTTY();
92
93
        $this->terminal->disableCanonicalMode();
94
        $this->terminal->disableEchoBack();
95
        $this->terminal->disableCursor();
96
        $this->terminal->clear();
97
    }
98
99
    /**
100
     * Revert changes made to the terminal
101
     */
102
    protected function tearDownTerminal() : void
103
    {
104
        $this->terminal->restoreOriginalConfiguration();
105
    }
106
107
    private function assertTerminalIsValidTTY() : void
108
    {
109
        if (!$this->terminal->isInteractive()) {
110
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
111
        }
112
    }
113
114
115
    public function setParent(CliMenu $parent) : void
116
    {
117
        $this->parent = $parent;
118
    }
119
120
    public function getParent() : ?CliMenu
121
    {
122
        return $this->parent;
123
    }
124
125
    public function getTerminal() : Terminal
126
    {
127
        return $this->terminal;
128
    }
129
130
    public function isOpen() : bool
131
    {
132
        return $this->open;
133
    }
134
135
    /**
136
     * Add a new Item to the menu
137
     */
138
    public function addItem(MenuItemInterface $item) : void
139
    {
140
        $this->items[] = $item;
141
        
142
        if (count($this->items) === 1) {
143
            $this->selectFirstItem();
144
        }
145
    }
146
147
    /**
148
     * Set the selected pointer to the first selectable item
149
     */
150
    private function selectFirstItem() : void
151
    {
152
        foreach ($this->items as $key => $item) {
153
            if ($item->canSelect()) {
154
                $this->selectedItem = $key;
155
                break;
156
            }
157
        }
158
    }
159
160
    /**
161
     * Display menu and capture input
162
     */
163
    private function display() : void
164
    {
165
        $this->draw();
166
167
        $reader = new NonCanonicalReader($this->terminal);
168
        $reader->addControlMappings([
169
            '^P' => InputCharacter::UP,
170
            'k'  => InputCharacter::UP,
171
            '^K' => InputCharacter::DOWN,
172
            'j'  => InputCharacter::DOWN,
173
            "\r" => InputCharacter::ENTER,
174
            ' '  => InputCharacter::ENTER,
175
            'l'  => 'LEFT',
176
            'm'  => 'RIGHT',
177
        ]);
178
179
        while ($this->isOpen() && $char = $reader->readCharacter()) {
180
            if (!$char->isHandledControl()) {
181
                continue;
182
            }
183
184
            switch ($char->getControl()) {
185
                case InputCharacter::UP:
186
                case InputCharacter::DOWN:
187
                case 'LEFT':
188
                case 'RIGHT':
189
                    $this->moveSelection($char->getControl());
190
                    $this->draw();
191
                    break;
192
                case InputCharacter::ENTER:
193
                    $this->executeCurrentItem();
194
                    break;
195
            }
196
        }
197
    }
198
199
    /**
200
     * Move the selection in a given direction, up / down
201
     */
202
    protected function moveSelection(string $direction) : void
203
    {
204
        do {
205
            if ($direction === 'UP' || $direction === 'DOWN') {
206
                $itemKeys = array_keys($this->items);
207
208
                $direction === 'UP'
209
                    ? $this->selectedItem--
210
                    : $this->selectedItem++;
211
212
                if (!array_key_exists($this->selectedItem, $this->items)) {
213
                    $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...
214
                        ? end($itemKeys)
215
                        : reset($itemKeys);
216
                } elseif ($this->getSelectedItem()->canSelect()) {
217
                    return;
218
                }
219
            } else {
220
                $item = $this->getSelectedItem(true);
221
                if (!$item instanceof SplitItem) {
222
                    return;
223
                }
224
225
                $itemKeys = array_keys($item->getItems());
226
                $selectedItemIndex = $item->getSelectedItemIndex();
227
                $direction === 'LEFT'
228
                    ? $selectedItemIndex--
229
                    : $selectedItemIndex++;
230
                $item->setSelectedItemIndex($selectedItemIndex);
231
                if (!array_key_exists($selectedItemIndex, $item->getItems())) {
232
                    $selectedItemIndex = $direction === 'LEFT'
233
                        ? end($itemKeys)
234
                        : reset($itemKeys);
235
                    $item->setSelectedItemIndex($selectedItemIndex);
236
                } elseif ($item->getItems()[$item->getSelectedItemIndex()]->canSelect()) {
237
                    return;
238
                }
239
            }
240
        } while (!$this->getSelectedItem()->canSelect());
241
    }
242
243
    public function getSelectedItem(bool $oneLevelDeep = false) : MenuItemInterface
244
    {
245
        if ($oneLevelDeep) {
246
            return $this->items[$this->selectedItem];
247
        } else {
248
            $item = $this->items[$this->selectedItem];
249
            if ($item instanceof SplitItem) {
250
                $item = $item->getItems()[$item->getSelectedItemIndex()];
251
            }
252
253
            return $item;
254
        }
255
    }
256
257
    /**
258
     * Execute the current item
259
     */
260
    protected function executeCurrentItem() : void
261
    {
262
        $item = $this->getSelectedItem();
263
264
        if ($item->canSelect()) {
265
            $callable = $item->getSelectAction();
266
            $callable($this);
267
        }
268
    }
269
270
    /**
271
     * Redraw the menu
272
     */
273
    public function redraw() : void
274
    {
275
        $this->assertOpen();
276
        $this->draw();
277
    }
278
279
    private function assertOpen() : void
280
    {
281
        if (!$this->isOpen()) {
282
            throw new MenuNotOpenException;
283
        }
284
    }
285
286
    /**
287
     * Draw the menu to STDOUT
288
     */
289
    protected function draw() : void
290
    {
291
        $this->terminal->clean();
292
        $this->terminal->moveCursorToTop();
293
294
        $frame = new Frame;
295
296
        $frame->newLine(2);
297
298
        if ($this->title) {
299
            $frame->addRows($this->drawMenuItem(new LineBreakItem()));
300
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
301
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
302
        }
303
304
        array_map(function ($item, $index) use ($frame) {
305
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
306
        }, $this->items, array_keys($this->items));
307
308
        $frame->addRows($this->drawMenuItem(new LineBreakItem()));
309
310
        $frame->newLine(2);
311
312
        foreach ($frame->getRows() as $row) {
313
            $this->terminal->write($row);
314
        }
315
316
        $this->currentFrame = $frame;
317
    }
318
319
    /**
320
     * Draw a menu item
321
     */
322
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
323
    {
324
        $rows = $item->getRows($this->style, $selected);
325
        
326
        if ($item instanceof SplitItem) {
327
            $selected = false;
328
        }
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