Completed
Push — master ( 3dd685...94a9c5 )
by Aydin
07:57 queued 05:55
created

CliMenu   F

Complexity

Total Complexity 75

Size/Duplication

Total Lines 509
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 15

Importance

Changes 0
Metric Value
wmc 75
lcom 1
cbo 15
dl 0
loc 509
rs 2.3076
c 0
b 0
f 0

36 Methods

Rating   Name   Duplication   Size   Complexity  
A getParent() 0 4 1
A __construct() 0 13 3
A configureTerminal() 0 9 1
A assertTerminalIsValidTTY() 0 6 2
A setParent() 0 4 1
A getTerminal() 0 4 1
A isOpen() 0 4 1
A addItem() 0 8 2
A addItems() 0 10 3
A setItems() 0 6 1
A selectFirstItem() 0 9 3
A addCustomControlMapping() 0 8 3
A tearDownTerminal() 0 5 1
A addCustomControlMappings() 0 6 2
A removeCustomControlMapping() 0 8 2
C display() 0 28 8
B moveSelection() 0 18 6
A getSelectedItem() 0 4 1
A executeCurrentItem() 0 9 2
A redraw() 0 5 1
A assertOpen() 0 6 2
B draw() 0 39 6
B drawMenuItem() 0 36 5
A open() 0 10 2
A close() 0 11 2
A closeThis() 0 6 1
A getItems() 0 4 1
A removeItem() 0 11 2
A getStyle() 0 4 1
A getCurrentFrame() 0 4 1
A flash() 0 10 1
A confirm() 0 10 1
A askNumber() 0 10 1
A askText() 0 10 1
A askPassword() 0 10 1
A guardSingleLine() 0 6 2

How to fix   Complexity   

Complex Class

Complex classes like CliMenu often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CliMenu, and based on these observations, apply Extract Interface, too.

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
     * Redraw the menu
311
     */
312
    public function redraw() : void
313
    {
314
        $this->assertOpen();
315
        $this->draw();
316
    }
317
318
    private function assertOpen() : void
319
    {
320
        if (!$this->isOpen()) {
321
            throw new MenuNotOpenException;
322
        }
323
    }
324
325
    /**
326
     * Draw the menu to STDOUT
327
     */
328
    protected function draw() : void
329
    {
330
        $frame = new Frame;
331
332
        $frame->newLine(2);
333
334
        if ($this->style->getBorderTopWidth() > 0) {
335
            $frame->addRows($this->style->getBorderTopRows());
336
        }
337
338
        if ($this->title) {
339
            $frame->addRows($this->drawMenuItem(new LineBreakItem()));
340
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
341
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
342
        }
343
344
        array_map(function ($item, $index) use ($frame) {
345
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
346
        }, $this->items, array_keys($this->items));
347
348
        $frame->addRows($this->drawMenuItem(new LineBreakItem()));
349
        
350
        if ($this->style->getBorderBottomWidth() > 0) {
351
            $frame->addRows($this->style->getBorderBottomRows());
352
        }
353
354
        $frame->newLine(2);
355
        
356
        $this->terminal->moveCursorToTop();
357
        foreach ($frame->getRows() as $row) {
358
            if ($row == "\n") {
359
                $this->terminal->clearLine();
360
            }
361
            $this->terminal->write($row);
362
        }
363
        $this->terminal->clearDown();
364
365
        $this->currentFrame = $frame;
366
    }
367
368
    /**
369
     * Draw a menu item
370
     */
371
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
372
    {
373
        $rows = $item->getRows($this->style, $selected);
374
375
        $invertedColour = $selected
376
            ? $this->style->getInvertedColoursSetCode()
377
            : '';
378
        $notInvertedColour = $selected
379
            ? $this->style->getInvertedColoursUnsetCode()
380
            : '';
381
382
        if ($this->style->getBorderLeftWidth() || $this->style->getBorderRightWidth()) {
383
            $borderColour = $this->style->getBorderColourCode();
384
        } else {
385
            $borderColour = '';
386
        }
387
388
        return array_map(function ($row) use ($invertedColour, $notInvertedColour, $borderColour) {
389
            return sprintf(
390
                "%s%s%s%s%s%s%s%s%s%s%s%s%s\n",
391
                str_repeat(' ', $this->style->getMargin()),
392
                $borderColour,
393
                str_repeat(' ', $this->style->getBorderLeftWidth()),
394
                $this->style->getColoursSetCode(),
395
                $invertedColour,
396
                str_repeat(' ', $this->style->getPadding()),
397
                $row,
398
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
399
                $notInvertedColour,
400
                $borderColour,
401
                str_repeat(' ', $this->style->getBorderRightWidth()),
402
                $this->style->getColoursResetCode(),
403
                str_repeat(' ', $this->style->getMargin())
404
            );
405
        }, $rows);
406
    }
407
408
    /**
409
     * @throws InvalidTerminalException
410
     */
411
    public function open() : void
412
    {
413
        if ($this->isOpen()) {
414
            return;
415
        }
416
417
        $this->configureTerminal();
418
        $this->open = true;
419
        $this->display();
420
    }
421
422
    /**
423
     * Close the menu
424
     *
425
     * @throws InvalidTerminalException
426
     */
427
    public function close() : void
428
    {
429
        $menu = $this;
430
431
        do {
432
            $menu->closeThis();
433
            $menu = $menu->getParent();
434
        } while (null !== $menu);
435
        
436
        $this->tearDownTerminal();
437
    }
438
439
    public function closeThis() : void
440
    {
441
        $this->terminal->clean();
442
        $this->terminal->moveCursorToTop();
443
        $this->open = false;
444
    }
445
446
    /**
447
     * @return MenuItemInterface[]
448
     */
449
    public function getItems() : array
450
    {
451
        return $this->items;
452
    }
453
454
    public function removeItem(MenuItemInterface $item) : void
455
    {
456
        $key = array_search($item, $this->items, true);
457
458
        if (false === $key) {
459
            throw new \InvalidArgumentException('Item does not exist in menu');
460
        }
461
462
        unset($this->items[$key]);
463
        $this->items = array_values($this->items);
464
    }
465
466
    public function getStyle() : MenuStyle
467
    {
468
        return $this->style;
469
    }
470
471
    public function getCurrentFrame() : Frame
472
    {
473
        return $this->currentFrame;
474
    }
475
476
    public function flash(string $text, MenuStyle $style = null) : Flash
477
    {
478
        $this->guardSingleLine($text);
479
480
        $style = $style ?? (new MenuStyle($this->terminal))
481
            ->setBg('yellow')
482
            ->setFg('red');
483
484
        return new Flash($this, $style, $this->terminal, $text);
485
    }
486
487
    public function confirm(string $text, MenuStyle $style = null) : Confirm
488
    {
489
        $this->guardSingleLine($text);
490
491
        $style = $style ?? (new MenuStyle($this->terminal))
492
            ->setBg('yellow')
493
            ->setFg('red');
494
495
        return new Confirm($this, $style, $this->terminal, $text);
496
    }
497
498
    public function askNumber(MenuStyle $style = null) : Number
499
    {
500
        $this->assertOpen();
501
502
        $style = $style ?? (new MenuStyle($this->terminal))
503
            ->setBg('yellow')
504
            ->setFg('red');
505
506
        return new Number(new InputIO($this, $this->terminal), $style);
507
    }
508
509
    public function askText(MenuStyle $style = null) : Text
510
    {
511
        $this->assertOpen();
512
513
        $style = $style ?? (new MenuStyle($this->terminal))
514
            ->setBg('yellow')
515
            ->setFg('red');
516
517
        return new Text(new InputIO($this, $this->terminal), $style);
518
    }
519
520
    public function askPassword(MenuStyle $style = null) : Password
521
    {
522
        $this->assertOpen();
523
524
        $style = $style ?? (new MenuStyle($this->terminal))
525
            ->setBg('yellow')
526
            ->setFg('red');
527
528
        return new Password(new InputIO($this, $this->terminal), $style);
529
    }
530
531
    private function guardSingleLine($text)
532
    {
533
        if (strpos($text, "\n") !== false) {
534
            throw new \InvalidArgumentException;
535
        }
536
    }
537
}
538