Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
0.00% |
0 / 1 |
|
0.00% |
0 / 30 |
CRAP | |
0.00% |
0 / 406 |
Editor | |
0.00% |
0 / 1 |
|
0.00% |
0 / 30 |
26082 | |
0.00% |
0 / 406 |
new | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 1 |
|||
__construct | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 3 |
|||
__get | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 3 |
|||
__debugInfo | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 13 |
|||
readKey | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 14 |
|||
selectSyntaxHighlight | |
0.00% |
0 / 1 |
42 | |
0.00% |
0 / 12 |
|||
rowCxToRx | |
0.00% |
0 / 1 |
12 | |
0.00% |
0 / 6 |
|||
rowRxToCx | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 8 |
|||
insertRow | |
0.00% |
0 / 1 |
30 | |
0.00% |
0 / 14 |
|||
deleteRow | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 8 |
|||
insertChar | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 5 |
|||
insertNewline | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 10 |
|||
deleteChar | |
0.00% |
0 / 1 |
30 | |
0.00% |
0 / 11 |
|||
rowsToString | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 2 |
|||
open | |
0.00% |
0 / 1 |
12 | |
0.00% |
0 / 10 |
|||
save | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 14 |
|||
findCallback | |
0.00% |
0 / 1 |
240 | |
0.00% |
0 / 44 |
|||
find | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 10 |
|||
scroll | |
0.00% |
0 / 1 |
42 | |
0.00% |
0 / 11 |
|||
drawRows | |
0.00% |
0 / 1 |
306 | |
0.00% |
0 / 50 |
|||
drawStatusBar | |
0.00% |
0 / 1 |
56 | |
0.00% |
0 / 19 |
|||
drawMessageBar | |
0.00% |
0 / 1 |
20 | |
0.00% |
0 / 6 |
|||
refreshScreen | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 12 |
|||
setStatusMessage | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 4 |
|||
prompt | |
0.00% |
0 / 1 |
182 | |
0.00% |
0 / 18 |
|||
moveCursor | |
0.00% |
0 / 1 |
272 | |
0.00% |
0 / 31 |
|||
processKeypress | |
0.00% |
0 / 1 |
600 | |
0.00% |
0 / 53 |
|||
pageUpOrDown | |
0.00% |
0 / 1 |
42 | |
0.00% |
0 / 9 |
|||
refreshSyntax | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 1 |
|||
refreshPHPSyntax | |
0.00% |
0 / 1 |
6 | |
0.00% |
0 / 4 |
1 | <?php declare(strict_types=1); |
2 | |
3 | namespace Aviat\Kilo; |
4 | |
5 | use Aviat\Kilo\Enum\{Color, KeyCode, KeyType, Highlight}; |
6 | use Aviat\Kilo\Tokens\PHP8; |
7 | |
8 | /** |
9 | * // Don't highlight this! |
10 | * @property-read int numRows |
11 | */ |
12 | class 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 | } |