php-kilo/src/Editor.php

824 lines
16 KiB
PHP
Raw Normal View History

2019-10-14 16:21:41 -04:00
<?php declare(strict_types=1);
namespace Kilo;
use FFI;
trait MagicProperties {
abstract public function __get(string $name);
public function __set(string $name, $value)
{
if (property_exists($this, $name))
{
$this->$name = $value;
}
}
public function __isset(string $name): bool
{
return isset($this->$name);
}
}
2019-10-14 16:21:41 -04:00
class Key {
2019-10-22 12:09:11 -04:00
public const ARROW_DOWN = 'ARROW_DOWN';
public const ARROW_LEFT = 'ARROW_LEFT';
public const ARROW_RIGHT = 'ARROW_RIGHT';
public const ARROW_UP = 'ARROW_UP';
2019-10-22 12:09:11 -04:00
public const BACKSPACE = 'BACKSPACE';
2019-10-22 16:16:28 -04:00
public const DEL_KEY = 'DELETE';
2019-10-14 16:21:41 -04:00
public const END_KEY = 'END';
2019-10-22 12:09:11 -04:00
public const ENTER = 'ENTER';
public const ESCAPE = 'ESCAPE';
public const HOME_KEY = 'HOME';
2019-10-14 16:21:41 -04:00
public const PAGE_DOWN = 'PAGE_DOWN';
2019-10-22 12:09:11 -04:00
public const PAGE_UP = 'PAGE_UP';
2019-10-14 16:21:41 -04:00
}
/**
* @property-read int size
* @property-read int rsize
*/
2019-10-15 13:23:25 -04:00
class Row {
use MagicProperties;
public string $chars = '';
public string $render = '';
2019-10-15 13:23:25 -04:00
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;
}
}
2019-10-22 16:16:28 -04:00
public function update(): void
{
$idx = 0;
for ($i = 0; $i < $this->size; $i++)
{
if ($this->chars[$i] === "\t")
{
$this->render[$idx++] = ' ';
while ($idx % KILO_TAB_STOP !== 0)
{
$this->render[$idx++] = ' ';
}
}
else
{
$this->render[$idx++] = $this->chars[$i];
}
}
}
2019-10-15 13:23:25 -04:00
}
/**
* @property-read int numRows
*/
2019-10-14 16:21:41 -04:00
class Editor {
use MagicProperties;
2019-10-14 16:21:41 -04:00
private FFI $ffi;
private string $ab = '';
2019-10-14 16:21:41 -04:00
protected int $cursorX = 0;
protected int $cursorY = 0;
protected int $renderX = 0;
protected int $rowOffset = 0;
protected int $colOffset = 0;
2019-10-14 16:21:41 -04:00
protected int $screenRows = 0;
protected int $screenCols = 0;
2019-10-15 13:23:25 -04:00
/**
* Array of Row objects
*/
protected array $rows = [];
2019-10-22 12:09:11 -04:00
protected int $dirty = 0;
protected string $filename = '';
protected string $statusMsg = '';
protected int $statusMsgTime;
2019-10-15 13:23:25 -04:00
public static function new(FFI $ffi): Editor
{
return new self($ffi);
}
private function __construct($ffi)
2019-10-14 16:21:41 -04:00
{
$this->ffi = $ffi;
$this->statusMsgTime = time();
2019-10-14 16:21:41 -04:00
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;
2019-10-14 16:21:41 -04:00
}
// ------------------------------------------------------------------------
// ! Terminal
// ------------------------------------------------------------------------
protected function readKey(): string
{
$c = read_stdin();
2019-10-14 16:21:41 -04:00
// @TODO Make this more DRY
switch ($c)
2019-10-14 16:21:41 -04:00
{
2019-10-22 12:09:11 -04:00
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;
2019-10-14 16:21:41 -04:00
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;
2019-10-14 16:21:41 -04:00
case "\x1bOF":
case "\x1b[4~":
case "\x1b[8~":
case "\x1b[F":
return Key::END_KEY;
2019-10-14 16:21:41 -04:00
2019-10-22 12:09:11 -04:00
case "\x1b":
return Key::ESCAPE;
default: return $c;
}
2019-10-14 16:21:41 -04:00
}
/**
* @TODO fix
*/
private function getCursorPosition(): bool
{
write_stdout("\x1b[999C\x1b[999B");
write_stdout("\x1b[6n");
$rows = 0;
$cols = 0;
$buffer = read_stdout();
2019-10-14 16:21:41 -04:00
$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;
2019-10-14 16:21:41 -04:00
}
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;
}
2019-10-15 13:23:25 -04:00
// ------------------------------------------------------------------------
// ! 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;
}
2019-10-22 16:16:28 -04:00
protected function insertRow(int $at, string $s): void
{
2019-10-22 16:16:28 -04:00
if ($at < 0 || $at > $this->numRows)
{
return;
}
$row = Row::new($s);
2019-10-22 16:16:28 -04:00
if ($at === $this->numRows)
{
2019-10-22 16:16:28 -04:00
$this->rows[] = $row;
}
else
{
$this->rows = [
...array_slice($this->rows, 0, $at),
$row,
...array_slice($this->rows, $at),
];
}
2019-10-22 16:16:28 -04:00
$this->rows[$at]->update();
2019-10-22 12:09:11 -04:00
$this->dirty++;
}
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);
$this->dirty++;
}
protected function rowInsertChar(Row $row, int $at, string $c): void
{
if ($at < 0 || $at > $row->size)
{
$at = $row->size;
}
// Safely insert into arbitrary position in the existing string
$row->chars = substr($row->chars, 0, $at) . $c . substr($row->chars, $at);
2019-10-22 16:16:28 -04:00
$row->update();
2019-10-22 12:09:11 -04:00
$this->dirty++;
}
protected function rowAppendString(Row $row, string $s): void
{
$row->chars .= $s;
2019-10-22 16:16:28 -04:00
$row->update();
2019-10-22 12:09:11 -04:00
$this->dirty++;
}
protected function rowDeleteChar(Row $row, int $at): void
{
if ($at < 0 || $at >= $row->size)
{
return;
}
$row->chars = substr_replace($row->chars, '', $at, 1);
2019-10-22 16:16:28 -04:00
$row->update();
2019-10-22 12:09:11 -04:00
$this->dirty++;
}
// ------------------------------------------------------------------------
// ! Editor Operations
// ------------------------------------------------------------------------
protected function insertChar(string $c): void
{
if ($this->cursorY === $this->numRows)
{
2019-10-22 16:16:28 -04:00
$this->insertRow($this->numRows, '');
2019-10-22 12:09:11 -04:00
}
$this->rowInsertChar($this->rows[$this->cursorY], $this->cursorX, $c);
$this->cursorX++;
}
2019-10-22 16:16:28 -04:00
protected function insertNewline(): void
{
if ($this->cursorX === 0)
{
$this->insertRow($this->cursorY, '');
}
else
{
$row = $this->rows[$this->cursorY];
// Add a new row, with the contents from the cursor to the end of the line
$this->insertRow($this->cursorY + 1, substr($row->chars, $this->cursorX));
// Update the (now previous) row
$row->chars = substr($row->chars, 0, $this->cursorX);
$row->update();
}
$this->cursorY++;
$this->cursorX = 0;
}
2019-10-22 12:09:11 -04:00
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)
{
$this->rowDeleteChar($row, $this->cursorX - 1);
$this->cursorX--;
}
else
{
$this->cursorX = $this->rows[$this->cursorY - 1]->size;
$this->rowAppendString($this->rows[$this->cursorY -1], $row->chars);
$this->deleteRow($this->cursorY);
$this->cursorY--;
}
2019-10-15 13:23:25 -04:00
}
// ------------------------------------------------------------------------
// ! File I/O
// ------------------------------------------------------------------------
2019-10-22 12:09:11 -04:00
protected function rowsToString(): string
2019-10-15 13:23:25 -04:00
{
2019-10-22 12:09:11 -04:00
$str = '';
foreach ($this->rows as $row)
{
$str .= $row->chars . "\n";
}
return $str;
}
2019-10-22 12:09:11 -04:00
public function open(string $filename): void
{
// Determine the full path to the file
2019-10-15 13:23:25 -04:00
$baseFile = basename($filename);
$basePath = str_replace($baseFile, '', $filename);
$path = (is_dir($basePath)) ? $basePath : getcwd();
$fullname = $path . '/' . $baseFile;
2019-10-22 12:09:11 -04:00
// Copy filename for display
$this->filename = $fullname;
2019-10-15 13:23:25 -04:00
// #TODO gracefully handle issues with loading a file
2019-10-15 13:23:25 -04:00
$handle = fopen($fullname, 'rb');
2019-10-22 16:16:28 -04:00
if ($handle === FALSE)
{
disableRawMode();
print_r(error_clear_last());
die();
}
2019-10-15 13:23:25 -04:00
while (($line = fgets($handle)) !== FALSE)
{
// Remove line endings when reading the file
2019-10-22 16:16:28 -04:00
$this->insertRow($this->numRows, rtrim($line));
2019-10-15 13:23:25 -04:00
}
fclose($handle);
2019-10-22 12:09:11 -04:00
$this->dirty = 0;
}
protected function save(): void
{
if ($this->filename === '')
{
2019-10-22 16:16:28 -04:00
$this->filename = $this->prompt('Save as: %s');
if ($this->filename === '')
{
$this->setStatusMessage('Save aborted');
return;
}
2019-10-22 12:09:11 -04:00
}
$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']);
2019-10-15 13:23:25 -04:00
}
2019-10-14 16:21:41 -04:00
// ------------------------------------------------------------------------
// ! Output
// ------------------------------------------------------------------------
2019-10-15 13:23:25 -04:00
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)
2019-10-15 13:23:25 -04:00
{
$this->rowOffset = $this->cursorY;
}
2019-10-18 16:20:34 -04:00
if ($this->cursorY >= ($this->rowOffset + $this->screenRows))
{
$this->rowOffset = $this->cursorY - $this->screenRows + 1;
2019-10-15 13:23:25 -04:00
}
// Horizontal Scrolling
if ($this->renderX < $this->colOffset)
{
$this->colOffset = $this->renderX;
}
2019-10-18 16:20:34 -04:00
if ($this->renderX >= ($this->colOffset + $this->screenCols))
2019-10-15 13:23:25 -04:00
{
$this->colOffset = $this->renderX - $this->screenCols + 1;
2019-10-15 13:23:25 -04:00
}
}
2019-10-14 16:21:41 -04:00
protected function drawRows(): void
{
for ($y = 0; $y < $this->screenRows; $y++)
{
$filerow = $y + $this->rowOffset;
2019-10-18 16:20:34 -04:00
if ($filerow >= $this->numRows)
2019-10-14 16:21:41 -04:00
{
if ($this->numRows === 0 && $y === $this->screenRows / 3)
2019-10-14 16:21:41 -04:00
{
2019-10-15 13:23:25 -04:00
$welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION);
$welcomelen = strlen($welcome);
if ($welcomelen > $this->screenCols)
{
$welcomelen = $this->screenCols;
}
2019-10-14 16:21:41 -04:00
2019-10-15 13:23:25 -04:00
$padding = ($this->screenCols - $welcomelen) / 2;
if ($padding > 0)
2019-10-15 13:23:25 -04:00
{
$this->ab .= '~';
$padding--;
}
2019-10-18 16:20:34 -04:00
for (; $padding >= 0; $padding--)
2019-10-15 13:23:25 -04:00
{
$this->ab .= ' ';
}
$this->ab .= substr($welcome, 0, $welcomelen);
2019-10-14 16:21:41 -04:00
}
2019-10-15 13:23:25 -04:00
else
2019-10-14 16:21:41 -04:00
{
2019-10-15 13:23:25 -04:00
$this->ab .= '~';
2019-10-14 16:21:41 -04:00
}
}
else
{
$len = $this->rows[$filerow]->rsize - $this->colOffset;
2019-10-15 13:23:25 -04:00
if ($len < 0)
{
$len = 0;
}
if ($len > $this->screenCols)
{
$len = $this->screenCols;
}
$this->ab .= substr($this->rows[$filerow]->render, $this->colOffset, $len);
2019-10-14 16:21:41 -04:00
}
$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]';
2019-10-22 12:09:11 -04:00
$isDirty = ($this->dirty > 0) ? '(modified)' : '';
$status = sprintf('%.20s - %d lines %s', $statusFilename, $this->numRows, $isDirty);
$rstatus = sprintf('%d/%d', $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)
2019-10-14 16:21:41 -04:00
{
$this->ab .= substr($rstatus, 0, $rlen);
break;
2019-10-14 16:21:41 -04:00
}
$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);
2019-10-14 16:21:41 -04:00
}
}
public function refreshScreen(): void
{
2019-10-15 13:23:25 -04:00
$this->scroll();
2019-10-14 16:21:41 -04:00
$this->ab = '';
$this->ab .= "\x1b[?25l"; // Hide the cursor
$this->ab .= "\x1b[H"; // Reposition cursor to top-left
$this->drawRows();
$this->drawStatusBar();
$this->drawMessageBar();
2019-10-14 16:21:41 -04:00
// Specify the current cursor position
$this->ab .= sprintf("\x1b[%d;%dH",
($this->cursorY - $this->rowOffset) + 1,
($this->renderX - $this->colOffset) + 1
);
2019-10-14 16:21:41 -04:00
$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();
2019-10-14 16:21:41 -04:00
}
// ------------------------------------------------------------------------
// ! Input
// ------------------------------------------------------------------------
2019-10-22 16:16:28 -04:00
protected function prompt(string $prompt): string
{
$buffer = '';
while (TRUE)
{
$this->setStatusMessage($prompt, $buffer);
$this->refreshScreen();
$c = $this->readKey();
$cord = ord($c);
if ($c === Key::ESCAPE)
{
$this->setStatusMessage('');
return '';
}
if ($c === Key::ENTER && $buffer !== '')
{
$this->setStatusMessage('');
return $buffer;
}
if ($c === Key::DEL_KEY || $c === Key::BACKSPACE || $c === chr(ctrl_key('h')))
{
$buffer = substr($buffer, 0, -1);
}
else if ($cord < 128 && $this->ffi->iscntrl($cord) === 0)
{
$buffer .= $c;
}
}
}
2019-10-14 16:21:41 -04:00
protected function moveCursor(string $key): void
{
$row = ($this->cursorY >= $this->numRows)
? NULL
: $this->rows[$this->cursorY];
2019-10-14 16:21:41 -04:00
switch ($key)
{
case Key::ARROW_LEFT:
if ($this->cursorX !== 0)
2019-10-14 16:21:41 -04:00
{
$this->cursorX--;
}
else if ($this->cursorX > 0)
{
$this->cursorY--;
$this->cursorX = $this->rows[$this->cursorY]->size;
2019-10-14 16:21:41 -04:00
}
break;
case Key::ARROW_RIGHT:
if ($row && $this->cursorX < $row->size)
{
$this->cursorX++;
}
else if ($row && $this->cursorX === $row->size)
2019-10-14 16:21:41 -04:00
{
$this->cursorY++;
$this->cursorX = 0;
2019-10-14 16:21:41 -04:00
}
break;
case Key::ARROW_UP:
if ($this->cursorY !== 0)
2019-10-14 16:21:41 -04:00
{
$this->cursorY--;
2019-10-14 16:21:41 -04:00
}
break;
case Key::ARROW_DOWN:
if ($this->cursorY < $this->numRows)
2019-10-14 16:21:41 -04:00
{
$this->cursorY++;
2019-10-14 16:21:41 -04:00
}
break;
}
$row = ($this->cursorY >= $this->numRows)
? NULL
: $this->rows[$this->cursorY];
$rowlen = $row ? $row->size : 0;
if ($this->cursorX > $rowlen)
{
$this->cursorX = $rowlen;
}
2019-10-14 16:21:41 -04:00
}
2019-10-16 22:14:30 -04:00
public function processKeypress(): ?string
2019-10-14 16:21:41 -04:00
{
2019-10-22 12:09:11 -04:00
static $quit_times = KILO_QUIT_TIMES;
2019-10-14 16:21:41 -04:00
$c = $this->readKey();
2019-10-16 22:14:30 -04:00
if ($c === "\0")
{
return '';
}
switch ($c)
2019-10-14 16:21:41 -04:00
{
2019-10-22 12:09:11 -04:00
case Key::ENTER:
2019-10-22 16:16:28 -04:00
$this->insertNewline();
2019-10-22 12:09:11 -04:00
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;
2019-10-14 16:21:41 -04:00
case Key::HOME_KEY:
$this->cursorX = 0;
2019-10-14 16:21:41 -04:00
break;
case Key::END_KEY:
if ($this->cursorY < $this->numRows)
{
$this->cursorX = $this->rows[$this->cursorY]->size - 1;
}
2019-10-14 16:21:41 -04:00
break;
2019-10-22 12:09:11 -04:00
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;
2019-10-14 16:21:41 -04:00
case Key::PAGE_UP:
case Key::PAGE_DOWN:
2019-10-15 13:23:25 -04:00
$this->pageUpOrDown($c);
2019-10-14 16:21:41 -04:00
break;
case Key::ARROW_UP:
case Key::ARROW_DOWN:
case Key::ARROW_LEFT:
case Key::ARROW_RIGHT:
$this->moveCursor($c);
break;
2019-10-22 12:09:11 -04:00
case chr(ctrl_key('l')):
case Key::ESCAPE:
// Do nothing
break;
2019-10-18 16:20:34 -04:00
default:
2019-10-22 12:09:11 -04:00
$this->insertChar($c);
break;
2019-10-14 16:21:41 -04:00
}
2019-10-22 12:09:11 -04:00
$quit_times = KILO_QUIT_TIMES;
2019-10-14 16:21:41 -04:00
return $c;
}
2019-10-15 13:23:25 -04:00
private function pageUpOrDown(string $c): void
2019-10-15 13:23:25 -04:00
{
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;
}
}
2019-10-15 13:23:25 -04:00
$times = $this->screenRows;
2019-10-18 16:20:34 -04:00
for (; $times > 0; $times--)
2019-10-15 13:23:25 -04:00
{
$this->moveCursor($c === Key::PAGE_UP ? Key::ARROW_UP : Key::ARROW_DOWN);
}
}
2019-10-14 16:21:41 -04:00
}