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