Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 406
Editor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 30
26082
0.00% covered (danger)
0.00%
0 / 406
 new
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 3
 __get
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 3
 __debugInfo
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 13
 readKey
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 14
 selectSyntaxHighlight
0.00% covered (danger)
0.00%
0 / 1
42
0.00% covered (danger)
0.00%
0 / 12
 rowCxToRx
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 6
 rowRxToCx
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 8
 insertRow
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 14
 deleteRow
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 8
 insertChar
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 5
 insertNewline
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 10
 deleteChar
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 11
 rowsToString
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 open
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 10
 save
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 14
 findCallback
0.00% covered (danger)
0.00%
0 / 1
240
0.00% covered (danger)
0.00%
0 / 44
 find
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 10
 scroll
0.00% covered (danger)
0.00%
0 / 1
42
0.00% covered (danger)
0.00%
0 / 11
 drawRows
0.00% covered (danger)
0.00%
0 / 1
306
0.00% covered (danger)
0.00%
0 / 50
 drawStatusBar
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 19
 drawMessageBar
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 6
 refreshScreen
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 12
 setStatusMessage
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 4
 prompt
0.00% covered (danger)
0.00%
0 / 1
182
0.00% covered (danger)
0.00%
0 / 18
 moveCursor
0.00% covered (danger)
0.00%
0 / 1
272
0.00% covered (danger)
0.00%
0 / 31
 processKeypress
0.00% covered (danger)
0.00%
0 / 1
600
0.00% covered (danger)
0.00%
0 / 53
 pageUpOrDown
0.00% covered (danger)
0.00%
0 / 1
42
0.00% covered (danger)
0.00%
0 / 9
 refreshSyntax
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 refreshPHPSyntax
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 4
1<?php declare(strict_types=1);
2
3namespace Aviat\Kilo;
4
5use Aviat\Kilo\Enum\{Color, KeyCode, KeyType, Highlight};
6use Aviat\Kilo\Tokens\PHP8;
7
8/**
9 * // Don't highlight this!
10 * @property-read int numRows
11 */
12class Editor {
13    use Traits\MagicProperties;
14
15    private string $ab = '';
16
17    protected int $cursorX = 0;
18    protected int $cursorY = 0;
19    protected int $renderX = 0;
20    protected int $rowOffset = 0;
21    protected int $colOffset = 0;
22    protected int $screenRows = 0;
23    protected int $screenCols = 0;
24
25    /**
26     * Array of Row objects
27     */
28    public array $rows = [];
29
30    public int $dirty = 0;
31    public string $filename = '';
32    protected string $statusMsg = '';
33    protected int $statusMsgTime;
34
35    public ?Syntax $syntax = NULL;
36
37    // Tokens for highlighting PHP
38    public array $tokens = [];
39
40    public static function new(): Editor
41    {
42        return new self();
43    }
44
45    private function __construct()
46    {
47        $this->statusMsgTime = time();
48
49        [$this->screenRows, $this->screenCols] = get_window_size();
50
51        // Remove a row for the status bar, and one for the message bar
52        $this->screenRows -= 2;
53    }
54
55    public function __get(string $name): ?int
56    {
57        if ($name === 'numRows')
58        {
59            return count($this->rows);
60        }
61
62        return NULL;
63    }
64
65    public function __debugInfo(): array
66    {
67        return [
68            'colOffset' => $this->colOffset,
69            'cursorX' => $this->cursorX,
70            'cursorY' => $this->cursorY,
71            'dirty' => $this->dirty,
72            'filename' => $this->filename,
73            'renderX' => $this->renderX,
74            'rowOffset' => $this->rowOffset,
75            'rows' => $this->rows,
76            'screenCols' => $this->screenCols,
77            'screenRows' => $this->screenRows,
78            'statusMsg' => $this->statusMsg,
79            'syntax' => $this->syntax,
80            'tokens' => $this->tokens,
81        ];
82    }
83
84    // ------------------------------------------------------------------------
85    // ! Terminal
86    // ------------------------------------------------------------------------
87    protected function readKey(): string
88    {
89        $c = read_stdin();
90
91        return match($c)
92        {
93            // Unambiguous mappings
94            KeyCode::ARROW_DOWN => KeyType::ARROW_DOWN,
95            KeyCode::ARROW_LEFT => KeyType::ARROW_LEFT,
96            KeyCode::ARROW_RIGHT => KeyType::ARROW_RIGHT,
97            KeyCode::ARROW_UP => KeyType::ARROW_UP,
98            KeyCode::DEL_KEY => KeyType::DEL_KEY,
99            KeyCode::ENTER => KeyType::ENTER,
100            KeyCode::PAGE_DOWN => KeyType::PAGE_DOWN,
101            KeyCode::PAGE_UP => KeyType::PAGE_UP,
102
103            // Backspace
104            KeyCode::CTRL('h'), KeyCode::BACKSPACE => KeyType::BACKSPACE,
105
106            // Escape
107            KeyCode::CTRL('l'), KeyCode::ESCAPE => KeyType::ESCAPE,
108
109            // Home Key
110            "\eOH", "\e[7~", "\e[1~", ANSI::RESET_CURSOR => KeyType::HOME_KEY,
111
112            // End Key
113            "\eOF", "\e[4~", "\e[8~", "\e[F" => KeyType::END_KEY,
114
115            default => $c,
116        };
117    }
118
119    protected function selectSyntaxHighlight(): void
120    {
121        $this->syntax = NULL;
122        if (empty($this->filename))
123        {
124            return;
125        }
126
127        // In PHP, `strchr` and `strstr` are the same function
128        $ext = (string)strstr(basename($this->filename), '.');
129
130        foreach (get_file_syntax_map() as $syntax)
131        {
132            if (
133                in_array($ext, $syntax->filematch, TRUE) ||
134                in_array(basename($this->filename), $syntax->filematch, TRUE)
135            ) {
136                $this->syntax = $syntax;
137
138                // Pre-tokenize the file
139                if ($this->syntax->filetype === 'PHP')
140                {
141                    $this->tokens = PHP8::getFileTokens($this->filename);
142                }
143
144                $this->refreshSyntax();
145
146                return;
147            }
148        }
149    }
150
151    // ------------------------------------------------------------------------
152    // ! Row Operations
153    // ------------------------------------------------------------------------
154
155    protected function rowCxToRx(Row $row, int $cx): int
156    {
157        $rx = 0;
158        for ($i = 0; $i < $cx; $i++)
159        {
160            if ($row->chars[$i] === KeyCode::TAB)
161            {
162                $rx += (KILO_TAB_STOP - 1) - ($rx % KILO_TAB_STOP);
163            }
164            $rx++;
165        }
166
167        return $rx;
168    }
169
170    protected function rowRxToCx(Row $row, int $rx): int
171    {
172        $cur_rx = 0;
173        for ($cx = 0; $cx < $row->size; $cx++)
174        {
175            if ($row->chars[$cx] === KeyCode::TAB)
176            {
177                $cur_rx += (KILO_TAB_STOP - 1) - ($cur_rx % KILO_TAB_STOP);
178            }
179            $cur_rx++;
180
181            if ($cur_rx > $rx)
182            {
183                return $cx;
184            }
185        }
186
187        return $cx;
188    }
189
190    protected function insertRow(int $at, string $s, bool $updateSyntax = TRUE): void
191    {
192        if ($at < 0 || $at > $this->numRows)
193        {
194            return;
195        }
196
197        $row = Row::new($this, $s, $at);
198
199        if ($at === $this->numRows)
200        {
201            $this->rows[] = $row;
202        }
203        else
204        {
205            $this->rows = [
206                ...array_slice($this->rows, 0, $at),
207                $row,
208                ...array_slice($this->rows, $at),
209            ];
210        }
211
212        ksort($this->rows);
213
214        $this->rows[$at]->update();
215
216        $this->dirty++;
217
218        // Re-tokenize the file
219        if ($updateSyntax)
220        {
221            $this->refreshPHPSyntax();
222        }
223    }
224
225    protected function deleteRow(int $at): void
226    {
227        if ($at < 0 || $at >= $this->numRows)
228        {
229            return;
230        }
231
232        // Remove the row
233        unset($this->rows[$at]);
234
235        // Re-index the array of rows
236        $this->rows = array_values($this->rows);
237        for ($i = $at; $i < $this->numRows; $i++)
238        {
239            $this->rows[$i]->idx--;
240        }
241
242        // Re-tokenize the file
243        $this->refreshPHPSyntax();
244
245        $this->dirty++;
246    }
247
248    // ------------------------------------------------------------------------
249    // ! Editor Operations
250    // ------------------------------------------------------------------------
251
252    protected function insertChar(string $c): void
253    {
254        if ($this->cursorY === $this->numRows)
255        {
256            $this->insertRow($this->numRows, '');
257        }
258        $this->rows[$this->cursorY]->insertChar($this->cursorX, $c);
259
260        // Re-tokenize the file
261        $this->refreshPHPSyntax();
262
263        $this->cursorX++;
264    }
265
266    protected function insertNewline(): void
267    {
268        // @TODO attempt smart indentation on newline?
269
270        if ($this->cursorX === 0)
271        {
272            $this->insertRow($this->cursorY, '');
273        }
274        else
275        {
276            $row = $this->rows[$this->cursorY];
277            $chars = $row->chars;
278            $newChars = substr($chars, 0, $this->cursorX);
279
280            // Truncate the previous row
281            $row->chars = $newChars;
282
283            // Add a new row, with the contents from the cursor to the end of the line
284            $this->insertRow($this->cursorY + 1, substr($chars, $this->cursorX));
285        }
286
287        $this->cursorY++;
288        $this->cursorX = 0;
289
290        // Re-tokenize the file
291        $this->refreshPHPSyntax();
292    }
293
294    protected function deleteChar(): void
295    {
296        if ($this->cursorY === $this->numRows || ($this->cursorX === 0 && $this->cursorY === 0))
297        {
298            return;
299        }
300
301        $row = $this->rows[$this->cursorY];
302        if ($this->cursorX > 0)
303        {
304            $row->deleteChar($this->cursorX - 1);
305            $this->cursorX--;
306        }
307        else
308        {
309            $this->cursorX = $this->rows[$this->cursorY - 1]->size;
310            $this->rows[$this->cursorY -1]->appendString($row->chars);
311            $this->deleteRow($this->cursorY);
312            $this->cursorY--;
313        }
314
315        // Re-tokenize the file
316        $this->refreshPHPSyntax();
317    }
318
319    // ------------------------------------------------------------------------
320    // ! File I/O
321    // ------------------------------------------------------------------------
322
323    protected function rowsToString(): string
324    {
325        $lines = array_map(fn (Row $row) => (string)$row, $this->rows);
326
327        return implode('', $lines);
328    }
329
330    public function open(string $filename): void
331    {
332        // Copy filename for display
333        $this->filename = $filename;
334
335        $this->selectSyntaxHighlight();
336
337        $handle = fopen($filename, 'rb');
338        if ($handle === FALSE)
339        {
340            $this->setStatusMessage('Failed to open file: %s', $filename);
341            return;
342        }
343
344        while (($line = fgets($handle)) !== FALSE)
345        {
346            // Remove line endings when reading the file
347            $this->insertRow($this->numRows, rtrim($line), FALSE);
348        }
349
350        fclose($handle);
351
352        $this->dirty = 0;
353    }
354
355    protected function save(): void
356    {
357        if ($this->filename === '')
358        {
359            $newFilename = $this->prompt('Save as: %s');
360            if ($newFilename === '')
361            {
362                $this->setStatusMessage('Save aborted');
363                return;
364            }
365
366            $this->filename = $newFilename;
367            $this->selectSyntaxHighlight();
368        }
369
370        $contents = $this->rowsToString();
371
372        $res = file_put_contents($this->filename, $contents);
373        if ($res === strlen($contents))
374        {
375            $this->setStatusMessage('%d bytes written to disk', strlen($contents));
376            $this->dirty = 0;
377            return;
378        }
379
380        $this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message']);
381    }
382
383    // ------------------------------------------------------------------------
384    // ! Find
385    // ------------------------------------------------------------------------
386
387    protected function findCallback(string $query, string $key): void
388    {
389        static $lastMatch = -1;
390        static $direction = 1;
391
392        static $savedHlLine = 0;
393        static $savedHl = [];
394
395        if ( ! empty($savedHl))
396        {
397            $this->rows[$savedHlLine]->hl = $savedHl;
398            $savedHl = [];
399        }
400
401        switch ($key)
402        {
403            case KeyCode::ENTER:
404            case KeyCode::ESCAPE:
405                $lastMatch = -1;
406                $direction = 1;
407                return;
408
409            case KeyType::ARROW_DOWN:
410            case KeyType::ARROW_RIGHT:
411                $direction = 1;
412            break;
413
414            case KeyType::ARROW_UP:
415            case KeyType::ARROW_LEFT:
416                $direction = -1;
417            break;
418
419            default:
420                $lastMatch = -1;
421                $direction = 1;
422        }
423
424        if ($lastMatch === -1)
425        {
426            $direction = 1;
427        }
428
429        $current = $lastMatch;
430
431        if (empty($query))
432        {
433            return;
434        }
435
436        for ($i = 0; $i < $this->numRows; $i++)
437        {
438            $current += $direction;
439            if ($current === -1)
440            {
441                $current = $this->numRows - 1;
442            }
443            else if ($current === $this->numRows)
444            {
445                $current = 0;
446            }
447
448            $row =& $this->rows[$current];
449
450            $match = strpos($row->render, $query);
451            if ($match !== FALSE)
452            {
453                $lastMatch = $current;
454                $this->cursorY = $current;
455                $this->cursorX = $this->rowRxToCx($row, $match);
456                $this->rowOffset = $this->numRows;
457
458                $savedHlLine = $current;
459                $savedHl = $row->hl;
460                // Update the highlight array of the relevant row with the 'MATCH' type
461                array_replace_range($row->hl, $match, strlen($query), Highlight::MATCH);
462
463                break;
464            }
465        }
466    }
467
468    protected function find(): void
469    {
470        $savedCx = $this->cursorX;
471        $savedCy = $this->cursorY;
472        $savedColOff = $this->colOffset;
473        $savedRowOff = $this->rowOffset;
474
475        $query = $this->prompt('Search: %s (Use ESC/Arrows/Enter)', [$this, 'findCallback']);
476
477        // If they pressed escape, the query will be empty,
478        // restore original cursor and scroll locations
479        if ($query === '')
480        {
481            $this->cursorX = $savedCx;
482            $this->cursorY = $savedCy;
483            $this->colOffset = $savedColOff;
484            $this->rowOffset = $savedRowOff;
485        }
486    }
487
488    // ------------------------------------------------------------------------
489    // ! Output
490    // ------------------------------------------------------------------------
491
492    protected function scroll(): void
493    {
494        $this->renderX = 0;
495        if ($this->cursorY < $this->numRows)
496        {
497            $this->renderX = $this->rowCxToRx($this->rows[$this->cursorY], $this->cursorX);
498        }
499
500        // Vertical Scrolling
501        if ($this->cursorY < $this->rowOffset)
502        {
503            $this->rowOffset = $this->cursorY;
504        }
505        if ($this->cursorY >= ($this->rowOffset + $this->screenRows))
506        {
507            $this->rowOffset = $this->cursorY - $this->screenRows + 1;
508        }
509
510        // Horizontal Scrolling
511        if ($this->renderX < $this->colOffset)
512        {
513            $this->colOffset = $this->renderX;
514        }
515        if ($this->renderX >= ($this->colOffset + $this->screenCols))
516        {
517            $this->colOffset = $this->renderX - $this->screenCols + 1;
518        }
519    }
520
521    protected function drawRows(): void
522    {
523        for ($y = 0; $y < $this->screenRows; $y++)
524        {
525            $filerow = $y + $this->rowOffset;
526            if ($filerow >= $this->numRows)
527            {
528                if ($this->numRows === 0 && $y === (int)($this->screenRows / 2))
529                {
530                    $welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION);
531                    $welcomelen = strlen($welcome);
532                    if ($welcomelen > $this->screenCols)
533                    {
534                        $welcomelen = $this->screenCols;
535                    }
536
537                    $padding = ($this->screenCols - $welcomelen) / 2;
538                    if ($padding > 0)
539                    {
540                        $this->ab .= '~';
541                        $padding--;
542                    }
543                    for ($i = 0; $i < $padding; $i++)
544                    {
545                        $this->ab .= ' ';
546                    }
547
548                    $this->ab .= substr($welcome, 0, $welcomelen);
549                }
550                else
551                {
552                    $this->ab .= '~';
553                }
554            }
555            else
556            {
557                $len = $this->rows[$filerow]->rsize - $this->colOffset;
558                if ($len < 0)
559                {
560                    $len = 0;
561                }
562                if ($len > $this->screenCols)
563                {
564                    $len = $this->screenCols;
565                }
566
567                $c = substr($this->rows[$filerow]->render, $this->colOffset, $len);
568                $hl = array_slice($this->rows[$filerow]->hl, $this->colOffset, $len);
569
570                $currentColor = -1;
571
572                for ($i = 0; $i < $len; $i++)
573                {
574                    // Handle 'non-printable' characters
575                    if (is_ctrl($c[$i]))
576                    {
577                        $sym = (ord($c[$i]) <= 26)
578                            ? chr(ord('@') + ord($c[$i]))
579                            : '?';
580                        $this->ab .= ANSI::color(Color::INVERT);
581                        $this->ab .= $sym;
582                        $this->ab .= ANSI::RESET_TEXT;
583                        if ($currentColor !== -1)
584                        {
585                            $this->ab .= ANSI::color($currentColor);
586                        }
587                    }
588                    else if ($hl[$i] === Highlight::NORMAL)
589                    {
590                        if ($currentColor !== -1)
591                        {
592                            $this->ab .= ANSI::RESET_TEXT;
593                            $this->ab .= ANSI::color(Color::FG_WHITE);
594                            $currentColor = -1;
595                        }
596                        $this->ab .= $c[$i];
597                    }
598                    else
599                    {
600                        $color = syntax_to_color($hl[$i]);
601                        if ($color !== $currentColor)
602                        {
603                            $currentColor = $color;
604                            $this->ab .= ANSI::RESET_TEXT;
605                            $this->ab .= ANSI::color($color);
606                        }
607                        $this->ab .= $c[$i];
608                    }
609                }
610
611                $this->ab .= ANSI::RESET_TEXT;
612                $this->ab .= ANSI::color(Color::FG_WHITE);
613            }
614
615            $this->ab .= ANSI::CLEAR_LINE;
616            $this->ab .= "\r\n";
617        }
618    }
619
620    protected function drawStatusBar(): void
621    {
622        $this->ab .= ANSI::color(Color::INVERT);
623
624        $statusFilename = $this->filename !== '' ? $this->filename : '[No Name]';
625        $syntaxType = ($this->syntax !== NULL) ? $this->syntax->filetype : 'no ft';
626        $isDirty = ($this->dirty > 0) ? '(modified)' : '';
627        $status = sprintf('%.20s - %d lines %s', $statusFilename, $this->numRows, $isDirty);
628        $rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursorY + 1, $this->numRows);
629        $len = strlen($status);
630        $rlen = strlen($rstatus);
631        if ($len > $this->screenCols)
632        {
633            $len = $this->screenCols;
634        }
635        $this->ab .= substr($status, 0, $len);
636        while ($len < $this->screenCols)
637        {
638            if ($this->screenCols - $len === $rlen)
639            {
640                $this->ab .= substr($rstatus, 0, $rlen);
641                break;
642            }
643
644            $this->ab .= ' ';
645            $len++;
646        }
647        $this->ab .= ANSI::RESET_TEXT;
648        $this->ab .= "\r\n";
649    }
650
651    protected function drawMessageBar(): void
652    {
653        $this->ab .= ANSI::CLEAR_LINE;
654        $len = strlen($this->statusMsg);
655        if ($len > $this->screenCols)
656        {
657            $len = $this->screenCols;
658        }
659
660        if ($len > 0 && (time() - $this->statusMsgTime) < 5)
661        {
662            $this->ab .= substr($this->statusMsg, 0, $len);
663        }
664    }
665
666    public function refreshScreen(): void
667    {
668        $this->scroll();
669
670        $this->ab = '';
671
672        $this->ab .= ANSI::HIDE_CURSOR;
673        $this->ab .= ANSI::RESET_CURSOR;
674
675        $this->drawRows();
676        $this->drawStatusBar();
677        $this->drawMessageBar();
678
679        // Specify the current cursor position
680        $this->ab .= ANSI::moveCursor(
681            ($this->cursorY - $this->rowOffset) + 1,
682            ($this->renderX - $this->colOffset) + 1
683        );
684
685        $this->ab .= ANSI::SHOW_CURSOR;
686
687        echo $this->ab;
688    }
689
690    public function setStatusMessage(string $fmt, mixed ...$args): void
691    {
692        $this->statusMsg = (count($args) > 0)
693            ? sprintf($fmt, ...$args)
694            : $fmt;
695        $this->statusMsgTime = time();
696    }
697
698    // ------------------------------------------------------------------------
699    // ! Input
700    // ------------------------------------------------------------------------
701
702    protected function prompt(string $prompt, ?callable $callback = NULL): string
703    {
704        $buffer = '';
705        $modifiers = KeyType::getConstList();
706        while (TRUE)
707        {
708            $this->setStatusMessage($prompt, $buffer);
709            $this->refreshScreen();
710
711            $c = $this->readKey();
712            $isModifier = in_array($c, $modifiers, TRUE);
713
714            if ($c === KeyType::ESCAPE || ($c === KeyType::ENTER && $buffer !== ''))
715            {
716                $this->setStatusMessage('');
717                if ($callback !== NULL)
718                {
719                    $callback($buffer, $c);
720                }
721                return ($c === KeyType::ENTER) ? $buffer : '';
722            }
723
724            if ($c === KeyType::DEL_KEY || $c === KeyType::BACKSPACE)
725            {
726                $buffer = substr($buffer, 0, -1);
727            }
728            else if (is_ascii($c) && ( ! (is_ctrl($c) || $isModifier)))
729            {
730                $buffer .= $c;
731            }
732
733            if ($callback !== NULL)
734            {
735                $callback($buffer, $c);
736            }
737        }
738    }
739
740    protected function moveCursor(string $key): void
741    {
742        $row = ($this->cursorY >= $this->numRows)
743            ? NULL
744            : $this->rows[$this->cursorY];
745
746        switch ($key)
747        {
748            case KeyType::ARROW_LEFT:
749                if ($this->cursorX !== 0)
750                {
751                    $this->cursorX--;
752                }
753                else if ($this->cursorX > 0)
754                {
755                    $this->cursorY--;
756                    $this->cursorX = $this->rows[$this->cursorY]->size;
757                }
758            break;
759
760            case KeyType::ARROW_RIGHT:
761                if ($row && $this->cursorX < $row->size)
762                {
763                    $this->cursorX++;
764                }
765                else if ($row && $this->cursorX === $row->size)
766                {
767                    $this->cursorY++;
768                    $this->cursorX = 0;
769                }
770            break;
771
772            case KeyType::ARROW_UP:
773                if ($this->cursorY !== 0)
774                {
775                    $this->cursorY--;
776                }
777            break;
778
779            case KeyType::ARROW_DOWN:
780                if ($this->cursorY < $this->numRows)
781                {
782                    $this->cursorY++;
783                }
784            break;
785        }
786
787        $row = ($this->cursorY >= $this->numRows)
788            ? NULL
789            : $this->rows[$this->cursorY];
790        $rowlen = $row->size ?? 0;
791        if ($this->cursorX > $rowlen)
792        {
793            $this->cursorX = $rowlen;
794        }
795    }
796
797    public function processKeypress(): ?string
798    {
799        static $quit_times = KILO_QUIT_TIMES;
800
801        $c = $this->readKey();
802
803        if ($c === KeyCode::NULL || $c === KeyCode::EMPTY)
804        {
805            return '';
806        }
807
808        switch ($c)
809        {
810            case KeyType::ENTER:
811                $this->insertNewline();
812            break;
813
814            case KeyCode::CTRL('q'):
815                if ($this->dirty > 0 && $quit_times > 0)
816                {
817                    $this->setStatusMessage('WARNING!!! File has unsaved changes.' .
818                        'Press Ctrl-Q %d more times to quit.', $quit_times);
819                    $quit_times--;
820                    return '';
821                }
822                write_stdout(ANSI::CLEAR_SCREEN);
823                write_stdout(ANSI::RESET_CURSOR);
824                return NULL;
825            break;
826
827            case KeyCode::CTRL('s'):
828                $this->save();
829            break;
830
831            case KeyType::HOME_KEY:
832                $this->cursorX = 0;
833            break;
834
835            case KeyType::END_KEY:
836                if ($this->cursorY < $this->numRows)
837                {
838                    $this->cursorX = $this->rows[$this->cursorY]->size - 1;
839                }
840            break;
841
842            case KeyCode::CTRL('f'):
843                $this->find();
844            break;
845
846            case KeyType::BACKSPACE:
847            case KeyType::DEL_KEY:
848                if ($c === KeyType::DEL_KEY)
849                {
850                    $this->moveCursor(KeyType::ARROW_RIGHT);
851                }
852                $this->deleteChar();
853            break;
854
855            case KeyType::PAGE_UP:
856            case KeyType::PAGE_DOWN:
857                $this->pageUpOrDown($c);
858            break;
859
860            case KeyType::ARROW_UP:
861            case KeyType::ARROW_DOWN:
862            case KeyType::ARROW_LEFT:
863            case KeyType::ARROW_RIGHT:
864                $this->moveCursor($c);
865            break;
866
867            case KeyCode::CTRL('l'):
868            case KeyType::ESCAPE:
869                // Do nothing
870            break;
871
872            default:
873                $this->insertChar($c);
874            break;
875        }
876
877        $quit_times = KILO_QUIT_TIMES;
878
879        return $c;
880    }
881
882    public function pageUpOrDown(string $c): void
883    {
884        if ($c === KeyType::PAGE_UP)
885        {
886            $this->cursorY = $this->rowOffset;
887        }
888        else if ($c === KeyType::PAGE_DOWN)
889        {
890            $this->cursorY = $this->rowOffset + $this->screenRows - 1;
891            if ($this->cursorY > $this->numRows)
892            {
893                $this->cursorY = $this->numRows;
894            }
895        }
896
897        $times = $this->screenRows;
898        for (; $times > 0; $times--)
899        {
900            $this->moveCursor($c === KeyType::PAGE_UP ? KeyType::ARROW_UP : KeyType::ARROW_DOWN);
901        }
902    }
903
904    protected function refreshSyntax(): void
905    {
906        // Update the syntax highlighting for all the rows of the file
907        array_walk($this->rows, static fn (Row $row) => $row->updateSyntax());
908    }
909
910    private function refreshPHPSyntax(): void
911    {
912        if ($this->syntax->filetype !== 'PHP')
913        {
914            return;
915        }
916
917        $this->tokens = PHP8::getTokens($this->rowsToString());
918        $this->refreshSyntax();
919    }
920}