Completed
Push — master ( 627059...4c883f )
by Michael
9s
created

CliMenu::askPassword()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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