Refactor related class properties into their own objects

This commit is contained in:
Timothy Warren 2021-03-11 16:56:02 -05:00
parent 0914265492
commit c5e1b6f1b2
8 changed files with 201 additions and 85 deletions

View File

@ -2,13 +2,18 @@
[![Build Status](https://jenkins.timshome.page/buildStatus/icon?job=timw4mail%2Fphp-kilo%2Fmaster)](https://jenkins.timshome.page/job/timw4mail/job/php-kilo/job/master/) [![Build Status](https://jenkins.timshome.page/buildStatus/icon?job=timw4mail%2Fphp-kilo%2Fmaster)](https://jenkins.timshome.page/job/timw4mail/job/php-kilo/job/master/)
A reimplementation of the [Kilo](https://viewsourcecode.org/snaptoken/kilo/index.html) tutorial in PHP. Requires PHP 8 and FFI. A reimplementation of the [Kilo](https://viewsourcecode.org/snaptoken/kilo/index.html) tutorial in PHP. Also has some inspiration from the [Hecto](https://www.philippflenker.com/hecto/) text editor tutorial. Requires PHP 8 and FFI.
## Requirements
* PHP 8
* FFI enabled
## Implementation notes: ## Implementation notes:
* The `editor` prefix has been removed from all the relevant functions, instead they are methods on the `Editor` class. * The `editor` prefix has been removed from all the relevant functions, instead they are methods on one of the implementation classes.
* Enums are faked with class constants * Enums are faked with class constants
* Composer is used for autoloading * Composer is used for autoloading
* Properties that must be manually updated in the C version (like counts/string length) are implemented with magic methods, * Properties that must be manually updated in the C version (like counts/string length) are implemented with magic methods,
so they are essentially calculated on read. so they are essentially calculated on read.
* Generally, if a function exists in PHP, with the same name as the C function, the PHP version will be used. * Generally, if a function exists in PHP, with the same name as the C function, the PHP version will be used.
* Classes are used to modularize functionality, and reduce the amount of functions with global side effects

18
kilo
View File

@ -22,23 +22,7 @@ return (static function (int $argc, array $argv): int {
Termios::enableRawMode(); Termios::enableRawMode();
register_shutdown_function([Termios::class, 'disableRawMode']); register_shutdown_function([Termios::class, 'disableRawMode']);
$editor = Editor::new(); Editor::new($argc, $argv)->run();
$editor->setStatusMessage('HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find');
if ($argc >= 2)
{
$editor->open($argv[1]);
}
// Input Loop
while (true)
{
$editor->refreshScreen();
if ($editor->processKeypress() === NULL)
{
break;
}
}
return 0; return 0;
})($argc, $argv); })($argc, $argv);

View File

@ -2,17 +2,64 @@
namespace Aviat\Kilo; namespace Aviat\Kilo;
/**
* The representation of the current document being edited
*
* @property-read int $numRows
*/
class Document { class Document {
public ?Syntax $syntax = NULL;
private function __construct( private function __construct(
public array $rows = [], public array $rows = [],
public ?string $filename = NULL, public ?string $filename = NULL,
private bool $dirty = FALSE, private bool $dirty = FALSE,
) {} ) {}
public function __get(string $name): ?int
{
if ($name === 'numRows')
{
return count($this->rows);
}
return NULL;
}
public static function new(): self
{
return new self();
}
public static function open(?string $filename = NULL): self public static function open(?string $filename = NULL): self
{ {
// @TODO move logic from Editor // @TODO move logic from Editor
return new self(filename: $filename); return new self(filename: $filename);
} }
} public function save(): bool
{
// @TODO move logic
return false;
}
public function insertChar(Point $at, string $c): void
{
}
public function isDirty(): bool
{
return $this->dirty;
}
public function deleteChar(Point $at): void
{
}
private function insertNewline(Point $at): void
{
}
}

View File

@ -2,8 +2,10 @@
namespace Aviat\Kilo; namespace Aviat\Kilo;
use Aviat\Kilo\Type\TerminalSize;
use Aviat\Kilo\Enum\{Color, KeyCode, KeyType, Highlight}; use Aviat\Kilo\Enum\{Color, KeyCode, KeyType, Highlight};
use Aviat\Kilo\Tokens\PHP8; use Aviat\Kilo\Tokens\PHP8;
use Aviat\Kilo\Type\{Point, StatusMessage};
/** /**
* // Don't highlight this! * // Don't highlight this!
@ -27,20 +29,30 @@ class Editor {
*/ */
protected Point $offset; 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 * @var int The rendered cursor position
*/ */
protected int $renderX = 0; protected int $renderX = 0;
/** /**
* @var int The size of the current terminal in rows * @var bool Should we stop the rendering loop?
*/ */
protected int $screenRows = 0; protected bool $shouldQuit = false;
/**
* @var int The size of the current terminal in columns
*/
protected int $screenCols = 0;
/** /**
* @var int The number of times to confirm you wish to quit * @var int The number of times to confirm you wish to quit
@ -54,29 +66,45 @@ class Editor {
public bool $dirty = FALSE; public bool $dirty = FALSE;
public string $filename = ''; public string $filename = '';
protected string $statusMsg = '';
protected int $statusMsgTime;
public ?Syntax $syntax = NULL; public ?Syntax $syntax = NULL;
// Tokens for highlighting PHP // Tokens for highlighting PHP
public array $tokens = []; public array $tokens = [];
public static function new(): Editor /**
* 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
{ {
if ($argc >= 2 && ! empty($argv[1]))
{
return new self($argv[1]);
}
return new self(); return new self();
} }
private function __construct() /**
* The real constructor, ladies and gentlemen
*
* @param string|null $filename
*/
private function __construct(?string $filename = NULL)
{ {
$this->statusMsgTime = time(); $this->statusMessage = StatusMessage::from('HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find');
$this->cursor = Point::new(); $this->cursor = Point::new();
$this->offset = Point::new(); $this->offset = Point::new();
$this->terminalSize = Terminal::size();
[$this->screenRows, $this->screenCols] = Terminal::getWindowSize(); if (is_string($filename))
{
// Remove a row for the status bar, and one for the message bar $this->open($filename);
$this->screenRows -= 2; }
} }
public function __get(string $name): ?int public function __get(string $name): ?int
@ -93,19 +121,31 @@ class Editor {
{ {
return [ return [
'cursor' => $this->cursor, 'cursor' => $this->cursor,
'document' => $this->document,
'offset' => $this->offset, 'offset' => $this->offset,
'dirty' => $this->dirty, 'dirty' => $this->dirty,
'filename' => $this->filename, 'filename' => $this->filename,
'renderX' => $this->renderX, 'renderX' => $this->renderX,
'rows' => $this->rows, 'rows' => $this->rows,
'screenCols' => $this->screenCols, 'terminalSize' => $this->terminalSize,
'screenRows' => $this->screenRows, 'statusMessage' => $this->statusMessage,
'statusMsg' => $this->statusMsg,
'syntax' => $this->syntax, 'syntax' => $this->syntax,
'tokens' => $this->tokens, 'tokens' => $this->tokens,
]; ];
} }
/**
* Start the input loop
*/
public function run(): void
{
while ( ! $this->shouldQuit)
{
$this->refreshScreen();
$this->processKeypress();
}
}
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// ! Terminal // ! Terminal
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
@ -372,7 +412,7 @@ class Editor {
return implode('', $lines); return implode('', $lines);
} }
public function open(string $filename): void protected function open(string $filename): void
{ {
// Copy filename for display // Copy filename for display
$this->filename = $filename; $this->filename = $filename;
@ -422,7 +462,7 @@ class Editor {
return; return;
} }
$this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message']); $this->setStatusMessage('Failed to save! I/O error: %s', error_get_last()['message'] ?? '');
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
@ -543,9 +583,9 @@ class Editor {
{ {
$this->offset->y = $this->cursor->y; $this->offset->y = $this->cursor->y;
} }
else if ($this->cursor->y >= ($this->offset->y + $this->screenRows)) else if ($this->cursor->y >= ($this->offset->y + $this->terminalSize->rows))
{ {
$this->offset->y = $this->cursor->y - $this->screenRows + 1; $this->offset->y = $this->cursor->y - $this->terminalSize->rows + 1;
} }
// Horizontal Scrolling // Horizontal Scrolling
@ -553,15 +593,15 @@ class Editor {
{ {
$this->offset->x = $this->renderX; $this->offset->x = $this->renderX;
} }
else if ($this->renderX >= ($this->offset->x + $this->screenCols)) else if ($this->renderX >= ($this->offset->x + $this->terminalSize->cols))
{ {
$this->offset->x = $this->renderX - $this->screenCols + 1; $this->offset->x = $this->renderX - $this->terminalSize->cols + 1;
} }
} }
protected function drawRows(): void protected function drawRows(): void
{ {
for ($y = 0; $y < $this->screenRows; $y++) for ($y = 0; $y < $this->terminalSize->rows; $y++)
{ {
$filerow = $y + $this->offset->y; $filerow = $y + $this->offset->y;
@ -582,9 +622,9 @@ class Editor {
{ {
$len = 0; $len = 0;
} }
if ($len > $this->screenCols) if ($len > $this->terminalSize->cols)
{ {
$len = $this->screenCols; $len = $this->terminalSize->cols;
} }
$chars = substr($this->rows[$rowIdx]->render, $this->offset->x, (int)$len); $chars = substr($this->rows[$rowIdx]->render, $this->offset->x, (int)$len);
@ -639,16 +679,16 @@ class Editor {
protected function drawPlaceholderRow(int $y): void protected function drawPlaceholderRow(int $y): void
{ {
if ($this->numRows === 0 && $y === (int)($this->screenRows / 2)) if ($this->numRows === 0 && $y === (int)($this->terminalSize->rows / 2))
{ {
$welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION); $welcome = sprintf('PHP Kilo editor -- version %s', KILO_VERSION);
$welcomelen = strlen($welcome); $welcomelen = strlen($welcome);
if ($welcomelen > $this->screenCols) if ($welcomelen > $this->terminalSize->cols)
{ {
$welcomelen = $this->screenCols; $welcomelen = $this->terminalSize->cols;
} }
$padding = ($this->screenCols - $welcomelen) / 2; $padding = ($this->terminalSize->cols - $welcomelen) / 2;
if ($padding > 0) if ($padding > 0)
{ {
$this->outputBuffer .= '~'; $this->outputBuffer .= '~';
@ -678,14 +718,14 @@ class Editor {
$rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursor->y + 1, $this->numRows); $rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursor->y + 1, $this->numRows);
$len = strlen($status); $len = strlen($status);
$rlen = strlen($rstatus); $rlen = strlen($rstatus);
if ($len > $this->screenCols) if ($len > $this->terminalSize->cols)
{ {
$len = $this->screenCols; $len = $this->terminalSize->cols;
} }
$this->outputBuffer .= substr($status, 0, $len); $this->outputBuffer .= substr($status, 0, $len);
while ($len < $this->screenCols) while ($len < $this->terminalSize->cols)
{ {
if ($this->screenCols - $len === $rlen) if ($this->terminalSize->cols - $len === $rlen)
{ {
$this->outputBuffer .= substr($rstatus, 0, $rlen); $this->outputBuffer .= substr($rstatus, 0, $rlen);
break; break;
@ -701,19 +741,19 @@ class Editor {
protected function drawMessageBar(): void protected function drawMessageBar(): void
{ {
$this->outputBuffer .= ANSI::CLEAR_LINE; $this->outputBuffer .= ANSI::CLEAR_LINE;
$len = strlen($this->statusMsg); $len = strlen($this->statusMessage->text);
if ($len > $this->screenCols) if ($len > $this->terminalSize->cols)
{ {
$len = $this->screenCols; $len = $this->terminalSize->cols;
} }
if ($len > 0 && (time() - $this->statusMsgTime) < 5) if ($len > 0 && (time() - $this->statusMessage->time) < 5)
{ {
$this->outputBuffer .= substr($this->statusMsg, 0, $len); $this->outputBuffer .= substr($this->statusMessage->text, 0, $len);
} }
} }
public function refreshScreen(): void protected function refreshScreen(): void
{ {
$this->scroll(); $this->scroll();
@ -739,10 +779,7 @@ class Editor {
public function setStatusMessage(string $fmt, mixed ...$args): void public function setStatusMessage(string $fmt, mixed ...$args): void
{ {
$this->statusMsg = (count($args) > 0) $this->statusMessage = StatusMessage::from($fmt, ...$args);
? sprintf($fmt, ...$args)
: $fmt;
$this->statusMsgTime = time();
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
@ -835,14 +872,14 @@ class Editor {
break; break;
case KeyType::PAGE_UP: case KeyType::PAGE_UP:
$y = ($y > $this->screenRows) $y = ($y > $this->terminalSize->rows)
? $y - $this->screenRows ? $y - $this->terminalSize->rows
: 0; : 0;
break; break;
case KeyType::PAGE_DOWN: case KeyType::PAGE_DOWN:
$y = ($y + $this->screenRows < $this->numRows) $y = ($y + $this->terminalSize->rows < $this->numRows)
? $y + $this->screenRows ? $y + $this->terminalSize->rows
: $this->numRows; : $this->numRows;
break; break;
@ -874,23 +911,20 @@ class Editor {
$this->cursor->y = $y; $this->cursor->y = $y;
} }
public function processKeypress(): ?string protected function processKeypress(): void
{ {
$c = $this->readKey(); $c = $this->readKey();
if ($c === KeyCode::NULL || $c === KeyCode::EMPTY) if ($c === KeyCode::NULL || $c === KeyCode::EMPTY)
{ {
return ''; return;
} }
switch ($c) switch ($c)
{ {
case KeyCode::CTRL('q'): case KeyCode::CTRL('q'):
return $this->quitAttempt(); $this->quitAttempt();
return;
case KeyType::ENTER:
$this->insertNewline();
break;
case KeyCode::CTRL('s'): case KeyCode::CTRL('s'):
$this->save(); $this->save();
@ -900,6 +934,10 @@ class Editor {
$this->find(); $this->find();
break; break;
case KeyType::ENTER:
$this->insertNewline();
break;
case KeyType::BACKSPACE: case KeyType::BACKSPACE:
case KeyType::DEL_KEY: case KeyType::DEL_KEY:
if ($c === KeyType::DEL_KEY) if ($c === KeyType::DEL_KEY)
@ -931,12 +969,14 @@ class Editor {
} }
// Reset quit confirmation timer on different keypress // Reset quit confirmation timer on different keypress
$this->quitTimes = KILO_QUIT_TIMES; if ($this->quitTimes < KILO_QUIT_TIMES)
{
return $c; $this->quitTimes = KILO_QUIT_TIMES;
$this->setStatusMessage('');
}
} }
protected function quitAttempt(): ?string protected function quitAttempt(): void
{ {
if ($this->dirty && $this->quitTimes > 0) if ($this->dirty && $this->quitTimes > 0)
{ {
@ -945,11 +985,12 @@ class Editor {
$this->quitTimes $this->quitTimes
); );
$this->quitTimes--; $this->quitTimes--;
return KeyCode::CTRL('q'); return;
} }
Terminal::clear(); Terminal::clear();
return NULL;
$this->shouldQuit = true;
} }
protected function refreshSyntax(): void protected function refreshSyntax(): void

View File

@ -2,7 +2,20 @@
namespace Aviat\Kilo; namespace Aviat\Kilo;
use Aviat\Kilo\Type\TerminalSize;
class Terminal { class Terminal {
/**
* Get the size of the current terminal window
*
* @codeCoverageIgnore
* @return TerminalSize
*/
public static function size(): TerminalSize
{
return new TerminalSize(...self::getWindowSize());
}
/** /**
* Get the size of the current terminal window * Get the size of the current terminal window
* *

View File

@ -1,6 +1,6 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
namespace Aviat\Kilo; namespace Aviat\Kilo\Type;
/** /**
* A representation of a 2d point * A representation of a 2d point

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace Aviat\Kilo\Type;
class StatusMessage {
private function __construct(
public string $text,
public int $time,
) {}
public static function from(string $text, mixed ...$args): self
{
return new self(sprintf($text, ...$args), time());
}
}

11
src/Type/TerminalSize.php Normal file
View File

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Aviat\Kilo\Type;
class TerminalSize {
public function __construct(public int $rows, public int $cols)
{
// Remove a row for the status bar, and one for the message bar
$this->rows -= 2;
}
}