Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
0.00% |
0 / 1 |
|
0.00% |
0 / 23 |
CRAP | |
0.00% |
0 / 319 |
Editor | |
0.00% |
0 / 1 |
|
0.00% |
0 / 23 |
19182 | |
0.00% |
0 / 319 |
new | |
0.00% |
0 / 1 |
12 | |
0.00% |
0 / 3 |
|||
__construct | |
0.00% |
0 / 1 |
12 | |
0.00% |
0 / 11 |
|||
__debugInfo | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 6 |
|||
run | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 3 |
|||
setStatusMessage | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 4 |
|||
rowCxToRx | |
0.00% |
0 / 1 |
12 | |
0.00% |
0 / 6 |
|||
rowRxToCx | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 8 |
|||
save | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 11 |
|||
findCallback | |
0.00% |
0 / 1 |
182 | |
0.00% |
0 / 42 |
|||
find | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 6 |
|||
scroll | |
0.00% |
0 / 1 |
56 | |
0.00% |
0 / 13 |
|||
drawRows | |
0.00% |
0 / 1 |
12 | |
0.00% |
0 / 7 |
|||
drawRow | |
0.00% |
0 / 1 |
132 | |
0.00% |
0 / 36 |
|||
drawPlaceholderRow | |
0.00% |
0 / 1 |
42 | |
0.00% |
0 / 13 |
|||
drawStatusBar | |
0.00% |
0 / 1 |
42 | |
0.00% |
0 / 19 |
|||
drawMessageBar | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 6 |
|||
refreshScreen | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 10 |
|||
prompt | |
0.00% |
0 / 1 |
182 | |
0.00% |
0 / 18 |
|||
processKeypress | |
0.00% |
0 / 1 |
420 | |
0.00% |
0 / 34 |
|||
moveCursor | |
0.00% |
0 / 1 |
420 | |
0.00% |
0 / 46 |
|||
insertChar | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 2 |
|||
removeChar | |
0.00% |
0 / 1 |
30 | |
0.00% |
0 / 5 |
|||
quitAttempt | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 10 |
1 | <?php declare(strict_types=1); |
2 | |
3 | namespace Aviat\Kilo; |
4 | |
5 | use Aviat\Kilo\Type\TerminalSize; |
6 | use Aviat\Kilo\Enum\{ |
7 | Color, |
8 | RawKeyCode, |
9 | KeyType, |
10 | Highlight, |
11 | SearchDirection |
12 | }; |
13 | use Aviat\Kilo\Type\{Point, StatusMessage}; |
14 | |
15 | /** |
16 | * // Don't highlight this! |
17 | */ |
18 | class Editor { |
19 | /** |
20 | * @var string The screen buffer |
21 | */ |
22 | private string $outputBuffer = ''; |
23 | |
24 | /** |
25 | * @var Point The 0-based location of the cursor in the current viewport |
26 | */ |
27 | protected Point $cursor; |
28 | |
29 | /** |
30 | * @var Point The scroll offset of the file in the current viewport |
31 | */ |
32 | protected Point $offset; |
33 | |
34 | /** |
35 | * @var Document The document being edited |
36 | */ |
37 | protected Document $document; |
38 | |
39 | /** |
40 | * @var StatusMessage A disappearing status message |
41 | */ |
42 | protected StatusMessage $statusMessage; |
43 | |
44 | /** |
45 | * @var TerminalSize The size of the terminal in rows and columns |
46 | */ |
47 | protected TerminalSize $terminalSize; |
48 | |
49 | /** |
50 | * @var int The rendered cursor position |
51 | */ |
52 | protected int $renderX = 0; |
53 | |
54 | /** |
55 | * @var bool Should we stop the rendering loop? |
56 | */ |
57 | protected bool $shouldQuit = false; |
58 | |
59 | /** |
60 | * @var int The number of times to confirm you wish to quit |
61 | */ |
62 | protected int $quitTimes = KILO_QUIT_TIMES; |
63 | |
64 | /** |
65 | * Create the Editor instance with CLI arguments |
66 | * |
67 | * @param int $argc |
68 | * @param array $argv |
69 | * @return Editor |
70 | */ |
71 | public static function new(int $argc = 0, array $argv = []): Editor |
72 | { |
73 | if ($argc >= 2 && ! empty($argv[1])) |
74 | { |
75 | return new self($argv[1]); |
76 | } |
77 | |
78 | return new self(); |
79 | } |
80 | |
81 | /** |
82 | * The real constructor, ladies and gentlemen |
83 | * |
84 | * @param string|null $filename |
85 | */ |
86 | private function __construct(?string $filename = NULL) |
87 | { |
88 | $this->statusMessage = StatusMessage::from('HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find'); |
89 | $this->cursor = Point::new(); |
90 | $this->offset = Point::new(); |
91 | $this->terminalSize = Terminal::size(); |
92 | |
93 | if (is_string($filename)) |
94 | { |
95 | $maybeDocument = Document::new()->open($filename); |
96 | if ($maybeDocument === NULL) |
97 | { |
98 | $this->document = Document::new(); |
99 | $this->setStatusMessage("ERR: Could not open file: {}", $filename); |
100 | } |
101 | else |
102 | { |
103 | $this->document = $maybeDocument; |
104 | } |
105 | } |
106 | else |
107 | { |
108 | $this->document = Document::new(); |
109 | } |
110 | } |
111 | |
112 | public function __debugInfo(): array |
113 | { |
114 | return [ |
115 | 'cursor' => $this->cursor, |
116 | 'document' => $this->document, |
117 | 'offset' => $this->offset, |
118 | 'renderX' => $this->renderX, |
119 | 'terminalSize' => $this->terminalSize, |
120 | 'statusMessage' => $this->statusMessage, |
121 | ]; |
122 | } |
123 | |
124 | /** |
125 | * Start the input loop |
126 | */ |
127 | public function run(): void |
128 | { |
129 | while ( ! $this->shouldQuit) |
130 | { |
131 | $this->refreshScreen(); |
132 | $this->processKeypress(); |
133 | } |
134 | } |
135 | |
136 | /** |
137 | * Set a status message to be displayed, using printf formatting |
138 | * @param string $fmt |
139 | * @param mixed ...$args |
140 | */ |
141 | public function setStatusMessage(string $fmt, mixed ...$args): void |
142 | { |
143 | $text = func_num_args() > 1 |
144 | ? sprintf($fmt, ...$args) |
145 | : $fmt; |
146 | |
147 | $this->statusMessage = StatusMessage::from($text); |
148 | } |
149 | |
150 | // ------------------------------------------------------------------------ |
151 | // ! Row Operations |
152 | // ------------------------------------------------------------------------ |
153 | |
154 | /** |
155 | * Cursor X to Render X |
156 | * |
157 | * @param Row $row |
158 | * @param int $cx |
159 | * @return int |
160 | */ |
161 | protected function rowCxToRx(Row $row, int $cx): int |
162 | { |
163 | $rx = 0; |
164 | for ($i = 0; $i < $cx; $i++) |
165 | { |
166 | if ($row->chars[$i] === RawKeyCode::TAB) |
167 | { |
168 | $rx += (KILO_TAB_STOP - 1) - ($rx % KILO_TAB_STOP); |
169 | } |
170 | $rx++; |
171 | } |
172 | |
173 | return $rx; |
174 | } |
175 | |
176 | /** |
177 | * Render X to Cursor X |
178 | * |
179 | * @param Row $row |
180 | * @param int $rx |
181 | * @return int |
182 | */ |
183 | protected function rowRxToCx(Row $row, int $rx): int |
184 | { |
185 | $cur_rx = 0; |
186 | for ($cx = 0; $cx < $row->size; $cx++) |
187 | { |
188 | if ($row->chars[$cx] === RawKeyCode::TAB) |
189 | { |
190 | $cur_rx += (KILO_TAB_STOP - 1) - ($cur_rx % KILO_TAB_STOP); |
191 | } |
192 | $cur_rx++; |
193 | |
194 | if ($cur_rx > $rx) |
195 | { |
196 | return $cx; |
197 | } |
198 | } |
199 | |
200 | return $cx; |
201 | } |
202 | |
203 | // ------------------------------------------------------------------------ |
204 | // ! File I/O |
205 | // ------------------------------------------------------------------------ |
206 | |
207 | protected function save(): void |
208 | { |
209 | if ($this->document->filename === '') |
210 | { |
211 | $newFilename = $this->prompt('Save as: %s'); |
212 | if ($newFilename === '') |
213 | { |
214 | $this->setStatusMessage('Save aborted'); |
215 | return; |
216 | } |
217 | |
218 | $this->document->filename = $newFilename; |
219 | } |
220 | |
221 | $res = $this->document->save(); |
222 | |
223 | if ($res !== FALSE) |
224 | { |
225 | $this->setStatusMessage('%d bytes written to disk', $res); |
226 | return; |
227 | } |
228 | |
229 | $this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message'] ?? ''); |
230 | } |
231 | |
232 | // ------------------------------------------------------------------------ |
233 | // ! Find |
234 | // ------------------------------------------------------------------------ |
235 | |
236 | protected function findCallback(string $query, string $key): void |
237 | { |
238 | static $lastMatch = NO_MATCH; |
239 | static $direction = SearchDirection::FORWARD; |
240 | |
241 | static $savedHlLine = 0; |
242 | static $savedHl = []; |
243 | |
244 | if ( ! empty($savedHl)) |
245 | { |
246 | $row = $this->document->row($savedHlLine); |
247 | |
248 | if ($row->isValid()) |
249 | { |
250 | $row->hl = $savedHl; |
251 | } |
252 | |
253 | $savedHl = []; |
254 | } |
255 | |
256 | $direction = match ($key) { |
257 | KeyType::ARROW_UP, KeyType::ARROW_LEFT => SearchDirection::BACKWARD, |
258 | default => SearchDirection::FORWARD |
259 | }; |
260 | |
261 | $arrowKeys = [KeyType::ARROW_UP, KeyType::ARROW_DOWN, KeyType::ARROW_LEFT, KeyType::ARROW_RIGHT]; |
262 | |
263 | // Reset search state with non arrow-key input |
264 | if ( ! in_array($key, $arrowKeys, true)) |
265 | { |
266 | $lastMatch = NO_MATCH; |
267 | $direction = SearchDirection::FORWARD; |
268 | |
269 | if ($key === RawKeyCode::ENTER || $key === RawKeyCode::ESCAPE) |
270 | { |
271 | return; |
272 | } |
273 | } |
274 | |
275 | if ($lastMatch === NO_MATCH) |
276 | { |
277 | $direction = SearchDirection::FORWARD; |
278 | } |
279 | |
280 | $current = (int)$lastMatch; |
281 | |
282 | if (empty($query)) |
283 | { |
284 | return; |
285 | } |
286 | |
287 | for ($i = 0; $i < $this->document->numRows; $i++) |
288 | { |
289 | $current += $direction; |
290 | if ($current === -1) |
291 | { |
292 | $current = $this->document->numRows - 1; |
293 | } |
294 | else if ($current === $this->document->numRows) |
295 | { |
296 | $current = 0; |
297 | } |
298 | |
299 | $row = $this->document->row($current); |
300 | if ( ! $row->isValid()) |
301 | { |
302 | break; |
303 | } |
304 | |
305 | $match = strpos($row->render, $query); |
306 | if ($match !== FALSE) |
307 | { |
308 | $lastMatch = $current; |
309 | $this->cursor->y = (int)$current; |
310 | $this->cursor->x = $this->rowRxToCx($row, $match); |
311 | $this->offset->y = $this->document->numRows; |
312 | |
313 | $savedHlLine = $current; |
314 | $savedHl = $row->hl; |
315 | // Update the highlight array of the relevant row with the 'MATCH' type |
316 | array_replace_range($row->hl, $match, strlen($query), Highlight::MATCH); |
317 | |
318 | break; |
319 | } |
320 | } |
321 | } |
322 | |
323 | protected function find(): void |
324 | { |
325 | $savedCursor = Point::from($this->cursor); |
326 | $savedOffset = Point::from($this->offset); |
327 | |
328 | $query = $this->prompt('Search: %s (Use ESC/Arrows/Enter)', [$this, 'findCallback']); |
329 | |
330 | // If they pressed escape, the query will be empty, |
331 | // restore original cursor and scroll locations |
332 | if ($query === '') |
333 | { |
334 | $this->cursor = Point::from($savedCursor); |
335 | $this->offset = Point::from($savedOffset); |
336 | } |
337 | } |
338 | |
339 | // ------------------------------------------------------------------------ |
340 | // ! Output |
341 | // ------------------------------------------------------------------------ |
342 | |
343 | protected function scroll(): void |
344 | { |
345 | $this->renderX = 0; |
346 | if ($this->cursor->y < $this->document->numRows) |
347 | { |
348 | $row = $this->document->row($this->cursor->y); |
349 | |
350 | if ($row->isValid()) |
351 | { |
352 | $this->renderX = $this->rowCxToRx($row, $this->cursor->x); |
353 | } |
354 | |
355 | } |
356 | |
357 | // Vertical Scrolling |
358 | if ($this->cursor->y < $this->offset->y) |
359 | { |
360 | $this->offset->y = $this->cursor->y; |
361 | } |
362 | else if ($this->cursor->y >= ($this->offset->y + $this->terminalSize->rows)) |
363 | { |
364 | $this->offset->y = $this->cursor->y - $this->terminalSize->rows + 1; |
365 | } |
366 | |
367 | // Horizontal Scrolling |
368 | if ($this->renderX < $this->offset->x) |
369 | { |
370 | $this->offset->x = $this->renderX; |
371 | } |
372 | else if ($this->renderX >= ($this->offset->x + $this->terminalSize->cols)) |
373 | { |
374 | $this->offset->x = $this->renderX - $this->terminalSize->cols + 1; |
375 | } |
376 | } |
377 | |
378 | protected function drawRows(): void |
379 | { |
380 | for ($y = 0; $y < $this->terminalSize->rows; $y++) |
381 | { |
382 | $fileRow = $y + $this->offset->y; |
383 | |
384 | $this->outputBuffer .= ANSI::CLEAR_LINE; |
385 | |
386 | ($fileRow >= $this->document->numRows) |
387 | ? $this->drawPlaceholderRow($y) |
388 | : $this->drawRow($fileRow); |
389 | |
390 | $this->outputBuffer .= "\r\n"; |
391 | } |
392 | } |
393 | |
394 | protected function drawRow(int $rowIdx): void |
395 | { |
396 | $row = $this->document->row($rowIdx); |
397 | if ( ! $row->isValid()) |
398 | { |
399 | return; |
400 | } |
401 | |
402 | $len = $row->rsize - $this->offset->x; |
403 | if ($len < 0) |
404 | { |
405 | $len = 0; |
406 | } |
407 | if ($len > $this->terminalSize->cols) |
408 | { |
409 | $len = $this->terminalSize->cols; |
410 | } |
411 | |
412 | $chars = substr($row->render, $this->offset->x, (int)$len); |
413 | $hl = array_slice($row->hl, $this->offset->x, (int)$len); |
414 | |
415 | $currentColor = -1; |
416 | |
417 | for ($i = 0; $i < $len; $i++) |
418 | { |
419 | $ch = $chars[$i]; |
420 | |
421 | // Handle 'non-printable' characters |
422 | if (is_ctrl($ch)) |
423 | { |
424 | $sym = (ord($ch) <= 26) |
425 | ? chr(ord('@') + ord($ch)) |
426 | : '?'; |
427 | $this->outputBuffer .= ANSI::color(Color::INVERT); |
428 | $this->outputBuffer .= $sym; |
429 | $this->outputBuffer .= ANSI::RESET_TEXT; |
430 | if ($currentColor !== -1) |
431 | { |
432 | $this->outputBuffer .= ANSI::color($currentColor); |
433 | } |
434 | } |
435 | else if ($hl[$i] === Highlight::NORMAL) |
436 | { |
437 | if ($currentColor !== -1) |
438 | { |
439 | $this->outputBuffer .= ANSI::RESET_TEXT; |
440 | $this->outputBuffer .= ANSI::color(Color::FG_WHITE); |
441 | $currentColor = -1; |
442 | } |
443 | $this->outputBuffer .= $ch; |
444 | } |
445 | else |
446 | { |
447 | $color = syntax_to_color($hl[$i]); |
448 | if ($color !== $currentColor) |
449 | { |
450 | $currentColor = $color; |
451 | $this->outputBuffer .= ANSI::RESET_TEXT; |
452 | $this->outputBuffer .= ANSI::color($color); |
453 | } |
454 | $this->outputBuffer .= $ch; |
455 | } |
456 | } |
457 | |
458 | $this->outputBuffer .= ANSI::RESET_TEXT; |
459 | $this->outputBuffer .= ANSI::color(Color::FG_WHITE); |
460 | } |
461 | |
462 | protected function drawPlaceholderRow(int $y): void |
463 | { |
464 | if ($this->document->numRows === 0 && $y === (int)($this->terminalSize->rows / 2)) |
465 | { |
466 | $welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION); |
467 | $welcomelen = strlen($welcome); |
468 | if ($welcomelen > $this->terminalSize->cols) |
469 | { |
470 | $welcomelen = $this->terminalSize->cols; |
471 | } |
472 | |
473 | $padding = ($this->terminalSize->cols - $welcomelen) / 2; |
474 | if ($padding > 0) |
475 | { |
476 | $this->outputBuffer .= '~'; |
477 | $padding--; |
478 | } |
479 | for ($i = 0; $i < $padding; $i++) |
480 | { |
481 | $this->outputBuffer .= ' '; |
482 | } |
483 | |
484 | $this->outputBuffer .= substr($welcome, 0, $welcomelen); |
485 | } |
486 | else |
487 | { |
488 | $this->outputBuffer .= '~'; |
489 | } |
490 | } |
491 | |
492 | protected function drawStatusBar(): void |
493 | { |
494 | $this->outputBuffer .= ANSI::color(Color::INVERT); |
495 | |
496 | $statusFilename = $this->document->filename !== '' ? $this->document->filename : '[No Name]'; |
497 | $syntaxType = $this->document->fileType->name; |
498 | $isDirty = $this->document->isDirty() ? '(modified)' : ''; |
499 | $status = sprintf('%.20s - %d lines %s', $statusFilename, $this->document->numRows, $isDirty); |
500 | $rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursor->y + 1, $this->document->numRows); |
501 | $len = strlen($status); |
502 | $rlen = strlen($rstatus); |
503 | if ($len > $this->terminalSize->cols) |
504 | { |
505 | $len = $this->terminalSize->cols; |
506 | } |
507 | $this->outputBuffer .= substr($status, 0, $len); |
508 | while ($len < $this->terminalSize->cols) |
509 | { |
510 | if ($this->terminalSize->cols - $len === $rlen) |
511 | { |
512 | $this->outputBuffer .= substr($rstatus, 0, $rlen); |
513 | break; |
514 | } |
515 | |
516 | $this->outputBuffer .= ' '; |
517 | $len++; |
518 | } |
519 | $this->outputBuffer .= ANSI::RESET_TEXT; |
520 | $this->outputBuffer .= "\r\n"; |
521 | } |
522 | |
523 | protected function drawMessageBar(): void |
524 | { |
525 | $this->outputBuffer .= ANSI::CLEAR_LINE; |
526 | $len = $this->statusMessage->len; |
527 | if ($len > $this->terminalSize->cols) |
528 | { |
529 | $len = $this->terminalSize->cols; |
530 | } |
531 | |
532 | // If there is a message, and it's been less than 5 seconds since |
533 | // last screen update, show the message |
534 | if ($len > 0 && (time() - $this->statusMessage->time) < 5) |
535 | { |
536 | $this->outputBuffer .= substr($this->statusMessage->text, 0, $len); |
537 | } |
538 | } |
539 | |
540 | protected function refreshScreen(): void |
541 | { |
542 | $this->scroll(); |
543 | |
544 | $this->outputBuffer = ANSI::HIDE_CURSOR . ANSI::RESET_CURSOR; |
545 | |
546 | $this->drawRows(); |
547 | $this->drawStatusBar(); |
548 | $this->drawMessageBar(); |
549 | |
550 | // Specify the current cursor position |
551 | $this->outputBuffer .= ANSI::moveCursor( |
552 | $this->cursor->y - $this->offset->y, |
553 | $this->renderX - $this->offset->x |
554 | ); |
555 | |
556 | $this->outputBuffer .= ANSI::SHOW_CURSOR; |
557 | |
558 | Terminal::write($this->outputBuffer, strlen($this->outputBuffer)); |
559 | } |
560 | |
561 | // ------------------------------------------------------------------------ |
562 | // ! Input |
563 | // ------------------------------------------------------------------------ |
564 | |
565 | protected function prompt(string $prompt, ?callable $callback = NULL): string |
566 | { |
567 | $buffer = ''; |
568 | $modifiers = KeyType::getConstList(); |
569 | while (TRUE) |
570 | { |
571 | $this->setStatusMessage($prompt, $buffer); |
572 | $this->refreshScreen(); |
573 | |
574 | $c = Terminal::readKey(); |
575 | $isModifier = in_array($c, $modifiers, TRUE); |
576 | |
577 | if ($c === KeyType::ESCAPE || ($c === RawKeyCode::ENTER && $buffer !== '')) |
578 | { |
579 | $this->setStatusMessage(''); |
580 | if ($callback !== NULL) |
581 | { |
582 | $callback($buffer, $c); |
583 | } |
584 | return ($c === RawKeyCode::ENTER) ? $buffer : ''; |
585 | } |
586 | |
587 | if ($c === KeyType::DELETE || $c === KeyType::BACKSPACE) |
588 | { |
589 | $buffer = substr($buffer, 0, -1); |
590 | } |
591 | else if (is_ascii($c) && ( ! (is_ctrl($c) || $isModifier))) |
592 | { |
593 | $buffer .= $c; |
594 | } |
595 | |
596 | if ($callback !== NULL) |
597 | { |
598 | $callback($buffer, $c); |
599 | } |
600 | } |
601 | } |
602 | |
603 | /** |
604 | * Input processing |
605 | */ |
606 | protected function processKeypress(): void |
607 | { |
608 | $c = Terminal::readKey(); |
609 | |
610 | if ($c === RawKeyCode::NULL || $c === RawKeyCode::EMPTY) |
611 | { |
612 | return; |
613 | } |
614 | |
615 | switch ($c) |
616 | { |
617 | case RawKeyCode::CTRL('q'): |
618 | $this->quitAttempt(); |
619 | return; |
620 | |
621 | case RawKeyCode::CTRL('s'): |
622 | $this->save(); |
623 | break; |
624 | |
625 | case RawKeyCode::CTRL('f'): |
626 | $this->find(); |
627 | break; |
628 | |
629 | case KeyType::DELETE: |
630 | case KeyType::BACKSPACE: |
631 | $this->removeChar($c); |
632 | break; |
633 | |
634 | case KeyType::ARROW_UP: |
635 | case KeyType::ARROW_DOWN: |
636 | case KeyType::ARROW_LEFT: |
637 | case KeyType::ARROW_RIGHT: |
638 | case KeyType::PAGE_UP: |
639 | case KeyType::PAGE_DOWN: |
640 | case KeyType::HOME: |
641 | case KeyType::END: |
642 | $this->moveCursor($c); |
643 | break; |
644 | |
645 | case RawKeyCode::CTRL('l'): |
646 | case KeyType::ESCAPE: |
647 | // Do nothing |
648 | break; |
649 | |
650 | default: |
651 | $this->insertChar($c); |
652 | break; |
653 | } |
654 | |
655 | // Reset quit confirmation timer on different keypress |
656 | if ($this->quitTimes < KILO_QUIT_TIMES) |
657 | { |
658 | $this->quitTimes = KILO_QUIT_TIMES; |
659 | $this->setStatusMessage(''); |
660 | } |
661 | } |
662 | |
663 | // ------------------------------------------------------------------------ |
664 | // ! Editor operation helpers |
665 | // ------------------------------------------------------------------------ |
666 | |
667 | protected function moveCursor(string $key): void |
668 | { |
669 | $x = $this->cursor->x; |
670 | $y = $this->cursor->y; |
671 | $row = $this->document->row($y); |
672 | if ( ! $row->isValid()) |
673 | { |
674 | return; |
675 | } |
676 | |
677 | switch ($key) |
678 | { |
679 | case KeyType::ARROW_LEFT: |
680 | if ($x !== 0) |
681 | { |
682 | $x--; |
683 | } |
684 | else if ($y > 0) |
685 | { |
686 | // Beginning of a line, go to end of previous line |
687 | $y--; |
688 | $x = $row->size - 1; |
689 | } |
690 | break; |
691 | |
692 | case KeyType::ARROW_RIGHT: |
693 | if ($x < $row->size) |
694 | { |
695 | $x++; |
696 | } |
697 | else if ($x === $row->size) |
698 | { |
699 | $y++; |
700 | $x = 0; |
701 | } |
702 | break; |
703 | |
704 | case KeyType::ARROW_UP: |
705 | if ($y !== 0) |
706 | { |
707 | $y--; |
708 | } |
709 | break; |
710 | |
711 | case KeyType::ARROW_DOWN: |
712 | if ($y < $this->document->numRows) |
713 | { |
714 | $y++; |
715 | } |
716 | break; |
717 | |
718 | case KeyType::PAGE_UP: |
719 | $y = saturating_sub($y, $this->terminalSize->rows); |
720 | // $y = ($y > $this->terminalSize->rows) |
721 | // ? $y - $this->terminalSize->rows |
722 | // : 0; |
723 | break; |
724 | |
725 | case KeyType::PAGE_DOWN: |
726 | $y = saturating_add($y, $this->terminalSize->rows, $this->document->numRows); |
727 | // $y = ($y + $this->terminalSize->rows < $this->document->numRows) |
728 | // ? $y + $this->terminalSize->rows |
729 | // : $this->document->numRows; |
730 | break; |
731 | |
732 | case KeyType::HOME: |
733 | $x = 0; |
734 | break; |
735 | |
736 | case KeyType::END: |
737 | if ($y < $this->document->numRows) |
738 | { |
739 | $x = $row->size; |
740 | } |
741 | break; |
742 | |
743 | default: |
744 | // Do nothing |
745 | } |
746 | |
747 | // Snap cursor to the end of a row when moving |
748 | // from a longer row to a shorter one |
749 | $row = $this->document->row($y); |
750 | if ($row->isValid()) |
751 | { |
752 | if ($x > $row->size) |
753 | { |
754 | $x = $row->size; |
755 | } |
756 | |
757 | $this->cursor->x = $x; |
758 | $this->cursor->y = $y; |
759 | } |
760 | } |
761 | |
762 | protected function insertChar(string $c): void |
763 | { |
764 | $this->document->insert($this->cursor, $c); |
765 | $this->moveCursor(KeyType::ARROW_RIGHT); |
766 | } |
767 | |
768 | protected function removeChar(string $ch): void |
769 | { |
770 | if ($ch === KeyType::DELETE) |
771 | { |
772 | $this->document->delete($this->cursor); |
773 | } |
774 | |
775 | if ($ch === KeyType::BACKSPACE && ($this->cursor->x > 0 || $this->cursor->y > 0)) |
776 | { |
777 | $this->moveCursor(KeyType::ARROW_LEFT); |
778 | $this->document->delete($this->cursor); |
779 | } |
780 | } |
781 | |
782 | protected function quitAttempt(): void |
783 | { |
784 | if ($this->document->isDirty() && $this->quitTimes > 0) |
785 | { |
786 | if ($this->quitTimes === KILO_QUIT_TIMES) |
787 | { |
788 | Terminal::ding(); |
789 | } |
790 | |
791 | $this->setStatusMessage( |
792 | 'WARNING!!! File has unsaved changes. Press Ctrl-Q %d more times to quit.', |
793 | $this->quitTimes |
794 | ); |
795 | |
796 | $this->quitTimes--; |
797 | return; |
798 | } |
799 | |
800 | Terminal::clear(); |
801 | |
802 | $this->shouldQuit = true; |
803 | } |
804 | } |