statusMsgTime = time(); [$this->screenRows, $this->screenCols] = get_window_size(); // Remove a row for the status bar, and one for the message bar $this->screenRows -= 2; } public function __get(string $name) { if ($name === 'numRows') { return count($this->rows); } return NULL; } public function __debugInfo(): array { return [ 'colOffset' => $this->colOffset, 'cursorX' => $this->cursorX, 'cursorY' => $this->cursorY, 'dirty' => $this->dirty, 'filename' => $this->filename, 'renderX' => $this->renderX, 'rowOffset' => $this->rowOffset, 'rows' => $this->rows, 'screenCols' => $this->screenCols, 'screenRows' => $this->screenRows, 'statusMsg' => $this->statusMsg, 'syntax' => $this->syntax, 'tokens' => $this->tokens, ]; } // ------------------------------------------------------------------------ // ! Terminal // ------------------------------------------------------------------------ protected function readKey(): string { $c = read_stdin(); // @TODO Make this more DRY switch ($c) { case "\x7f": return Key::BACKSPACE; case "\r": return Key::ENTER; case "\x1b[A": return Key::ARROW_UP; case "\x1b[B": return Key::ARROW_DOWN; case "\x1b[C": return Key::ARROW_RIGHT; case "\x1b[D": return Key::ARROW_LEFT; case "\x1b[3~": return Key::DEL_KEY; case "\x1b[5~": return Key::PAGE_UP; case "\x1b[6~": return Key::PAGE_DOWN; case "\x1bOH": case "\x1b[1~": case "\x1b[7~": case "\x1b[H": return Key::HOME_KEY; case "\x1bOF": case "\x1b[4~": case "\x1b[8~": case "\x1b[F": return Key::END_KEY; case "\x1b": return Key::ESCAPE; default: return $c; } } protected function selectSyntaxHighlight(): void { $this->syntax = NULL; if (empty($this->filename)) { return; } // In PHP, `strchr` and `strstr` are the same function $ext = (string)strstr($this->filename, '.'); foreach (get_file_syntax_map() as $syntax) { foreach ($syntax->filematch as $searchExt) { $is_ext = (strpos($searchExt, '.') === 0); if ( ($is_ext && ( ! strcmp($ext, $searchExt))) || (( ! $is_ext) && strpos($this->filename, $searchExt) !== FALSE) ) { $this->syntax = $syntax; // Pre-tokenize the file if ($this->syntax->filetype === 'PHP') { $this->tokens = PHP::getFileTokens($this->filename); } // Update the syntax highlighting for all the rows of the file for ($i = 0; $i < $this->numRows; $i++) { // @codeCoverageIgnoreStart $this->rows[$i]->updateSyntax(); // @codeCoverageIgnoreEnd } return; } } } } // ------------------------------------------------------------------------ // ! Row Operations // ------------------------------------------------------------------------ protected function rowCxToRx(Row $row, int $cx): int { $rx = 0; for ($i = 0; $i < $cx; $i++) { if ($row->chars[$i] === "\t") { $rx += (KILO_TAB_STOP - 1) - ($rx % KILO_TAB_STOP); } $rx++; } return $rx; } protected function rowRxToCx(Row $row, int $rx): int { $cur_rx = 0; for ($cx = 0; $cx < $row->size; $cx++) { if ($row->chars[$cx] === "\t") { $cur_rx += (KILO_TAB_STOP - 1) - ($cur_rx % KILO_TAB_STOP); } $cur_rx++; if ($cur_rx > $rx) { return $cx; } } return $cx; } protected function insertRow(int $at, string $s, bool $updateSyntax = TRUE): void { if ($at < 0 || $at > $this->numRows) { return; } $row = Row::new($this, $s, $at); if ($at === $this->numRows) { $this->rows[] = $row; } else { $this->rows = [ ...array_slice($this->rows, 0, $at), $row, ...array_slice($this->rows, $at), ]; } ksort($this->rows); // Update row indexes for ($i = 0; $i < $this->numRows; $i++) { $this->rows[$i]->idx = $i; } $this->rows[$at]->update(); $this->dirty++; // Re-tokenize the file if ($updateSyntax) { $this->refreshPHPSyntax(); } } protected function deleteRow(int $at): void { if ($at < 0 || $at >= $this->numRows) { return; } // Remove the row unset($this->rows[$at]); // Re-index the array of rows $this->rows = array_values($this->rows); for ($i = $at; $i < $this->numRows; $i++) { $this->rows[$i]->idx--; } // Re-tokenize the file $this->refreshPHPSyntax(); $this->dirty++; } // ------------------------------------------------------------------------ // ! Editor Operations // ------------------------------------------------------------------------ protected function insertChar(string $c): void { if ($this->cursorY === $this->numRows) { $this->insertRow($this->numRows, ''); } $this->rows[$this->cursorY]->insertChar($this->cursorX, $c); // Re-tokenize the file $this->refreshPHPSyntax(); $this->cursorX++; } protected function insertNewline(): void { if ($this->cursorX === 0) { $this->insertRow($this->cursorY, ''); } else { $row = $this->rows[$this->cursorY]; $chars = $row->chars; $newChars = substr($chars, 0, $this->cursorX); // Truncate the previous row $row->chars = $newChars; // Add a new row, with the contents from the cursor to the end of the line $this->insertRow($this->cursorY + 1, substr($chars, $this->cursorX)); } $this->cursorY++; $this->cursorX = 0; // Re-tokenize the file $this->refreshPHPSyntax(); } protected function deleteChar(): void { if ($this->cursorY === $this->numRows || ($this->cursorX === 0 && $this->cursorY === 0)) { return; } $row = $this->rows[$this->cursorY]; if ($this->cursorX > 0) { $row->deleteChar($this->cursorX - 1); $this->cursorX--; } else { $this->cursorX = $this->rows[$this->cursorY - 1]->size; $this->rows[$this->cursorY -1]->appendString($row->chars); $this->deleteRow($this->cursorY); $this->cursorY--; } // Re-tokenize the file $this->refreshPHPSyntax(); } // ------------------------------------------------------------------------ // ! File I/O // ------------------------------------------------------------------------ protected function rowsToString(): string { $lines = array_map(fn (Row $row) => (string)$row, $this->rows); return implode('', $lines); } public function open(string $filename): void { // Copy filename for display $this->filename = $filename; $this->selectSyntaxHighlight(); // #TODO gracefully handle issues with loading a file $handle = fopen($filename, 'rb'); if ($handle === FALSE) { write_stdout("\x1b[2J"); // Clear the screen write_stdout("\x1b[H"); // Reposition cursor to top-left Termios::disableRawMode(); print_r(error_get_last()); die(); } while (($line = fgets($handle)) !== FALSE) { // Remove line endings when reading the file $this->insertRow($this->numRows, rtrim($line), FALSE); } fclose($handle); $this->dirty = 0; } protected function save(): void { if ($this->filename === '') { $newFilename = $this->prompt('Save as: %s'); if ($newFilename === '') { $this->setStatusMessage('Save aborted'); return; } $this->filename = $newFilename; $this->selectSyntaxHighlight(); } $contents = $this->rowsToString(); $res = file_put_contents($this->filename, $contents); if ($res === strlen($contents)) { $this->setStatusMessage('%d bytes written to disk', strlen($contents)); $this->dirty = 0; return; } $this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message']); } // ------------------------------------------------------------------------ // ! Find // ------------------------------------------------------------------------ protected function findCallback(string $query, string $key): void { static $lastMatch = -1; static $direction = 1; static $savedHlLine = 0; static $savedHl = []; if ( ! empty($savedHl)) { $this->rows[$savedHlLine]->hl = $savedHl; $savedHl = []; } if ($key === "\r" || $key === "\x1b") { $lastMatch = -1; $direction = 1; return; } if ($key === Key::ARROW_RIGHT || $key === Key::ARROW_DOWN) { $direction = 1; } else if ($key === Key::ARROW_LEFT || $key === Key::ARROW_UP) { $direction = -1; } else { $lastMatch = -1; $direction = 1; } if ($lastMatch === -1) { $direction = 1; } if (empty($query)) { return; } $current = $lastMatch; for ($i = 0; $i < $this->numRows; $i++) { $current += $direction; if ($current === -1) { $current = $this->numRows - 1; } else if ($current === $this->numRows) { $current = 0; } $row = $this->rows[$current]; $match = strpos($row->render, $query); if ($match !== FALSE) { $lastMatch = $current; $this->cursorY = $current; $this->cursorX = $this->rowRxToCx($row, $match); $this->rowOffset = $this->numRows; $savedHlLine = $current; $savedHl = $row->hl; // Update the highlight array of the relevant row with the 'MATCH' type array_replace_range($row->hl, $match, strlen($query), Highlight::MATCH); break; } } } protected function find(): void { $savedCx = $this->cursorX; $savedCy = $this->cursorY; $savedColOff = $this->colOffset; $savedRowOff = $this->rowOffset; $query = $this->prompt('Search: %s (Use ESC/Arrows/Enter)', [$this, 'findCallback']); // If they pressed escape, the query will be empty, // restore original cursor and scroll locations if ($query === '') { $this->cursorX = $savedCx; $this->cursorY = $savedCy; $this->colOffset = $savedColOff; $this->rowOffset = $savedRowOff; } } // ------------------------------------------------------------------------ // ! Output // ------------------------------------------------------------------------ protected function scroll(): void { $this->renderX = 0; if ($this->cursorY < $this->numRows) { $this->renderX = $this->rowCxToRx($this->rows[$this->cursorY], $this->cursorX); } // Vertical Scrolling if ($this->cursorY < $this->rowOffset) { $this->rowOffset = $this->cursorY; } if ($this->cursorY >= ($this->rowOffset + $this->screenRows)) { $this->rowOffset = $this->cursorY - $this->screenRows + 1; } // Horizontal Scrolling if ($this->renderX < $this->colOffset) { $this->colOffset = $this->renderX; } if ($this->renderX >= ($this->colOffset + $this->screenCols)) { $this->colOffset = $this->renderX - $this->screenCols + 1; } } protected function drawRows(): void { for ($y = 0; $y < $this->screenRows; $y++) { $filerow = $y + $this->rowOffset; if ($filerow >= $this->numRows) { if ($this->numRows === 0 && $y === (int)($this->screenRows / 2)) { $welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION); $welcomelen = strlen($welcome); if ($welcomelen > $this->screenCols) { $welcomelen = $this->screenCols; } $padding = ($this->screenCols - $welcomelen) / 2; if ($padding > 0) { $this->ab .= '~'; $padding--; } for ($i = 0; $i < $padding; $i++) { $this->ab .= ' '; } $this->ab .= substr($welcome, 0, $welcomelen); } else { $this->ab .= '~'; } } else { $len = $this->rows[$filerow]->rsize - $this->colOffset; if ($len < 0) { $len = 0; } if ($len > $this->screenCols) { $len = $this->screenCols; } $c = substr($this->rows[$filerow]->render, $this->colOffset, $len); $hl = array_slice($this->rows[$filerow]->hl, $this->colOffset, $len); $currentColor = -1; for ($i = 0; $i < $len; $i++) { // Handle 'non-printable' characters if (is_cntrl($c[$i])) { $sym = (ord($c[$i]) <= 26) ? chr(ord('@') + ord($c[$i])) : '?'; $this->ab .= "\x1b[7m"; $this->ab .= $sym; $this->ab .= "\x1b[m"; if ($currentColor !== -1) { $this->ab .= sprintf("\x1b%dm", $currentColor); } } else if ($hl[$i] === Highlight::NORMAL) { if ($currentColor !== -1) { $this->ab .= "\x1b[0m"; // Reset background color $this->ab .= "\x1b[39m"; // Reset foreground color $currentColor = -1; } $this->ab .= $c[$i]; } else { $color = syntax_to_color($hl[$i]); if ($color !== $currentColor) { $currentColor = $color; $this->ab .= "\x1b[0m"; // Reset background color $this->ab .= sprintf("\x1b[%dm", $color); } $this->ab .= $c[$i]; } } $this->ab .= "\x1b[0m"; $this->ab .= "\x1b[39m"; } $this->ab .= "\x1b[K"; // Clear the current line $this->ab .= "\r\n"; } } protected function drawStatusBar(): void { $this->ab .= "\x1b[7m"; $statusFilename = $this->filename !== '' ? $this->filename : '[No Name]'; $syntaxType = ($this->syntax !== NULL) ? $this->syntax->filetype : 'no ft'; $isDirty = ($this->dirty > 0) ? '(modified)' : ''; $status = sprintf('%.20s - %d lines %s', $statusFilename, $this->numRows, $isDirty); $rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursorY + 1, $this->numRows); $len = strlen($status); $rlen = strlen($rstatus); if ($len > $this->screenCols) { $len = $this->screenCols; } $this->ab .= substr($status, 0, $len); while ($len < $this->screenCols) { if ($this->screenCols - $len === $rlen) { $this->ab .= substr($rstatus, 0, $rlen); break; } $this->ab .= ' '; $len++; } $this->ab .= "\x1b[m"; $this->ab .= "\r\n"; } protected function drawMessageBar(): void { $this->ab .= "\x1b[K"; $len = strlen($this->statusMsg); if ($len > $this->screenCols) { $len = $this->screenCols; } if ($len > 0 && (time() - $this->statusMsgTime) < 5) { $this->ab .= substr($this->statusMsg, 0, $len); } } public function refreshScreen(): void { $this->scroll(); $this->ab = ''; $this->ab .= "\x1b[?25l"; // Hide the cursor $this->ab .= "\x1b[H"; // Reposition cursor to top-left $this->drawRows(); $this->drawStatusBar(); $this->drawMessageBar(); // Specify the current cursor position $this->ab .= sprintf("\x1b[%d;%dH", ($this->cursorY - $this->rowOffset) + 1, ($this->renderX - $this->colOffset) + 1 ); $this->ab .= "\x1b[?25h"; // Show the cursor echo $this->ab; } public function setStatusMessage(string $fmt, ...$args): void { $this->statusMsg = (count($args) > 0) ? sprintf($fmt, ...$args) : $fmt; $this->statusMsgTime = time(); } // ------------------------------------------------------------------------ // ! Input // ------------------------------------------------------------------------ protected function prompt(string $prompt, ?callable $callback = NULL): string { $buffer = ''; $modifiers = Key::getConstList(); while (TRUE) { $this->setStatusMessage($prompt, $buffer); $this->refreshScreen(); $c = $this->readKey(); if ($c === Key::ESCAPE) { $this->setStatusMessage(''); if ($callback !== NULL) { $callback($buffer, $c); } return ''; } if ($c === Key::ENTER && $buffer !== '') { $this->setStatusMessage(''); if ($callback !== NULL) { $callback($buffer, $c); } return $buffer; } if ($c === Key::DEL_KEY || $c === Key::BACKSPACE || $c === chr(ctrl_key('h'))) { $buffer = substr($buffer, 0, -1); } else if (is_ascii($c) && ( ! is_cntrl($c)) && ! in_array($c, $modifiers, TRUE)) { $buffer .= $c; } if ($callback !== NULL) { $callback($buffer, $c); } } } protected function moveCursor(string $key): void { $row = ($this->cursorY >= $this->numRows) ? NULL : $this->rows[$this->cursorY]; switch ($key) { case Key::ARROW_LEFT: if ($this->cursorX !== 0) { $this->cursorX--; } else if ($this->cursorX > 0) { $this->cursorY--; $this->cursorX = $this->rows[$this->cursorY]->size; } break; case Key::ARROW_RIGHT: if ($row && $this->cursorX < $row->size) { $this->cursorX++; } else if ($row && $this->cursorX === $row->size) { $this->cursorY++; $this->cursorX = 0; } break; case Key::ARROW_UP: if ($this->cursorY !== 0) { $this->cursorY--; } break; case Key::ARROW_DOWN: if ($this->cursorY < $this->numRows) { $this->cursorY++; } break; } $row = ($this->cursorY >= $this->numRows) ? NULL : $this->rows[$this->cursorY]; $rowlen = $row ? $row->size : 0; if ($this->cursorX > $rowlen) { $this->cursorX = $rowlen; } } public function processKeypress(): ?string { static $quit_times = KILO_QUIT_TIMES; $c = $this->readKey(); if ($c === "\0" || $c === '') { return ''; } switch ($c) { case Key::ENTER: $this->insertNewline(); break; case chr(ctrl_key('q')): if ($this->dirty > 0 && $quit_times > 0) { $this->setStatusMessage('WARNING!!! File has unsaved changes.' . 'Press Ctrl-Q %d more times to quit.', $quit_times); $quit_times--; return ''; } write_stdout("\x1b[2J"); // Clear the screen write_stdout("\x1b[H"); // Reposition cursor to top-left return NULL; break; case chr(ctrl_key('s')): $this->save(); break; case Key::HOME_KEY: $this->cursorX = 0; break; case Key::END_KEY: if ($this->cursorY < $this->numRows) { $this->cursorX = $this->rows[$this->cursorY]->size - 1; } break; case chr(ctrl_key('f')): $this->find(); break; case Key::BACKSPACE: case chr(ctrl_key('h')): case Key::DEL_KEY: if ($c === Key::DEL_KEY) { $this->moveCursor(Key::ARROW_RIGHT); } $this->deleteChar(); break; case Key::PAGE_UP: case Key::PAGE_DOWN: $this->pageUpOrDown($c); break; case Key::ARROW_UP: case Key::ARROW_DOWN: case Key::ARROW_LEFT: case Key::ARROW_RIGHT: $this->moveCursor($c); break; case chr(ctrl_key('l')): case Key::ESCAPE: // Do nothing break; default: $this->insertChar($c); break; } $quit_times = KILO_QUIT_TIMES; return $c; } private function pageUpOrDown(string $c): void { if ($c === Key::PAGE_UP) { $this->cursorY = $this->rowOffset; } else if ($c === Key::PAGE_DOWN) { $this->cursorY = $this->rowOffset + $this->screenRows - 1; if ($this->cursorY > $this->numRows) { $this->cursorY = $this->numRows; } } $times = $this->screenRows; for (; $times > 0; $times--) { $this->moveCursor($c === Key::PAGE_UP ? Key::ARROW_UP : Key::ARROW_DOWN); } } private function refreshPHPSyntax(): void { if ($this->syntax->filetype !== 'PHP') { return; } $this->tokens = PHP::getTokens($this->rowsToString()); for ($i = 0; $i < $this->numRows; $i++) { $this->rows[$i]->update(); } } }