php-kilo/src/Editor.php

746 lines
16 KiB
PHP
Raw Normal View History

2019-10-14 16:21:41 -04:00
<?php declare(strict_types=1);
2019-11-08 16:27:08 -05:00
namespace Aviat\Kilo;
use Aviat\Kilo\Type\TerminalSize;
use Aviat\Kilo\Enum\{Color, KeyCode, KeyType, Highlight};
use Aviat\Kilo\Type\{Point, StatusMessage};
2019-10-14 16:21:41 -04:00
/**
2019-10-25 16:36:03 -04:00
* // Don't highlight this!
*/
2019-10-14 16:21:41 -04:00
class Editor {
/**
* @var string The screen buffer
*/
2021-03-04 12:03:51 -05:00
private string $outputBuffer = '';
2019-10-14 16:21:41 -04:00
/**
2021-03-09 17:22:49 -05:00
* @var Point The 0-based location of the cursor in the current viewport
*/
2021-03-09 17:22:49 -05:00
protected Point $cursor;
/**
2021-03-09 17:22:49 -05:00
* @var Point The scroll offset of the file in the current viewport
*/
2021-03-09 17:22:49 -05:00
protected Point $offset;
/**
* @var Document The document being edited
*/
protected Document $document;
/**
* @var StatusMessage A disappearing status message
*/
protected StatusMessage $statusMessage;
/**
* @var TerminalSize The size of the terminal in rows and columns
*/
protected TerminalSize $terminalSize;
/**
* @var int The rendered cursor position
*/
protected int $renderX = 0;
/**
* @var bool Should we stop the rendering loop?
*/
protected bool $shouldQuit = false;
2019-10-14 16:21:41 -04:00
2021-03-09 17:22:49 -05:00
/**
* @var int The number of times to confirm you wish to quit
*/
protected int $quitTimes = KILO_QUIT_TIMES;
/**
* Create the Editor instance with CLI arguments
*
* @param int $argc
* @param array $argv
* @return Editor
*/
public static function new(int $argc = 0, array $argv = []): Editor
2019-10-15 13:23:25 -04:00
{
if ($argc >= 2 && ! empty($argv[1]))
{
return new self($argv[1]);
}
return new self();
2019-10-15 13:23:25 -04:00
}
/**
* The real constructor, ladies and gentlemen
*
* @param string|null $filename
*/
private function __construct(?string $filename = NULL)
2019-10-14 16:21:41 -04:00
{
$this->statusMessage = StatusMessage::from('HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find');
2021-03-09 17:22:49 -05:00
$this->cursor = Point::new();
$this->offset = Point::new();
$this->terminalSize = Terminal::size();
2019-10-14 16:21:41 -04:00
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;
}
}
2019-10-14 16:21:41 -04:00
}
2019-11-20 15:03:48 -05:00
public function __debugInfo(): array
{
return [
'cursor' => $this->cursor,
'document' => $this->document,
'offset' => $this->offset,
2019-11-20 15:03:48 -05:00
'renderX' => $this->renderX,
'terminalSize' => $this->terminalSize,
'statusMessage' => $this->statusMessage,
2019-11-20 15:03:48 -05:00
];
}
/**
* Start the input loop
*/
public function run(): void
{
while ( ! $this->shouldQuit)
{
$this->refreshScreen();
$this->processKeypress();
}
}
2019-10-15 13:23:25 -04:00
// ------------------------------------------------------------------------
// ! Row Operations
// ------------------------------------------------------------------------
2021-03-09 13:37:03 -05:00
/**
* Cursor X to Render X
*
* @param Row $row
* @param int $cx
* @return int
*/
protected function rowCxToRx(Row $row, int $cx): int
{
$rx = 0;
for ($i = 0; $i < $cx; $i++)
{
2020-02-05 14:50:31 -05:00
if ($row->chars[$i] === KeyCode::TAB)
{
$rx += (KILO_TAB_STOP - 1) - ($rx % KILO_TAB_STOP);
}
$rx++;
}
return $rx;
}
2021-03-09 13:37:03 -05:00
/**
* Render X to Cursor X
*
* @param Row $row
* @param int $rx
* @return int
*/
protected function rowRxToCx(Row $row, int $rx): int
{
$cur_rx = 0;
for ($cx = 0; $cx < $row->size; $cx++)
{
2020-02-05 14:50:31 -05:00
if ($row->chars[$cx] === KeyCode::TAB)
{
$cur_rx += (KILO_TAB_STOP - 1) - ($cur_rx % KILO_TAB_STOP);
}
$cur_rx++;
if ($cur_rx > $rx)
{
return $cx;
}
}
return $cx;
}
2019-10-22 12:09:11 -04:00
// ------------------------------------------------------------------------
// ! Editor Operations
// ------------------------------------------------------------------------
protected function insertChar(string $c): void
{
$this->document->insert($this->cursor, $c);
$this->moveCursor(KeyType::ARROW_RIGHT);
}
2019-10-15 13:23:25 -04:00
// ------------------------------------------------------------------------
// ! File I/O
// ------------------------------------------------------------------------
2019-10-22 12:09:11 -04:00
protected function save(): void
{
if ($this->document->filename === '')
2019-10-22 12:09:11 -04:00
{
2019-10-22 16:44:55 -04:00
$newFilename = $this->prompt('Save as: %s');
if ($newFilename === '')
2019-10-22 16:16:28 -04:00
{
$this->setStatusMessage('Save aborted');
return;
}
2019-10-22 16:44:55 -04:00
$this->document->filename = $newFilename;
2019-10-22 12:09:11 -04:00
}
$res = $this->document->save();
2019-10-22 12:09:11 -04:00
if ($res !== FALSE)
2019-10-22 12:09:11 -04:00
{
$this->setStatusMessage('%d bytes written to disk', $res);
2019-10-22 12:09:11 -04:00
return;
}
$this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message'] ?? '');
2019-10-15 13:23:25 -04:00
}
// ------------------------------------------------------------------------
// ! Find
// ------------------------------------------------------------------------
protected function findCallback(string $query, string $key): void
{
static $lastMatch = -1;
static $direction = 1;
static $savedHlLine = 0;
static $savedHl = [];
if ( ! empty($savedHl))
{
$this->document->rows[$savedHlLine]->hl = $savedHl;
$savedHl = [];
}
2020-02-05 14:50:31 -05:00
switch ($key)
{
2020-02-05 14:50:31 -05:00
case KeyCode::ENTER:
case KeyCode::ESCAPE:
$lastMatch = -1;
$direction = 1;
return;
2020-02-05 14:50:31 -05:00
case KeyType::ARROW_DOWN:
case KeyType::ARROW_RIGHT:
$direction = 1;
break;
case KeyType::ARROW_UP:
case KeyType::ARROW_LEFT:
$direction = -1;
break;
default:
$lastMatch = -1;
$direction = 1;
}
if ($lastMatch === -1)
{
$direction = 1;
}
2019-10-25 16:36:03 -04:00
$current = $lastMatch;
2020-12-04 11:18:21 -05:00
if (empty($query))
{
return;
}
for ($i = 0; $i < $this->document->numRows; $i++)
{
$current += $direction;
if ($current === -1)
{
$current = $this->document->numRows - 1;
}
else if ($current === $this->document->numRows)
{
$current = 0;
}
$row =& $this->document->rows[$current];
2020-02-05 16:32:17 -05:00
$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;
2020-02-05 16:32:17 -05:00
$savedHl = $row->hl;
// Update the highlight array of the relevant row with the 'MATCH' type
2020-02-05 16:32:17 -05:00
array_replace_range($row->hl, $match, strlen($query), Highlight::MATCH);
break;
}
}
}
protected function find(): void
{
2021-03-09 17:22:49 -05:00
$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 === '')
{
2021-03-09 17:22:49 -05:00
$this->cursor = Point::from($savedCursor);
$this->offset = Point::from($savedOffset);
}
}
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->cursor->y < $this->document->numRows)
{
$this->renderX = $this->rowCxToRx($this->document->rows[$this->cursor->y], $this->cursor->x);
}
// Vertical Scrolling
if ($this->cursor->y < $this->offset->y)
2019-10-15 13:23:25 -04:00
{
$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;
2019-10-15 13:23:25 -04:00
}
// Horizontal Scrolling
if ($this->renderX < $this->offset->x)
{
$this->offset->x = $this->renderX;
}
else if ($this->renderX >= ($this->offset->x + $this->terminalSize->cols))
2019-10-15 13:23:25 -04:00
{
$this->offset->x = $this->renderX - $this->terminalSize->cols + 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->terminalSize->rows; $y++)
2019-10-14 16:21:41 -04:00
{
$fileRow = $y + $this->offset->y;
2021-03-04 12:03:51 -05:00
2021-03-09 17:22:49 -05:00
$this->outputBuffer .= ANSI::CLEAR_LINE;
($fileRow >= $this->document->numRows)
2021-03-04 12:03:51 -05:00
? $this->drawPlaceholderRow($y)
: $this->drawRow($fileRow);
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= "\r\n";
}
}
protected function drawRow(int $rowIdx): void
{
$len = $this->document->rows[$rowIdx]->rsize - $this->offset->x;
2021-03-04 12:03:51 -05:00
if ($len < 0)
{
$len = 0;
}
if ($len > $this->terminalSize->cols)
2021-03-04 12:03:51 -05:00
{
$len = $this->terminalSize->cols;
2021-03-04 12:03:51 -05:00
}
$chars = substr($this->document->rows[$rowIdx]->render, $this->offset->x, (int)$len);
$hl = array_slice($this->document->rows[$rowIdx]->hl, $this->offset->x, (int)$len);
2021-03-04 12:03:51 -05:00
$currentColor = -1;
for ($i = 0; $i < $len; $i++)
{
$ch = $chars[$i];
// Handle 'non-printable' characters
if (is_ctrl($ch))
2019-10-14 16:21:41 -04:00
{
2021-03-04 12:03:51 -05:00
$sym = (ord($ch) <= 26)
? chr(ord('@') + ord($ch))
: '?';
$this->outputBuffer .= ANSI::color(Color::INVERT);
$this->outputBuffer .= $sym;
$this->outputBuffer .= ANSI::RESET_TEXT;
if ($currentColor !== -1)
2019-10-14 16:21:41 -04:00
{
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::color($currentColor);
2019-10-14 16:21:41 -04:00
}
2021-03-04 12:03:51 -05:00
}
else if ($hl[$i] === Highlight::NORMAL)
{
if ($currentColor !== -1)
2019-10-14 16:21:41 -04:00
{
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::RESET_TEXT;
$this->outputBuffer .= ANSI::color(Color::FG_WHITE);
$currentColor = -1;
2019-10-14 16:21:41 -04:00
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= $ch;
2019-10-14 16:21:41 -04:00
}
else
{
2021-03-04 12:03:51 -05:00
$color = syntax_to_color($hl[$i]);
if ($color !== $currentColor)
2019-10-15 13:23:25 -04:00
{
2021-03-04 12:03:51 -05:00
$currentColor = $color;
$this->outputBuffer .= ANSI::RESET_TEXT;
$this->outputBuffer .= ANSI::color($color);
2019-10-15 13:23:25 -04:00
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= $ch;
}
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::RESET_TEXT;
$this->outputBuffer .= ANSI::color(Color::FG_WHITE);
}
2019-10-23 13:36:16 -04:00
2021-03-04 12:03:51 -05:00
protected function drawPlaceholderRow(int $y): void
{
if ($this->document->numRows === 0 && $y === (int)($this->terminalSize->rows / 2))
2021-03-04 12:03:51 -05:00
{
$welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION);
$welcomelen = strlen($welcome);
if ($welcomelen > $this->terminalSize->cols)
2021-03-04 12:03:51 -05:00
{
$welcomelen = $this->terminalSize->cols;
2021-03-04 12:03:51 -05:00
}
$padding = ($this->terminalSize->cols - $welcomelen) / 2;
2021-03-04 12:03:51 -05:00
if ($padding > 0)
{
$this->outputBuffer .= '~';
$padding--;
}
for ($i = 0; $i < $padding; $i++)
{
$this->outputBuffer .= ' ';
2019-10-14 16:21:41 -04:00
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= substr($welcome, 0, $welcomelen);
}
else
{
$this->outputBuffer .= '~';
}
}
protected function drawStatusBar(): void
{
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::color(Color::INVERT);
$statusFilename = $this->document->filename !== '' ? $this->document->filename : '[No Name]';
$syntaxType = $this->document->fileType->name;
$isDirty = $this->document->dirty ? '(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;
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= substr($status, 0, $len);
while ($len < $this->terminalSize->cols)
{
if ($this->terminalSize->cols - $len === $rlen)
2019-10-14 16:21:41 -04:00
{
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= substr($rstatus, 0, $rlen);
break;
2019-10-14 16:21:41 -04:00
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ' ';
$len++;
}
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::RESET_TEXT;
$this->outputBuffer .= "\r\n";
}
protected function drawMessageBar(): void
{
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::CLEAR_LINE;
$len = strlen($this->statusMessage->text);
if ($len > $this->terminalSize->cols)
{
$len = $this->terminalSize->cols;
}
if ($len > 0 && (time() - $this->statusMessage->time) < 5)
{
$this->outputBuffer .= substr($this->statusMessage->text, 0, $len);
2019-10-14 16:21:41 -04:00
}
}
protected function refreshScreen(): void
2019-10-14 16:21:41 -04:00
{
2019-10-15 13:23:25 -04:00
$this->scroll();
2021-03-04 12:03:51 -05:00
$this->outputBuffer = '';
2019-10-14 16:21:41 -04:00
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::HIDE_CURSOR;
$this->outputBuffer .= ANSI::RESET_CURSOR;
2019-10-14 16:21:41 -04:00
$this->drawRows();
$this->drawStatusBar();
$this->drawMessageBar();
2019-10-14 16:21:41 -04:00
// Specify the current cursor position
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::moveCursor(
$this->cursor->y - $this->offset->y,
$this->renderX - $this->offset->x
);
2019-10-14 16:21:41 -04:00
2021-03-04 12:03:51 -05:00
$this->outputBuffer .= ANSI::SHOW_CURSOR;
2019-10-14 16:21:41 -04:00
Terminal::write($this->outputBuffer, strlen($this->outputBuffer));
}
2021-03-03 11:50:29 -05:00
public function setStatusMessage(string $fmt, mixed ...$args): void
{
$this->statusMessage = StatusMessage::from($fmt, ...$args);
2019-10-14 16:21:41 -04:00
}
// ------------------------------------------------------------------------
// ! Input
// ------------------------------------------------------------------------
protected function prompt(string $prompt, ?callable $callback = NULL): string
2019-10-22 16:16:28 -04:00
{
$buffer = '';
$modifiers = KeyType::getConstList();
2019-10-22 16:16:28 -04:00
while (TRUE)
{
$this->setStatusMessage($prompt, $buffer);
$this->refreshScreen();
$c = $this->readKey();
2020-02-05 16:32:17 -05:00
$isModifier = in_array($c, $modifiers, TRUE);
2019-10-22 16:16:28 -04:00
2020-02-05 14:50:31 -05:00
if ($c === KeyType::ESCAPE || ($c === KeyType::ENTER && $buffer !== ''))
2019-10-22 16:16:28 -04:00
{
$this->setStatusMessage('');
if ($callback !== NULL)
{
$callback($buffer, $c);
}
2020-12-04 11:18:21 -05:00
return ($c === KeyType::ENTER) ? $buffer : '';
2019-10-22 16:16:28 -04:00
}
2020-02-05 16:32:17 -05:00
if ($c === KeyType::DEL_KEY || $c === KeyType::BACKSPACE)
2019-10-22 16:16:28 -04:00
{
$buffer = substr($buffer, 0, -1);
}
2020-02-05 16:32:17 -05:00
else if (is_ascii($c) && ( ! (is_ctrl($c) || $isModifier)))
2019-10-22 16:16:28 -04:00
{
$buffer .= $c;
}
if ($callback !== NULL)
{
$callback($buffer, $c);
}
2019-10-22 16:16:28 -04:00
}
}
2019-10-14 16:21:41 -04:00
protected function moveCursor(string $key): void
{
2021-03-09 13:37:03 -05:00
$x = $this->cursor->x;
$y = $this->cursor->y;
$row = $this->document->rows[$y];
2019-10-14 16:21:41 -04:00
switch ($key)
{
case KeyType::ARROW_LEFT:
2021-03-09 13:37:03 -05:00
if ($x !== 0)
2019-10-14 16:21:41 -04:00
{
2021-03-09 13:37:03 -05:00
$x--;
}
2021-03-09 13:37:03 -05:00
else if ($y > 0)
{
2021-03-09 13:37:03 -05:00
// Beginning of a line, go to end of previous line
$y--;
$x = $this->document->rows[$y]->size - 1;
2019-10-14 16:21:41 -04:00
}
break;
case KeyType::ARROW_RIGHT:
2021-03-09 13:37:03 -05:00
if ($row && $x < $row->size)
{
2021-03-09 13:37:03 -05:00
$x++;
}
2021-03-09 13:37:03 -05:00
else if ($row && $x === $row->size)
2019-10-14 16:21:41 -04:00
{
2021-03-09 13:37:03 -05:00
$y++;
$x = 0;
2019-10-14 16:21:41 -04:00
}
break;
case KeyType::ARROW_UP:
2021-03-09 13:37:03 -05:00
if ($y !== 0)
2019-10-14 16:21:41 -04:00
{
2021-03-09 13:37:03 -05:00
$y--;
2019-10-14 16:21:41 -04:00
}
break;
case KeyType::ARROW_DOWN:
if ($y < $this->document->numRows)
2019-10-14 16:21:41 -04:00
{
2021-03-09 13:37:03 -05:00
$y++;
2019-10-14 16:21:41 -04:00
}
break;
case KeyType::PAGE_UP:
$y = ($y > $this->terminalSize->rows)
? $y - $this->terminalSize->rows
: 0;
break;
case KeyType::PAGE_DOWN:
$y = ($y + $this->terminalSize->rows < $this->document->numRows)
? $y + $this->terminalSize->rows
: $this->document->numRows;
break;
case KeyType::HOME_KEY:
2021-03-09 13:37:03 -05:00
$x = 0;
break;
case KeyType::END_KEY:
if ($y < $this->document->numRows)
{
$x = $this->document->rows[$y]->size;
}
break;
default:
// Do nothing
2019-10-14 16:21:41 -04:00
}
2021-03-09 17:22:49 -05:00
// Snap cursor to the end of a row when moving
// from a longer row to a shorter one
$row = $this->document->rows[$y];
2021-03-09 17:22:49 -05:00
$rowLen = ($row !== NULL) ? $row->size : 0;
if ($x > $rowLen)
{
2021-03-09 17:22:49 -05:00
$x = $rowLen;
}
2021-03-09 13:37:03 -05:00
$this->cursor->x = $x;
$this->cursor->y = $y;
2019-10-14 16:21:41 -04:00
}
protected function processKeypress(): void
2019-10-14 16:21:41 -04:00
{
$c = Terminal::readKey();
2019-10-16 22:14:30 -04:00
2020-02-05 14:50:31 -05:00
if ($c === KeyCode::NULL || $c === KeyCode::EMPTY)
2019-10-16 22:14:30 -04:00
{
return;
2019-10-16 22:14:30 -04:00
}
switch ($c)
2019-10-14 16:21:41 -04:00
{
2021-03-09 17:22:49 -05:00
case KeyCode::CTRL('q'):
$this->quitAttempt();
return;
2019-10-22 12:09:11 -04:00
2020-02-05 16:32:17 -05:00
case KeyCode::CTRL('s'):
2019-10-22 12:09:11 -04:00
$this->save();
break;
2020-02-05 16:32:17 -05:00
case KeyCode::CTRL('f'):
$this->find();
break;
case KeyType::DEL_KEY:
$this->document->delete($this->cursor);
break;
case KeyType::BACKSPACE:
if ($this->cursor->x > 0 || $this->cursor->y > 0)
2019-10-22 12:09:11 -04:00
{
$this->moveCursor(KeyType::ARROW_LEFT);
$this->document->delete($this->cursor);
2019-10-22 12:09:11 -04:00
}
break;
case KeyType::ARROW_UP:
case KeyType::ARROW_DOWN:
case KeyType::ARROW_LEFT:
case KeyType::ARROW_RIGHT:
case KeyType::PAGE_UP:
case KeyType::PAGE_DOWN:
case KeyType::HOME_KEY:
case KeyType::END_KEY:
2019-10-14 16:21:41 -04:00
$this->moveCursor($c);
break;
2020-02-05 16:32:17 -05:00
case KeyCode::CTRL('l'):
case KeyType::ESCAPE:
2019-10-22 12:09:11 -04:00
// 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
}
2021-03-09 17:22:49 -05:00
// Reset quit confirmation timer on different keypress
if ($this->quitTimes < KILO_QUIT_TIMES)
{
$this->quitTimes = KILO_QUIT_TIMES;
$this->setStatusMessage('');
}
2019-10-14 16:21:41 -04:00
}
2019-10-15 13:23:25 -04:00
protected function quitAttempt(): void
2021-03-09 17:22:49 -05:00
{
if ($this->document->dirty && $this->quitTimes > 0)
2021-03-09 17:22:49 -05:00
{
$this->setStatusMessage(
'WARNING!!! File has unsaved changes. Press Ctrl-Q %d more times to quit.',
$this->quitTimes
);
$this->quitTimes--;
return;
2021-03-09 17:22:49 -05:00
}
Terminal::clear();
$this->shouldQuit = true;
2021-03-09 17:22:49 -05:00
}
2019-10-24 20:21:44 -04:00
}