Completed
Pull Request — master (#78)
by Aydin
02:26
created

CliMenu::removeItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 6
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\Terminal\TerminalInterface;
20
use PhpSchool\CliMenu\Util\StringUtil as s;
21
22
/**
23
 * Class CliMenu
24
 *
25
 * @package PhpSchool\CliMenu
26
 * @author Michael Woodward <[email protected]>
27
 */
28
class CliMenu
29
{
30
    /**
31
     * @var TerminalInterface
32
     */
33
    protected $terminal;
34
35
    /**
36
     * @var MenuStyle
37
     */
38
    protected $style;
39
40
    /**
41
     * @var string
42
     */
43
    protected $title;
44
45
    /**
46
     * @var MenuItemInterface[]
47
     */
48
    protected $items = [];
49
50
    /**
51
     * @var int
52
     */
53
    protected $selectedItem;
54
55
    /**
56
     * @var bool
57
     */
58
    protected $open = false;
59
60
    /**
61
     * @var CliMenu|null
62
     */
63
    protected $parent;
64
65
    /**
66
     * @var Frame|null
67
     */
68
    private $currentFrame;
69
70
    /**
71
     * @param string $title
72
     * @param array $items
73
     * @param TerminalInterface|null $terminal
74
     * @param MenuStyle|null $style
75
     *
76
     * @throws InvalidTerminalException
77
     */
78
    public function __construct(
79
        $title,
80
        array $items,
81
        TerminalInterface $terminal = null,
82
        MenuStyle $style = null
83
    ) {
84
        $this->title      = $title;
85
        $this->items      = $items;
86
        $this->terminal   = $terminal ?: TerminalFactory::fromSystem();
87
        $this->style      = $style ?: new MenuStyle($this->terminal);
88
89
        $this->selectFirstItem();
90
    }
91
92
    /**
93
     * Configure the terminal to work with CliMenu
94
     *
95
     * @throws InvalidTerminalException
96
     */
97
    protected function configureTerminal()
98
    {
99
        $this->assertTerminalIsValidTTY();
100
101
        $this->terminal->setCanonicalMode();
102
        $this->terminal->disableCursor();
103
        $this->terminal->clear();
104
    }
105
106
    /**
107
     * Revert changes made to the terminal
108
     *
109
     * @throws InvalidTerminalException
110
     */
111
    protected function tearDownTerminal()
112
    {
113
        $this->assertTerminalIsValidTTY();
114
115
        $this->terminal->setCanonicalMode(false);
116
        $this->terminal->enableCursor();
117
    }
118
119
    private function assertTerminalIsValidTTY()
120
    {
121
        if (!$this->terminal->isTTY()) {
122
            throw new InvalidTerminalException(
123
                sprintf('Terminal "%s" is not a valid TTY', $this->terminal->getDetails())
124
            );
125
        }
126
    }
127
128
    /**
129
     * @param CliMenu $parent
130
     */
131
    public function setParent(CliMenu $parent)
132
    {
133
        $this->parent = $parent;
134
    }
135
136
    /**
137
     * @return CliMenu|null
138
     */
139
    public function getParent()
140
    {
141
        return $this->parent;
142
    }
143
144
    /**
145
     * @return TerminalInterface
146
     */
147
    public function getTerminal()
148
    {
149
        return $this->terminal;
150
    }
151
152
    /**
153
     * @return bool
154
     */
155
    public function isOpen()
156
    {
157
        return $this->open;
158
    }
159
160
    /**
161
     * Add a new Item to the listing
162
     *
163
     * @param MenuItemInterface $item
164
     */
165
    public function addItem(MenuItemInterface $item)
166
    {
167
        $this->items[] = $item;
168
        
169
        if (count($this->items) === 1) {
170
            $this->selectFirstItem();
171
        }
172
    }
173
174
    /**
175
     * Set the selected pointer to the first selectable item
176
     */
177
    private function selectFirstItem()
178
    {
179
        foreach ($this->items as $key => $item) {
180
            if ($item->canSelect()) {
181
                $this->selectedItem = $key;
182
                break;
183
            }
184
        }
185
    }
186
187
    /**
188
     * Display menu and capture input
189
     */
190
    private function display()
191
    {
192
        $this->draw();
193
194
        while ($this->isOpen() && $input = $this->terminal->getKeyedInput()) {
195
            switch ($input) {
196
                case 'up':
197
                case 'down':
198
                    $this->moveSelection($input);
199
                    $this->draw();
200
                    break;
201
                case 'enter':
202
                    $this->executeCurrentItem();
203
                    break;
204
            }
205
        }
206
    }
207
208
    /**
209
     * Move the selection in a given direction, up / down
210
     *
211
     * @param $direction
212
     */
213
    protected function moveSelection($direction)
214
    {
215
        do {
216
            $itemKeys = array_keys($this->items);
217
218
            $direction === 'up'
219
                ? $this->selectedItem--
220
                : $this->selectedItem++;
221
222
            if (!array_key_exists($this->selectedItem, $this->items)) {
223
                $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...
224
                    ? end($itemKeys)
225
                    : reset($itemKeys);
226
            } elseif ($this->getSelectedItem()->canSelect()) {
227
                return;
228
            }
229
        } while (!$this->getSelectedItem()->canSelect());
230
    }
231
232
    /**
233
     * @return MenuItemInterface
234
     */
235
    public function getSelectedItem()
236
    {
237
        return $this->items[$this->selectedItem];
238
    }
239
240
    /**
241
     * Execute the current item
242
     */
243
    protected function executeCurrentItem()
244
    {
245
        $item = $this->getSelectedItem();
246
247
        if ($item->canSelect()) {
248
            $callable = $item->getSelectAction();
249
            $callable($this);
250
        }
251
    }
252
253
    /**
254
     * Redraw the menu
255
     */
256
    public function redraw()
257
    {
258
        if (!$this->isOpen()) {
259
            throw new MenuNotOpenException;
260
        }
261
262
        $this->draw();
263
    }
264
265
    /**
266
     * Draw the menu to STDOUT
267
     */
268
    protected function draw()
269
    {
270
        $this->terminal->clean();
271
        $this->terminal->moveCursorToTop();
272
273
        $frame = new Frame;
274
275
        $frame->newLine(2);
276
277
        if (is_string($this->title)) {
278
            $frame->addRows($this->drawMenuItem(new LineBreakItem()));
279
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
280
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
281
        }
282
283
        array_map(function ($item, $index) use ($frame) {
284
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
285
        }, $this->items, array_keys($this->items));
286
287
        $frame->addRows($this->drawMenuItem(new LineBreakItem()));
288
289
        $frame->newLine(2);
290
291
        foreach ($frame->getRows() as $row) {
292
            echo $row;
293
        }
294
295
        $this->currentFrame = $frame;
296
    }
297
298
    /**
299
     * Draw a menu item
300
     *
301
     * @param MenuItemInterface $item
302
     * @param bool|false $selected
303
     * @return array
304
     */
305
    protected function drawMenuItem(MenuItemInterface $item, $selected = false)
306
    {
307
        $rows = $item->getRows($this->style, $selected);
308
309
        $setColour = $selected
310
            ? $this->style->getSelectedSetCode()
311
            : $this->style->getUnselectedSetCode();
312
313
        $unsetColour = $selected
314
            ? $this->style->getSelectedUnsetCode()
315
            : $this->style->getUnselectedUnsetCode();
316
317
        return array_map(function ($row) use ($setColour, $unsetColour) {
318
            return sprintf(
319
                "%s%s%s%s%s%s%s\n\r",
320
                str_repeat(' ', $this->style->getMargin()),
321
                $setColour,
322
                str_repeat(' ', $this->style->getPadding()),
323
                $row,
324
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
325
                $unsetColour,
326
                str_repeat(' ', $this->style->getMargin())
327
            );
328
        }, $rows);
329
    }
330
331
    /**
332
     * @throws InvalidTerminalException
333
     */
334
    public function open()
335
    {
336
        if ($this->isOpen()) {
337
            return;
338
        }
339
340
        $this->configureTerminal();
341
        $this->open = true;
342
        $this->display();
343
    }
344
345
    /**
346
     * Close the menu
347
     *
348
     * @throws InvalidTerminalException
349
     */
350
    public function close()
351
    {
352
        $menu = $this;
353
354
        do {
355
            $menu->closeThis();
356
            $menu = $menu->getParent();
357
        } while (null !== $menu);
358
        
359
        $this->tearDownTerminal();
360
    }
361
362
    /**
363
     * @throws InvalidTerminalException
364
     */
365
    public function closeThis()
366
    {
367
        $this->terminal->clean();
368
        $this->terminal->moveCursorToTop();
369
        $this->open = false;
370
    }
371
372
    /**
373
     * @return MenuItemInterface[]
374
     */
375
    public function getItems()
376
    {
377
        return $this->items;
378
    }
379
380
    /**
381
     * @param MenuItemInterface $item
382
     */
383
    public function removeItem(MenuItemInterface $item)
384
    {
385
        $key = array_search($item, $this->items);
386
387
        if (false === $key) {
388
            throw new \InvalidArgumentException('Item does not exist in menu');
389
        }
390
391
        unset($this->items[$key]);
392
        $this->items = array_values($this->items);
393
    }
394
395
    /**
396
     * @return MenuStyle
397
     */
398
    public function getStyle()
399
    {
400
        return $this->style;
401
    }
402
403
    public function getCurrentFrame()
404
    {
405
        return $this->currentFrame;
406
    }
407
408
    /**
409
     * @param string $text
410
     * @return Flash
411
     */
412 View Code Duplication
    public function flash($text)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
413
    {
414
        $this->guardSingleLine($text);
415
416
        $style = (new MenuStyle($this->terminal))
417
            ->setBg('yellow')
418
            ->setFg('red');
419
420
        return new Flash($this, $style, $this->terminal, $text);
421
    }
422
423
    /**
424
     * @param string $text
425
     * @return Confirm
426
     */
427 View Code Duplication
    public function confirm($text)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
428
    {
429
        $this->guardSingleLine($text);
430
431
        $style = (new MenuStyle($this->terminal))
432
            ->setBg('yellow')
433
            ->setFg('red');
434
435
        return new Confirm($this, $style, $this->terminal, $text);
436
    }
437
438 View Code Duplication
    public function askNumber() : Number
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
439
    {
440
        $style = (new MenuStyle($this->terminal))
441
            ->setBg('yellow')
442
            ->setFg('red');
443
444
        return new Number(new InputIO($this, $style, $this->terminal));
445
    }
446
447 View Code Duplication
    public function askText() : Text
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
448
    {
449
        $style = (new MenuStyle($this->terminal))
450
            ->setBg('yellow')
451
            ->setFg('red');
452
453
        return new Text(new InputIO($this, $style, $this->terminal));
454
    }
455
456 View Code Duplication
    public function askPassword() : Password
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
457
    {
458
        $style = (new MenuStyle($this->terminal))
459
            ->setBg('yellow')
460
            ->setFg('red');
461
462
        return new Password(new InputIO($this, $style, $this->terminal));
463
    }
464
465
    private function guardSingleLine($text)
466
    {
467
        if (strpos($text, "\n") !== false) {
468
            throw new \InvalidArgumentException;
469
        }
470
    }
471
}
472