= 2 && ! empty($argv[1])) { return new self($argv[1]); } return new self(); } /** * The real constructor, ladies and gentlemen */ private function __construct(?string $filename = NULL) { $this->statusMessage = StatusMessage::from('HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find'); $this->cursor = Point::new(); $this->offset = Point::new(); $this->terminalSize = Terminal::size(); if (is_string($filename)) { $maybeDocument = Document::new()->open($filename); if ($maybeDocument === NULL) { $this->document = Document::new(); $this->setStatusMessage("ERR: Could not open file: {}", $filename); } else { $this->document = $maybeDocument; } } else { $this->document = Document::new(); } } public function __debugInfo(): array { return [ 'cursor' => $this->cursor, 'document' => $this->document, 'offset' => $this->offset, 'renderX' => $this->renderX, 'terminalSize' => $this->terminalSize, 'statusMessage' => $this->statusMessage, ]; } /** * Start the input loop */ public function run(): void { $this->refreshScreen(); while ( ! $this->shouldQuit) { // Don't redraw unless the screen actually needs to update! if ($this->processKeypress() !== false) { $this->refreshScreen(); } } } /** * Set a status message to be displayed, using printf formatting */ public function setStatusMessage(string $fmt, mixed ...$args): void { $text = func_num_args() > 1 ? sprintf($fmt, ...$args) : $fmt; $this->statusMessage = StatusMessage::from($text); } // ------------------------------------------------------------------------ // ! Row Operations // ------------------------------------------------------------------------ /** * Cursor X to Render X */ protected function rowCxToRx(Row $row, int $cx): int { $rx = 0; for ($i = 0; $i < $cx; $i++) { if ($row->chars[$i] === RawKeyCode::TAB) { $rx += (KILO_TAB_STOP - 1) - ($rx % KILO_TAB_STOP); } $rx++; } return $rx; } /** * Render X to Cursor X */ protected function rowRxToCx(Row $row, int $rx): int { $cur_rx = 0; for ($cx = 0; $cx < $row->size; $cx++) { if ($row->chars[$cx] === RawKeyCode::TAB) { $cur_rx += (KILO_TAB_STOP - 1) - ($cur_rx % KILO_TAB_STOP); } $cur_rx++; if ($cur_rx > $rx) { return $cx; } } return $cx; } // ------------------------------------------------------------------------ // ! File I/O // ------------------------------------------------------------------------ protected function save(): void { if ($this->document->filename === '') { $newFilename = $this->prompt('Save as: %s'); if ($newFilename === '') { $this->setStatusMessage('Save aborted'); return; } $this->document->filename = $newFilename; } $res = $this->document->save(); if ($res !== FALSE) { $this->setStatusMessage('%d bytes written to disk', $res); return; } $this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message'] ?? ''); } // ------------------------------------------------------------------------ // ! Find // ------------------------------------------------------------------------ protected function findCallback(string $query, string|KeyType $key): void { static $lastMatch = NO_MATCH; static $direction = SearchDirection::FORWARD; static $savedHlLine = 0; static $savedHl = []; if ( ! empty($savedHl)) { $row = $this->document->row($savedHlLine); if ($row->isValid()) { $row->hl = $savedHl; } $savedHl = []; } $direction = match ($key) { KeyType::ArrowUp, KeyType::ArrowLeft => SearchDirection::BACKWARD, default => SearchDirection::FORWARD }; $arrowKeys = [KeyType::ArrowUp, KeyType::ArrowDown, KeyType::ArrowLeft, KeyType::ArrowRight]; // Reset search state with non arrow-key input if ( ! in_array($key, $arrowKeys, true)) { $lastMatch = NO_MATCH; $direction = SearchDirection::FORWARD; if ($key === RawKeyCode::ENTER || $key === RawKeyCode::ESCAPE) { return; } } if ($lastMatch === NO_MATCH) { $direction = SearchDirection::FORWARD; } $current = (int)$lastMatch; if (empty($query)) { return; } for ($i = 0; $i < $this->document->numRows; $i++) { $current += $direction->value; if ($current === -1) { $current = $this->document->numRows - 1; } else if ($current === $this->document->numRows) { $current = 0; } $row = $this->document->row($current); if ( ! $row->isValid()) { break; } $match = strpos($row->render, $query); if ($match !== FALSE) { $lastMatch = $current; $this->cursor->y = (int)$current; $this->cursor->x = $this->rowRxToCx($row, $match); $this->offset->y = $this->document->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::SearchMatch); break; } } } protected function find(): void { $savedCursor = Point::from($this->cursor); $savedOffset = Point::from($this->offset); $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->cursor = Point::from($savedCursor); $this->offset = Point::from($savedOffset); } } // ------------------------------------------------------------------------ // ! Output // ------------------------------------------------------------------------ protected function scroll(): void { $this->renderX = 0; if ($this->cursor->y < $this->document->numRows) { $row = $this->document->row($this->cursor->y); if ($row->isValid()) { $this->renderX = $this->rowCxToRx($row, $this->cursor->x); } } // Vertical Scrolling if ($this->cursor->y < $this->offset->y) { $this->offset->y = $this->cursor->y; } else if ($this->cursor->y >= ($this->offset->y + $this->terminalSize->rows)) { $this->offset->y = $this->cursor->y - $this->terminalSize->rows + 1; } // Horizontal Scrolling if ($this->renderX < $this->offset->x) { $this->offset->x = $this->renderX; } else if ($this->renderX >= ($this->offset->x + $this->terminalSize->cols)) { $this->offset->x = $this->renderX - $this->terminalSize->cols + 1; } } protected function drawRows(): void { for ($y = 0; $y < $this->terminalSize->rows; $y++) { $fileRow = $y + $this->offset->y; $this->outputBuffer .= ANSI::CLEAR_LINE; ($fileRow >= $this->document->numRows) ? $this->drawPlaceholderRow($y) : $this->drawRow($fileRow); $this->outputBuffer .= "\r\n"; } } protected function drawRow(int $rowIdx): void { $row = $this->document->row($rowIdx); if ( ! $row->isValid()) { return; } $len = $row->rsize - $this->offset->x; if ($len < 0) { $len = 0; } if ($len > $this->terminalSize->cols) { $len = $this->terminalSize->cols; } if ($this->showLineNumbers) { $this->outputBuffer .= sprintf("%-{$this->numberGutter}s", ($rowIdx+1)); } $chars = substr($row->render, $this->offset->x, (int)$len); $hl = array_slice($row->hl, $this->offset->x, (int)$len); $currentColor = -1; for ($i = 0; $i < $len; $i++) { $ch = $chars[$i]; // Handle 'non-printable' characters if (is_ctrl($ch)) { $sym = (ord($ch) <= 26) ? chr(ord('@') + ord($ch)) : '?'; $this->outputBuffer .= ANSI::invert($sym); if ($currentColor !== -1) { $this->outputBuffer .= ANSI::color($currentColor); } } else if ($hl[$i] === Highlight::Normal) { if ($currentColor !== -1) { $this->outputBuffer .= ANSI::RESET_TEXT; $this->outputBuffer .= ANSI::color(Color::FG_WHITE); $currentColor = -1; } $this->outputBuffer .= $ch; } else { $color = get_syntax_color($hl[$i]); if ($color !== $currentColor) { $currentColor = $color; $this->outputBuffer .= ANSI::RESET_TEXT; $this->outputBuffer .= ANSI::color($color); } $this->outputBuffer .= $ch; } } $this->outputBuffer .= ANSI::RESET_TEXT; $this->outputBuffer .= ANSI::color(Color::FG_WHITE); } protected function drawPlaceholderRow(int $y): void { if ($this->document->numRows === 0 && $y === (int)($this->terminalSize->rows / 2)) { $welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION); $welcomelen = strlen($welcome); if ($welcomelen > $this->terminalSize->cols) { $welcomelen = $this->terminalSize->cols; } $padding = (int)floor(($this->terminalSize->cols - $welcomelen) / 2); if ($padding > 0) { $this->outputBuffer .= '~'; $padding--; } $this->outputBuffer .= str_repeat(' ', $padding); $this->outputBuffer .= substr($welcome, 0, $welcomelen); } else { $this->outputBuffer .= '~'; } } protected function drawStatusBar(): void { $this->outputBuffer .= ANSI::INVERSE_TEXT; $statusFilename = $this->document->filename !== '' ? $this->document->filename : '[No Name]'; $syntaxType = $this->document->fileType->name; $isDirty = $this->document->isDirty() ? '(modified)' : ''; $status = sprintf('%.20s - %d lines %s', $statusFilename, $this->document->numRows, $isDirty); $rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursor->y + 1, $this->document->numRows); $len = strlen($status); $rlen = strlen($rstatus); if ($len > $this->terminalSize->cols) { $len = $this->terminalSize->cols; } $this->outputBuffer .= substr($status, 0, $len); while ($len < $this->terminalSize->cols) { if ($this->terminalSize->cols - $len === $rlen) { $this->outputBuffer .= substr($rstatus, 0, $rlen); break; } $this->outputBuffer .= ' '; $len++; } $this->outputBuffer .= ANSI::RESET_TEXT; $this->outputBuffer .= "\r\n"; } protected function drawMessageBar(): void { $this->outputBuffer .= ANSI::CLEAR_LINE; $len = $this->statusMessage->len; if ($len > $this->terminalSize->cols) { $len = $this->terminalSize->cols; } // If there is a message, and it's been less than 5 seconds since // last screen update, show the message if ($len > 0 && (time() - $this->statusMessage->time) < 5) { $this->outputBuffer .= substr($this->statusMessage->text, 0, $len); } } protected function refreshScreen(): void { $this->scroll(); $this->outputBuffer = ANSI::HIDE_CURSOR . ANSI::RESET_CURSOR; $this->drawRows(); $this->drawStatusBar(); $this->drawMessageBar(); $gutter = ($this->showLineNumbers) ? $this->numberGutter : 0; // Specify the current cursor position $this->outputBuffer .= ANSI::moveCursor( $this->cursor->y - $this->offset->y, ($this->renderX - $this->offset->x) + $gutter, ); $this->outputBuffer .= ANSI::SHOW_CURSOR; Terminal::write($this->outputBuffer, strlen($this->outputBuffer)); } // ------------------------------------------------------------------------ // ! Input // ------------------------------------------------------------------------ protected function prompt(string $prompt, ?callable $callback = NULL): string { $buffer = ''; $modifiers = KeyType::getConstList(); while (TRUE) { $this->setStatusMessage($prompt, $buffer); $this->refreshScreen(); $c = Terminal::readKey(); $isModifier = in_array($c, $modifiers, TRUE); if ($c === KeyType::Escape || ($c === KeyType::Enter && $buffer !== '')) { $this->setStatusMessage(''); if ($callback !== NULL) { $callback($buffer, $c); } return ($c === KeyType::Enter) ? $buffer : ''; } if ($c === KeyType::Delete || $c === KeyType::Backspace) { $buffer = substr($buffer, 0, -1); } else if (is_string($c) && is_ascii($c) && ( ! (is_ctrl($c) || $isModifier))) { $buffer .= $c; } if ($callback !== NULL) { $callback($buffer, $c); } } } /** * Input processing * * Returns `false` on no keypress */ protected function processKeypress(): bool|null { $c = Terminal::readKey(); if ($c === RawKeyCode::NULL || $c === RawKeyCode::EMPTY) { return false; } switch ($c) { case RawKeyCode::CTRL('q'): $this->quitAttempt(); return true; case RawKeyCode::CTRL('s'): $this->save(); break; case RawKeyCode::CTRL('f'): $this->find(); break; case KeyType::Delete: case KeyType::Backspace: $this->removeChar($c); break; case KeyType::ArrowUp: case KeyType::ArrowDown: case KeyType::ArrowLeft: case KeyType::ArrowRight: case KeyType::PageUp: case KeyType::PageDown: case KeyType::Home: case KeyType::End: $this->moveCursor($c); break; case RawKeyCode::CTRL('l'): case KeyType::Escape: // Do nothing break; default: $this->insertChar($c); break; } // Reset quit confirmation timer on different keypress if ($this->quitTimes < KILO_QUIT_TIMES) { $this->quitTimes = KILO_QUIT_TIMES; $this->setStatusMessage(''); } return true; } // ------------------------------------------------------------------------ // ! Editor operation helpers // ------------------------------------------------------------------------ protected function moveCursor(KeyType $key): void { $x = $this->cursor->x; $y = $this->cursor->y; $row = $this->document->row($y); if ( ! $row->isValid()) { return; } switch ($key) { case KeyType::ArrowLeft: if ($x !== 0) { $x--; } else if ($y > 0) { // Beginning of a line, go to end of previous line $y--; $x = $row->size - 1; } break; case KeyType::ArrowRight: if ($x < $row->size) { $x++; } else if ($x === $row->size) { $y++; $x = 0; } break; case KeyType::ArrowUp: if ($y !== 0) { $y--; } break; case KeyType::ArrowDown: if ($y < $this->document->numRows) { $y++; } break; case KeyType::PageUp: $y = saturating_sub($y, $this->terminalSize->rows); break; case KeyType::PageDown: $y = saturating_add($y, $this->terminalSize->rows, $this->document->numRows); break; case KeyType::Home: $x = 0; break; case KeyType::End: if ($y < $this->document->numRows) { $x = $row->size; } break; default: // Do nothing } // Snap cursor to the end of a row when moving // from a longer row to a shorter one $row = $this->document->row($y); if ($row->isValid()) { if ($x > $row->size) { $x = $row->size; } $this->cursor->x = $x; $this->cursor->y = $y; } } protected function insertChar(string|KeyType $c): void { $this->document->insert($this->cursor, $c); $this->moveCursor(KeyType::ArrowRight); } protected function removeChar(string|KeyType $ch): void { if ($ch === KeyType::Delete) { $this->document->delete($this->cursor, $this->numberGutter); } if ($ch === KeyType::Backspace && (($this->cursor->x >= $this->numberGutter) || $this->cursor->y > 0)) { $this->moveCursor(KeyType::ArrowLeft); $this->document->delete($this->cursor, $this->numberGutter); } } protected function quitAttempt(): void { if ($this->document->isDirty() && $this->quitTimes > 0) { if ($this->quitTimes === KILO_QUIT_TIMES) { Terminal::ding(); } $this->setStatusMessage( 'WARNING!!! File has unsaved changes. Press Ctrl-Q %d more times to quit.', $this->quitTimes ); $this->quitTimes--; return; } Terminal::clear(); $this->shouldQuit = true; } }