Completed
Push — master ( 3d83d9...3b6426 )
by Aydin
03:10 queued 01:27
created

CliMenu   F

Complexity

Total Complexity 62

Size/Duplication

Total Lines 437
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 15

Importance

Changes 0
Metric Value
wmc 62
lcom 1
cbo 15
dl 0
loc 437
rs 3.8461
c 0
b 0
f 0

33 Methods

Rating   Name   Duplication   Size   Complexity  
A selectFirstItem() 0 9 3
A getStyle() 0 4 1
A getCurrentFrame() 0 4 1
A __construct() 0 13 3
A configureTerminal() 0 9 1
A tearDownTerminal() 0 4 1
A assertTerminalIsValidTTY() 0 6 2
A setParent() 0 4 1
A getParent() 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
C display() 0 31 7
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 29 3
B drawMenuItem() 0 25 3
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 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 Frame
68
     */
69
    private $currentFrame;
70
71
    public function __construct(
72
        ?string $title,
73
        array $items,
74
        Terminal $terminal = null,
75
        MenuStyle $style = null
76
    ) {
77
        $this->title      = $title;
78
        $this->items      = $items;
79
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
80
        $this->style      = $style ?: new MenuStyle($this->terminal);
81
82
        $this->selectFirstItem();
83
    }
84
85
    /**
86
     * Configure the terminal to work with CliMenu
87
     */
88
    protected function configureTerminal() : void
89
    {
90
        $this->assertTerminalIsValidTTY();
91
92
        $this->terminal->disableCanonicalMode();
93
        $this->terminal->disableEchoBack();
94
        $this->terminal->disableCursor();
95
        $this->terminal->clear();
96
    }
97
98
    /**
99
     * Revert changes made to the terminal
100
     */
101
    protected function tearDownTerminal() : void
102
    {
103
        $this->terminal->restoreOriginalConfiguration();
104
    }
105
106
    private function assertTerminalIsValidTTY() : void
107
    {
108
        if (!$this->terminal->isInteractive()) {
109
            throw new InvalidTerminalException('Terminal is not interactive (TTY)');
110
        }
111
    }
112
113
114
    public function setParent(CliMenu $parent) : void
115
    {
116
        $this->parent = $parent;
117
    }
118
119
    public function getParent() : ?CliMenu
120
    {
121
        return $this->parent;
122
    }
123
124
    public function getTerminal() : Terminal
125
    {
126
        return $this->terminal;
127
    }
128
129
    public function isOpen() : bool
130
    {
131
        return $this->open;
132
    }
133
134
    /**
135
     * Add a new Item to the menu
136
     */
137
    public function addItem(MenuItemInterface $item) : void
138
    {
139
        $this->items[] = $item;
140
        
141
        if (count($this->items) === 1) {
142
            $this->selectFirstItem();
143
        }
144
    }
145
146
    /**
147
     * Add multiple Items to the menu
148
     */
149
    public function addItems(array $items) : void
150
    {
151
        foreach ($items as $item) {
152
            $this->items[] = $item;
153
        }
154
155
        if (count($this->items) === count($items)) {
156
            $this->selectFirstItem();
157
        }
158
    }
159
160
    /**
161
     * Set Items of the menu
162
     */
163
    public function setItems(array $items) : void
164
    {
165
        $this->items = $items;
166
167
        $this->selectFirstItem();
168
    }
169
170
    /**
171
     * Set the selected pointer to the first selectable item
172
     */
173
    private function selectFirstItem() : void
174
    {
175
        foreach ($this->items as $key => $item) {
176
            if ($item->canSelect()) {
177
                $this->selectedItem = $key;
178
                break;
179
            }
180
        }
181
    }
182
183
    /**
184
     * Display menu and capture input
185
     */
186
    private function display() : void
187
    {
188
        $this->draw();
189
190
        $reader = new NonCanonicalReader($this->terminal);
191
        $reader->addControlMappings([
192
            '^P' => InputCharacter::UP,
193
            'k'  => InputCharacter::UP,
194
            '^K' => InputCharacter::DOWN,
195
            'j'  => InputCharacter::DOWN,
196
            "\r" => InputCharacter::ENTER,
197
            ' '  => InputCharacter::ENTER,
198
        ]);
199
200
        while ($this->isOpen() && $char = $reader->readCharacter()) {
201
            if (!$char->isHandledControl()) {
202
                continue;
203
            }
204
205
            switch ($char->getControl()) {
206
                case InputCharacter::UP:
207
                case InputCharacter::DOWN:
208
                    $this->moveSelection($char->getControl());
209
                    $this->draw();
210
                    break;
211
                case InputCharacter::ENTER:
212
                    $this->executeCurrentItem();
213
                    break;
214
            }
215
        }
216
    }
217
218
    /**
219
     * Move the selection in a given direction, up / down
220
     */
221
    protected function moveSelection(string $direction) : void
222
    {
223
        do {
224
            $itemKeys = array_keys($this->items);
225
226
            $direction === 'UP'
227
                ? $this->selectedItem--
228
                : $this->selectedItem++;
229
230
            if (!array_key_exists($this->selectedItem, $this->items)) {
231
                $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...
232
                    ? end($itemKeys)
233
                    : reset($itemKeys);
234
            } elseif ($this->getSelectedItem()->canSelect()) {
235
                return;
236
            }
237
        } while (!$this->getSelectedItem()->canSelect());
238
    }
239
240
    public function getSelectedItem() : MenuItemInterface
241
    {
242
        return $this->items[$this->selectedItem];
243
    }
244
245
    /**
246
     * Execute the current item
247
     */
248
    protected function executeCurrentItem() : void
249
    {
250
        $item = $this->getSelectedItem();
251
252
        if ($item->canSelect()) {
253
            $callable = $item->getSelectAction();
254
            $callable($this);
255
        }
256
    }
257
258
    /**
259
     * Redraw the menu
260
     */
261
    public function redraw() : void
262
    {
263
        $this->assertOpen();
264
        $this->draw();
265
    }
266
267
    private function assertOpen() : void
268
    {
269
        if (!$this->isOpen()) {
270
            throw new MenuNotOpenException;
271
        }
272
    }
273
274
    /**
275
     * Draw the menu to STDOUT
276
     */
277
    protected function draw() : void
278
    {
279
        $this->terminal->clean();
280
        $this->terminal->moveCursorToTop();
281
282
        $frame = new Frame;
283
284
        $frame->newLine(2);
285
286
        if ($this->title) {
287
            $frame->addRows($this->drawMenuItem(new LineBreakItem()));
288
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
289
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
290
        }
291
292
        array_map(function ($item, $index) use ($frame) {
293
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
294
        }, $this->items, array_keys($this->items));
295
296
        $frame->addRows($this->drawMenuItem(new LineBreakItem()));
297
298
        $frame->newLine(2);
299
300
        foreach ($frame->getRows() as $row) {
301
            $this->terminal->write($row);
302
        }
303
304
        $this->currentFrame = $frame;
305
    }
306
307
    /**
308
     * Draw a menu item
309
     */
310
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
311
    {
312
        $rows = $item->getRows($this->style, $selected);
313
314
        $setColour = $selected
315
            ? $this->style->getSelectedSetCode()
316
            : $this->style->getUnselectedSetCode();
317
318
        $unsetColour = $selected
319
            ? $this->style->getSelectedUnsetCode()
320
            : $this->style->getUnselectedUnsetCode();
321
322
        return array_map(function ($row) use ($setColour, $unsetColour) {
323
            return sprintf(
324
                "%s%s%s%s%s%s%s\n",
325
                str_repeat(' ', $this->style->getMargin()),
326
                $setColour,
327
                str_repeat(' ', $this->style->getPadding()),
328
                $row,
329
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
330
                $unsetColour,
331
                str_repeat(' ', $this->style->getMargin())
332
            );
333
        }, $rows);
334
    }
335
336
    /**
337
     * @throws InvalidTerminalException
338
     */
339
    public function open() : void
340
    {
341
        if ($this->isOpen()) {
342
            return;
343
        }
344
345
        $this->configureTerminal();
346
        $this->open = true;
347
        $this->display();
348
    }
349
350
    /**
351
     * Close the menu
352
     *
353
     * @throws InvalidTerminalException
354
     */
355
    public function close() : void
356
    {
357
        $menu = $this;
358
359
        do {
360
            $menu->closeThis();
361
            $menu = $menu->getParent();
362
        } while (null !== $menu);
363
        
364
        $this->tearDownTerminal();
365
    }
366
367
    public function closeThis() : void
368
    {
369
        $this->terminal->clean();
370
        $this->terminal->moveCursorToTop();
371
        $this->open = false;
372
    }
373
374
    /**
375
     * @return MenuItemInterface[]
376
     */
377
    public function getItems() : array
378
    {
379
        return $this->items;
380
    }
381
382
    public function removeItem(MenuItemInterface $item) : void
383
    {
384
        $key = array_search($item, $this->items, true);
385
386
        if (false === $key) {
387
            throw new \InvalidArgumentException('Item does not exist in menu');
388
        }
389
390
        unset($this->items[$key]);
391
        $this->items = array_values($this->items);
392
    }
393
394
    public function getStyle() : MenuStyle
395
    {
396
        return $this->style;
397
    }
398
399
    public function getCurrentFrame() : Frame
400
    {
401
        return $this->currentFrame;
402
    }
403
404
    public function flash(string $text) : Flash
405
    {
406
        $this->guardSingleLine($text);
407
408
        $style = (new MenuStyle($this->terminal))
409
            ->setBg('yellow')
410
            ->setFg('red');
411
412
        return new Flash($this, $style, $this->terminal, $text);
413
    }
414
415
    public function confirm($text) : Confirm
416
    {
417
        $this->guardSingleLine($text);
418
419
        $style = (new MenuStyle($this->terminal))
420
            ->setBg('yellow')
421
            ->setFg('red');
422
423
        return new Confirm($this, $style, $this->terminal, $text);
424
    }
425
426
    public function askNumber() : Number
427
    {
428
        $this->assertOpen();
429
430
        $style = (new MenuStyle($this->terminal))
431
            ->setBg('yellow')
432
            ->setFg('red');
433
434
        return new Number(new InputIO($this, $this->terminal), $style);
435
    }
436
437
    public function askText() : Text
438
    {
439
        $this->assertOpen();
440
441
        $style = (new MenuStyle($this->terminal))
442
            ->setBg('yellow')
443
            ->setFg('red');
444
445
        return new Text(new InputIO($this, $this->terminal), $style);
446
    }
447
448
    public function askPassword() : Password
449
    {
450
        $this->assertOpen();
451
452
        $style = (new MenuStyle($this->terminal))
453
            ->setBg('yellow')
454
            ->setFg('red');
455
456
        return new Password(new InputIO($this, $this->terminal), $style);
457
    }
458
459
    private function guardSingleLine($text)
460
    {
461
        if (strpos($text, "\n") !== false) {
462
            throw new \InvalidArgumentException;
463
        }
464
    }
465
}
466