$name = $value; } } public function __isset(string $name): bool { return isset($this->$name); } } class Key { public const ARROW_LEFT = 'ARROW_LEFT'; public const ARROW_RIGHT = 'ARROW_RIGHT'; public const ARROW_UP = 'ARROW_UP'; public const ARROW_DOWN = 'ARROW_DOWN'; public const DEL_KEY = 'DEL'; public const HOME_KEY = 'HOME'; public const END_KEY = 'END'; public const PAGE_UP = 'PAGE_UP'; public const PAGE_DOWN = 'PAGE_DOWN'; } /** * @property-read int size * @property-read int rsize */ class Row { use MagicProperties; public string $chars = ''; public string $render = ''; public static function new(string $chars): self { return new self($chars); } private function __construct($chars) { $this->chars = $chars; } public function __get(string $name) { switch ($name) { case 'size': return strlen($this->chars); case 'rsize': return strlen($this->render); default: return NULL; } } } /** * @property-read int numRows */ class Editor { use MagicProperties; private FFI $ffi; private string $ab = ''; protected int $cursorX = 0; protected int $cursorY = 0; protected int $renderX = 0; protected int $rowOffset = 0; protected int $colOffset = 0; protected int $screenRows = 0; protected int $screenCols = 0; /** * Array of Row objects */ protected array $rows = []; protected string $filename = ''; protected string $statusMsg = ''; protected int $statusMsgTime; public static function new(FFI $ffi): Editor { return new self($ffi); } private function __construct($ffi) { $this->ffi = $ffi; $this->statusMsgTime = time(); if ( ! $this->getWindowSize()) { die('Failed to get screen size'); } $this->screenRows -= 2; // print_r($this); die(); } public function __get(string $name) { if ($name === 'numRows') { return count($this->rows); } return NULL; } // ------------------------------------------------------------------------ // ! Terminal // ------------------------------------------------------------------------ protected function readKey(): string { $c = read_stdin(1); if ($c === '\x1b') { $seq = read_stdin(); if (strlen($seq) < 3) { return '\x1b'; } if (strpos($seq, '[') === 0) { $seq1 = (int)$seq[1]; if ($seq1 >= 0 && $seq1 <= 9) { if (strpos($seq, '~') === 2) { switch ($seq[1]) { case '1': case '7': return Key::HOME_KEY; case '4': case '8': return Key::END_KEY; case '3': return Key::DEL_KEY; case '5': return Key::PAGE_UP; case '6': return Key::PAGE_DOWN; } } } else { switch ($seq[1]) { case 'A': return Key::ARROW_UP; case 'B': return Key::ARROW_DOWN; case 'C': return Key::ARROW_RIGHT; case 'D': return Key::ARROW_LEFT; case 'H': return Key::HOME_KEY; case 'F': return Key::END_KEY; } } } else if (strpos($seq, 'O') === 0) { switch ($seq[1]) { case 'H': return Key::HOME_KEY; case 'F': return Key::END_KEY; } } return '\x1b'; } return $c; } /** * @TODO fix */ private function getCursorPosition(): bool { write_stdout("\x1b[999C\x1b[999B"); write_stdout("\x1b[6n"); $rows = 0; $cols = 0; $buffer = read_stdout(); $res = sscanf($buffer, '\x1b[%d;%dR', $rows, $cols); if ($res === -1 || $buffer[0] !== '\x1b' || $buffer[1] !== '[') { die('Failed to get screen size'); } $this->screenRows = $rows; $this->screenCols = $cols; return TRUE; } private function getWindowSize(): bool { $ws = $this->ffi->new('struct winsize'); $res = $this->ffi->ioctl(STDOUT_FILENO, TIOCGWINSZ, FFI::addr($ws)); if ($res === -1 || $ws->ws_col === 0) { return $this->getCursorPosition(); } $this->screenCols = $ws->ws_col; $this->screenRows = $ws->ws_row; return true; } // ------------------------------------------------------------------------ // ! 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 updateRow(Row $row): void { $idx = 0; for ($i = 0; $i < $row->size; $i++) { if ($row->chars[$i] === "\t") { $row->render[$idx++] = ' '; while ($idx % KILO_TAB_STOP !== 0) { $row->render[$idx++] = ' '; } } else { $row->render[$idx++] = $row->chars[$i]; } } } protected function appendRow(string $s): void { $at = $this->numRows; $this->rows[$at] = Row::new($s); $this->updateRow($this->rows[$at]); } // ------------------------------------------------------------------------ // ! File I/O // ------------------------------------------------------------------------ public function open(string $filename): void { // Copy filename for display $this->filename = $filename; // Determine the full path to the file $baseFile = basename($filename); $basePath = str_replace($baseFile, '', $filename); $path = (is_dir($basePath)) ? $basePath : getcwd(); $fullname = $path . '/' . $baseFile; // #TODO gracefully handle issues with loading a file $handle = fopen($fullname, 'rb'); while (($line = fgets($handle)) !== FALSE) { $this->appendRow($line); } fclose($handle); } // ------------------------------------------------------------------------ // ! 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 >= count($this->rows)) { if ($this->numRows === 0 && $y === $this->screenRows / 3) { $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--; } while ($padding--) { $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; } $this->ab .= substr($this->rows[$filerow]->render, $this->colOffset, $len); } $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]'; $status = sprintf("%.20s - %d lines", $statusFilename, count($this->rows)); $rstatus = sprintf("%d/%d", $this->cursorY + 1, count($this->rows)); $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 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 { $c = $this->readKey(); if ($c === "\0") { return ''; } switch ($c) { case Key::HOME_KEY: $this->cursorX = 0; break; case Key::END_KEY: if ($this->cursorY < $this->numRows) { $this->cursorX = $this->rows[$this->cursorY]->size; } 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('q')): write_stdout("\x1b[2J"); // Clear the screen write_stdout("\x1b[H"); // Reposition cursor to top-left return NULL; break; } 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; while ($times--) { $this->moveCursor($c === Key::PAGE_UP ? Key::ARROW_UP : Key::ARROW_DOWN); } } }