Completed
Pull Request — master (#83)
by
unknown
04:01
created

CliMenu::getTerminal()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
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
        $frame = new Frame;
256
257
        $frame->newLine(2);
258
259
        if ($this->title) {
260
            $frame->addRows($this->drawMenuItem(new LineBreakItem()));
261
            $frame->addRows($this->drawMenuItem(new StaticItem($this->title)));
262
            $frame->addRows($this->drawMenuItem(new LineBreakItem($this->style->getTitleSeparator())));
263
        }
264
265
        array_map(function ($item, $index) use ($frame) {
266
            $frame->addRows($this->drawMenuItem($item, $index === $this->selectedItem));
267
        }, $this->items, array_keys($this->items));
268
269
        $frame->addRows($this->drawMenuItem(new LineBreakItem()));
270
271
        $frame->newLine(2);
272
        
273
        $this->terminal->moveCursorToTop();
274
        foreach ($frame->getRows() as $row) {
275
            if ($row == "\n") {
276
                $this->terminal->write("\033[2K");
277
            }
278
            $this->terminal->write($row);
279
        }
280
        $this->terminal->write("\033[J");
281
282
        $this->currentFrame = $frame;
283
    }
284
285
    /**
286
     * Draw a menu item
287
     */
288
    protected function drawMenuItem(MenuItemInterface $item, bool $selected = false) : array
289
    {
290
        $rows = $item->getRows($this->style, $selected);
291
292
        $setColour = $selected
293
            ? $this->style->getSelectedSetCode()
294
            : $this->style->getUnselectedSetCode();
295
296
        $unsetColour = $selected
297
            ? $this->style->getSelectedUnsetCode()
298
            : $this->style->getUnselectedUnsetCode();
299
300
        return array_map(function ($row) use ($setColour, $unsetColour) {
301
            return sprintf(
302
                "%s%s%s%s%s%s%s\n",
303
                str_repeat(' ', $this->style->getMargin()),
304
                $setColour,
305
                str_repeat(' ', $this->style->getPadding()),
306
                $row,
307
                str_repeat(' ', $this->style->getRightHandPadding(mb_strlen(s::stripAnsiEscapeSequence($row)))),
308
                $unsetColour,
309
                str_repeat(' ', $this->style->getMargin())
310
            );
311
        }, $rows);
312
    }
313
314
    /**
315
     * @throws InvalidTerminalException
316
     */
317
    public function open() : void
318
    {
319
        if ($this->isOpen()) {
320
            return;
321
        }
322
323
        $this->configureTerminal();
324
        $this->open = true;
325
        $this->display();
326
    }
327
328
    /**
329
     * Close the menu
330
     *
331
     * @throws InvalidTerminalException
332
     */
333
    public function close() : void
334
    {
335
        $menu = $this;
336
337
        do {
338
            $menu->closeThis();
339
            $menu = $menu->getParent();
340
        } while (null !== $menu);
341
        
342
        $this->tearDownTerminal();
343
    }
344
345
    public function closeThis() : void
346
    {
347
        $this->terminal->clean();
348
        $this->terminal->moveCursorToTop();
349
        $this->open = false;
350
    }
351
352
    /**
353
     * @return MenuItemInterface[]
354
     */
355
    public function getItems() : array
356
    {
357
        return $this->items;
358
    }
359
360
    public function removeItem(MenuItemInterface $item) : void
361
    {
362
        $key = array_search($item, $this->items, true);
363
364
        if (false === $key) {
365
            throw new \InvalidArgumentException('Item does not exist in menu');
366
        }
367
368
        unset($this->items[$key]);
369
        $this->items = array_values($this->items);
370
    }
371
372
    public function getStyle() : MenuStyle
373
    {
374
        return $this->style;
375
    }
376
377
    public function getCurrentFrame() : Frame
378
    {
379
        return $this->currentFrame;
380
    }
381
382
    public function flash(string $text) : Flash
383
    {
384
        $this->guardSingleLine($text);
385
386
        $style = (new MenuStyle($this->terminal))
387
            ->setBg('yellow')
388
            ->setFg('red');
389
390
        return new Flash($this, $style, $this->terminal, $text);
391
    }
392
393
    public function confirm($text) : Confirm
394
    {
395
        $this->guardSingleLine($text);
396
397
        $style = (new MenuStyle($this->terminal))
398
            ->setBg('yellow')
399
            ->setFg('red');
400
401
        return new Confirm($this, $style, $this->terminal, $text);
402
    }
403
404
    public function askNumber() : Number
405
    {
406
        $this->assertOpen();
407
408
        $style = (new MenuStyle($this->terminal))
409
            ->setBg('yellow')
410
            ->setFg('red');
411
412
        return new Number(new InputIO($this, $this->terminal), $style);
413
    }
414
415
    public function askText() : Text
416
    {
417
        $this->assertOpen();
418
419
        $style = (new MenuStyle($this->terminal))
420
            ->setBg('yellow')
421
            ->setFg('red');
422
423
        return new Text(new InputIO($this, $this->terminal), $style);
424
    }
425
426
    public function askPassword() : Password
427
    {
428
        $this->assertOpen();
429
430
        $style = (new MenuStyle($this->terminal))
431
            ->setBg('yellow')
432
            ->setFg('red');
433
434
        return new Password(new InputIO($this, $this->terminal), $style);
435
    }
436
437
    private function guardSingleLine($text)
438
    {
439
        if (strpos($text, "\n") !== false) {
440
            throw new \InvalidArgumentException;
441
        }
442
    }
443
}
444