diff --git a/docs/content/docs/reference/widgets/TextAreaWidget.md b/docs/content/docs/reference/widgets/TextAreaWidget.md new file mode 100644 index 00000000..6ca22fe7 --- /dev/null +++ b/docs/content/docs/reference/widgets/TextAreaWidget.md @@ -0,0 +1,24 @@ +--- +title: Text Area +description: +--- +## Text Area + +`PhpTui\Tui\Extension\TextArea\Widget\TextAreaWidget` + + +### Example + +{{% terminal file="/data/example/docs/widget/textAreaWidget.html" %}} +{{< details "Show code" >}} +{{% codeInclude file="/data/example/docs/widget/textAreaWidget.php" language="php" %}} + +{{< /details >}} +### Parameters + +Configure the widget using the builder methods named as follows: + +| Name | Type | Description | +| --- | --- | --- | +| **editor** | `PhpTui\Tui\Extension\TextArea\TextArea` | | +| **state** | `PhpTui\Tui\Extension\TextArea\Widget\TextAreaState` | | \ No newline at end of file diff --git a/example/demo/src/ActivePage.php b/example/demo/src/ActivePage.php index 11bda4b1..ec52aca5 100644 --- a/example/demo/src/ActivePage.php +++ b/example/demo/src/ActivePage.php @@ -20,6 +20,7 @@ enum ActivePage case CanvasScaling; case Gauge; case BarChart; + case TextEditor; public function navItem(): NavItem { @@ -36,6 +37,8 @@ public function navItem(): NavItem ActivePage::Gauge => new NavItem('!', 'guage'), ActivePage::BarChart => new NavItem('"', 'barchart'), ActivePage::CanvasScaling => new NavItem('0', 'scaling'), + ActivePage::TextEditor => new NavItem('$', 'editor'), + }; } diff --git a/example/demo/src/App.php b/example/demo/src/App.php index da7716cc..9b7f48d2 100644 --- a/example/demo/src/App.php +++ b/example/demo/src/App.php @@ -13,6 +13,7 @@ use PhpTui\Term\Terminal; use PhpTui\Tui\Bridge\PhpTerm\PhpTermBackend as PhpTuiPhpTermBackend; use PhpTui\Tui\DisplayBuilder; +use PhpTui\Tui\Example\Demo\Command\FocusCommand; use PhpTui\Tui\Example\Demo\Page\BarChartPage; use PhpTui\Tui\Example\Demo\Page\BlocksPage; use PhpTui\Tui\Example\Demo\Page\CanvasPage; @@ -25,11 +26,13 @@ use PhpTui\Tui\Example\Demo\Page\ItemListPage; use PhpTui\Tui\Example\Demo\Page\SpritePage; use PhpTui\Tui\Example\Demo\Page\TablePage; +use PhpTui\Tui\Example\Demo\Page\TextEditorPage; use PhpTui\Tui\Extension\Bdf\BdfExtension; use PhpTui\Tui\Extension\Core\Widget\BlockWidget; use PhpTui\Tui\Extension\Core\Widget\GridWidget; use PhpTui\Tui\Extension\Core\Widget\ParagraphWidget; use PhpTui\Tui\Extension\ImageMagick\ImageMagickExtension; +use PhpTui\Tui\Extension\TextArea\TextAreaExtension; use PhpTui\Tui\Model\Direction; use PhpTui\Tui\Model\Display\Backend; use PhpTui\Tui\Model\Display\Display; @@ -60,6 +63,8 @@ */ final class App { + private ?Component $focused = null; + /** * @param array $pages * @param int[] $frameSamples @@ -70,6 +75,7 @@ private function __construct( private ActivePage $activePage, private array $pages, private array $frameSamples, + private CommandBus $bus, ) { } @@ -77,6 +83,7 @@ public static function new(?Terminal $terminal = null, ?Backend $backend = null) { $terminal = $terminal ?? Terminal::new(); $pages = []; + $bus = new CommandBus([]); // build up an exhaustive set of pages foreach (ActivePage::cases() as $case) { @@ -91,6 +98,7 @@ public static function new(?Terminal $terminal = null, ?Backend $backend = null) ActivePage::Colors => new ColorsPage(), ActivePage::Images => new ImagePage(), ActivePage::CanvasScaling => new CanvasScalingPage($terminal), + ActivePage::TextEditor => new TextEditorPage($bus), ActivePage::Gauge => new GaugePage(), ActivePage::BarChart => new BarChartPage(), }; @@ -99,6 +107,7 @@ public static function new(?Terminal $terminal = null, ?Backend $backend = null) $display = DisplayBuilder::default($backend ?? PhpTuiPhpTermBackend::new($terminal)) ->addExtension(new ImageMagickExtension()) ->addExtension(new BdfExtension()) + ->addExtension(new TextAreaExtension()) ->build(); return new self( @@ -107,6 +116,7 @@ public static function new(?Terminal $terminal = null, ?Backend $backend = null) ActivePage::Events, $pages, [], + $bus, ); } @@ -137,8 +147,18 @@ private function doRun(): int // the main loop while (true) { + foreach ($this->bus->drain() as $command) { + if ($command instanceof FocusCommand) { + $this->focused = $command->component; + } + } // handle events sent to the terminal while (null !== $event = $this->terminal->events()->next()) { + if ($this->focused) { + $this->focused->handle($event); + + continue; + } if ($event instanceof CharKeyEvent) { if ($event->modifiers === KeyModifiers::NONE) { if ($event->char === 'q') { @@ -181,6 +201,9 @@ private function doRun(): int $this->activePage = ActivePage::BarChart; } } + if ($event->char === '$') { + $this->activePage = ActivePage::TextEditor; + } } if ($event instanceof CodedKeyEvent) { if ($event->code === KeyCode::Tab) { @@ -282,6 +305,6 @@ private function frameRate(): float return $ac; }, []); - return array_sum($bySecond) / count($bySecond); + return count($bySecond) > 0 ? array_sum($bySecond) / count($bySecond) : 0; } } diff --git a/example/demo/src/Command.php b/example/demo/src/Command.php new file mode 100644 index 00000000..00ea6d75 --- /dev/null +++ b/example/demo/src/Command.php @@ -0,0 +1,9 @@ +commands; + $this->commands = []; + + return $commands; + } + + public function dispatch(Command $command): void + { + $this->commands[] = $command; + } +} diff --git a/example/demo/src/Page/TextEditorPage.php b/example/demo/src/Page/TextEditorPage.php new file mode 100644 index 00000000..b66ffc19 --- /dev/null +++ b/example/demo/src/Page/TextEditorPage.php @@ -0,0 +1,203 @@ +editor = TextEditor::fromString( + <<<'EOT' + It little profits that an idle king, + By this still hearth, among these barren crags, + Match'd with an aged wife, I mete and dole + Unequal laws unto a savage race, + That hoard, and sleep, and feed, and know not me. + I cannot rest from travel: I will drink + Life to the lees: All times I have enjoy'd + Greatly, have suffer'd greatly, both with those + That loved me, and alone, on shore, and when + Thro' scudding drifts the rainy Hyades + Vext the dim sea: I am become a name; + For always roaming with a hungry heart + Much have I seen and known; cities of men + And manners, climates, councils, governments, + Myself not least, but honour'd of them all; + And drunk delight of battle with my peers, + Far on the ringing plains of windy Troy. + I am a part of all that I have met; + Yet all experience is an arch wherethro' + Gleams that untravell'd world whose margin fades + For ever and forever when I move. + How dull it is to pause, to make an end, + To rust unburnish'd, not to shine in use! + As tho' to breathe were life! Life piled on life + Were all too little, and of one to me + Little remains: but every hour is saved + From that eternal silence, something more, + A bringer of new things; and vile it were + For some three suns to store and hoard myself, + And this gray spirit yearning in desire + To follow knowledge like a sinking star, + Beyond the utmost bound of human thought. + EOT + ); + $this->state = new TextAreaState(0); + } + + public function build(): Widget + { + return GridWidget::default() + ->constraints( + Constraint::min(3), + Constraint::length(3), + ) + ->widgets( + TextAreaWidget::fromEditor($this->editor)->state($this->state), + BlockWidget::default() + ->borders(Borders::ALL) + ->widget( + ParagraphWidget::fromText( + Text::parse( + sprintf( + '%s | %s,%s %s', + $this->editing ? + 'EDITING' : + 'VIEWING', + $this->editor->cursorPosition()->x, + $this->editor->cursorPosition()->y, + $this->editing ? + 'hit ESC to stop editing' : + 'press "i" to start editing', + ) + ) + ) + ), + ); + } + + public function handle(Event $event): void + { + // global keys + if ($event instanceof CodedKeyEvent) { + if ($event->code === KeyCode::Down) { + $this->editor->cursorDown(); + } + if ($event->code === KeyCode::Enter) { + $this->editor->newLine(); + } + if ($event->code === KeyCode::Up) { + $this->editor->cursorUp(); + } + if ($event->code === KeyCode::Left) { + $this->editor->cursorLeft(); + } + if ($event->code === KeyCode::Right) { + $this->editor->cursorRight(); + } + } + + + // normal mode + if (false === $this->editing) { + if ($event instanceof CharKeyEvent) { + if ($event->char === 'i') { + $this->enableInsertMode(); + + return; + } + if ($event->char === 'A') { + $this->enableInsertMode(); + $this->editor->lineEnd(); + $this->editor->cursorRight(); + + return; + } + if ($event->char === 'j') { + $this->editor->cursorDown(); + } + if ($event->char === 'k') { + $this->editor->cursorUp(); + } + if ($event->char === 'h') { + $this->editor->cursorLeft(); + } + if ($event->char === 'l' || $event->char === ' ') { + $this->editor->cursorRight(); + } + if ($event->char === 'w') { + $this->editor->seekWordNext(); + } + if ($event->char === 'b') { + $this->editor->seekWordPrev(); + } + if ($event->char === 'x') { + $this->editor->delete(); + } + if ($event->char === '^') { + $this->editor->lineStart(); + } + if ($event->char === '$') { + $this->editor->lineEnd(); + } + } + if ($event instanceof CodedKeyEvent) { + if ($event->code === KeyCode::Backspace) { + $this->editor->cursorLeft(); + } + } + + return; + } + + // insert mode + if ($event instanceof CharKeyEvent) { + $this->editor->insert($event->char); + } + if ($event instanceof CodedKeyEvent) { + if ($event->code === KeyCode::Esc) { + $this->bus->dispatch(new FocusCommand(null)); + $this->editing = false; + } + + if ( + $event->code === KeyCode::Backspace + ) { + $this->editor->deleteBackwards(); + } + } + } + + private function enableInsertMode() + { + $this->bus->dispatch(new FocusCommand($this)); + $this->editing = true; + } + +} diff --git a/example/docs/widget/textAreaWidget.html b/example/docs/widget/textAreaWidget.html new file mode 100644 index 00000000..4233bdfa --- /dev/null +++ b/example/docs/widget/textAreaWidget.html @@ -0,0 +1,1076 @@ + + \ No newline at end of file diff --git a/example/docs/widget/textAreaWidget.php b/example/docs/widget/textAreaWidget.php new file mode 100644 index 00000000..2f0244e9 --- /dev/null +++ b/example/docs/widget/textAreaWidget.php @@ -0,0 +1,29 @@ +addExtension(new TextAreaExtension()) + ->build(); +$display->draw( + TextAreaWidget::fromString( + <<<'EOT' + Hear the sledges with the bells— + Silver bells! + What a world of merriment their melody foretells! + How they tinkle, tinkle, tinkle, + In the icy air of night! + While the stars that oversprinkle + All the heavens, seem to twinkle + With a crystalline delight; + Keeping time, time, time, + In a sort of Runic rhyme, + EOT + ) +); diff --git a/example/docs/widget/textAreaWidget.snapshot b/example/docs/widget/textAreaWidget.snapshot new file mode 100644 index 00000000..1975e205 --- /dev/null +++ b/example/docs/widget/textAreaWidget.snapshot @@ -0,0 +1,20 @@ + ▲ Hear the sledges with the bells— + █ Silver bells! + █ What a world of merriment their melody foretel + █ How they tinkle, tinkle, tinkle, + █ In the icy air of night! + █ While the stars that oversprinkle + █ All the heavens, seem to twinkle + █ With a crystalline delight; + █ Keeping time, time, time, + █ In a sort of Runic rhyme, + █ + █ + █ + █ + █ + █ + █ + █ + █ + ▼ \ No newline at end of file diff --git a/src/Extension/TextArea/TextAreaExtension.php b/src/Extension/TextArea/TextAreaExtension.php new file mode 100644 index 00000000..229b2813 --- /dev/null +++ b/src/Extension/TextArea/TextAreaExtension.php @@ -0,0 +1,23 @@ + $lines + */ + private function __construct( + private Position $cursor, + private array $lines, + ) { + } + + public static function fromString(string $contents): self + { + return new self(Position::at(0, 0), explode("\n", $contents)); + } + + public function toString(): string + { + return implode("\n", $this->lines); + } + + /** + * Return the text editors cursor position normalized to the bounds of the + * current line. + * + * If the cursor position is [10,0] and the current line is: + * + * ``` + * Hello + * ``` + * + * Then the cursor position will be returned as [4,0]. + */ + public function cursorPosition(): Position + { + return $this->cursor->change(function (int $x, int $y) { + $line = $this->lines[$y] ?? null; + if ($line === null) { + $y = count($this->lines) - 1; + $line = $this->lines[$y]; + } + if ($x > mb_strlen($line)) { + $x = max(0, mb_strlen($line) - 1); + } + + return [ + $x, + $y + ]; + }); + } + + /** + * Insert text at the given offset. If length is provided it will replace + * that number of multibyte characters. + * + * @param int<0,max> $length + */ + public function insert(string $text, int $length = 0): void + { + /** @phpstan-ignore-next-line */ + if ($length < 0) { + throw new OutOfRangeException(sprintf( + 'Insert length must be > 0, got %d', + $length + )); + } + + $line = $this->resolveLine(); + $line = sprintf( + '%s%s%s', + mb_substr($line, 0, $this->cursor->x), + $text, + mb_substr($line, $this->cursor->x + $length, mb_strlen($line) - $this->cursor->x), + ); + $this->cursor->x += mb_strlen($text); + $this->setLine($line); + } + + public function lineStart(): void + { + $this->cursor->x = 0; + $line = $this->resolveLine(); + if (mb_substr($line, 0, 1) === ' ') { + $this->seekWordNext(); + } + } + + public function lineEnd(): void + { + $line = $this->resolveLine(); + $this->cursor->x = mb_strlen($line) - 1; + } + + public function delete(): void + { + $line = $this->resolveLine(); + $line = sprintf( + '%s%s', + mb_substr($line, 0, $this->cursor->x), + mb_substr($line, $this->cursor->x + 1), + ); + $this->setLine($line); + $lineEnd = mb_strlen($line) - 1; + if ($lineEnd < $this->cursor->x) { + $this->cursor->x = max(0, $lineEnd); + } + } + + public function deleteBackwards(int $length = 1): void + { + $line = $this->resolveLine(); + $line = sprintf( + '%s%s', + mb_substr($line, 0, max(0, $this->cursor->x - $length)), + mb_substr($line, $this->cursor->x, max(0, mb_strlen($line) - $this->cursor->x)), + ); + $this->setLine($line); + $this->cursor->x = max(0, $this->cursor->x - 1); + } + + public function cursorLeft(int $amount = 1): void + { + $this->cursor = $this->cursor->change( + static fn (int $x, int $y): array => [max(0, $x - $amount), $y] + ); + } + + public function cursorRight(int $amount = 1): void + { + $line = $this->resolveLine(); + $this->cursor = $this->cursor->change( + static fn (int $x, int $y): array => [ + min(mb_strlen($line), $x + $amount), + $y + ] + ); + } + + public function cursorDown(int $amount = 1): void + { + $this->cursor = $this->cursor->change( + fn (int $x, int $y): array => [$x, min(count($this->lines) - 1, $y + $amount)] + ); + } + public function cursorUp(int $amount = 1): void + { + $this->cursor = $this->cursor->change( + static fn (int $x, int $y): array => [ + $x, + max(0, $y - $amount) + ] + ); + } + + /** + * @param string[] $lines + */ + public static function fromLines(array $lines): self + { + return new self(Position::at(0, 0), $lines); + } + + public function newLine(): void + { + $line = $this->resolveLine(); + $pre = mb_substr($line, 0, $this->cursor->x); + $this->lines = array_values([ + ...array_slice($this->lines, 0, $this->cursor->y), + $pre, + ...array_slice($this->lines, $this->cursor->y) + ]); + $post = mb_substr($line, $this->cursor->x); + $this->cursorDown(); + $this->setLine($post); + $this->cursor->x = 0; + } + + public function moveCursor(Position $position): void + { + $this->cursor = $position->change(function (int $x, int $y): array { + $line = $this->lines[$y] ?? null; + if ($line === null) { + $y = count($this->lines) - 1; + $line = $this->lines[$y]; + } + if ($x > mb_strlen($line)) { + $x = mb_strlen($line) - 1; + } + + return [ + $x, + $y + ]; + }); + } + + public function lineCount(): int + { + return count($this->lines); + } + + public function seekWordPrev(): void + { + if ($this->cursor->x === 0 && $this->cursor->y === 0) { + return; + } + + if ($this->cursor->x === 0) { + $this->cursorUp(); + $this->cursorEndOfLine(); + $this->seekWordPrev(); + + return; + } + + $line = mb_substr($this->resolveLine(), 0, $this->cursor->x); + $split = preg_split('{(\s+)}', $line, -1, PREG_SPLIT_OFFSET_CAPTURE); + + if (false === $split) { + return; + } + + if ($split !== []) { + $last = array_pop($split); + if ($this->cursor->x === $last[1]) { + $last = array_pop($split); + } + if ($last === null) { + return; + } + $this->cursor->x = $last[1]; + + return; + } + } + + public function seekWordNext(): void + { + $line = mb_substr($this->resolveLine(), $this->cursor->x); + $split = preg_split('{(\s+)}', $line, -1, PREG_SPLIT_OFFSET_CAPTURE); + + if (false === $split) { + return; + } + + if (count($split) > 1) { + // remove first element + array_shift($split); + $last = array_shift($split); + if (null === $last) { + // should never happen + return; + } + $this->cursor->x = $last[1] + $this->cursor->x; + + return; + } + + if (!isset($this->lines[$this->cursor->y + 1])) { + return; + } + $this->cursor->y++; + $this->cursor->x = 0; + if (!str_starts_with($this->lines[$this->cursor->y], ' ')) { + return; + } + $this->seekWordNext(); + } + + /** + * @return string[] + */ + public function viewportLines(int $offset, int $height): array + { + return array_slice($this->lines, $offset, $height); + } + + private function resolveLine(): string + { + $position = $this->cursor; + + // if the line at the cursor doesn't exist + if (!isset($this->lines[$position->y])) { + throw new RuntimeException(sprintf( + 'There is no line at position: %d', + $position->y + )); + } + + // return the current line + return $this->lines[$position->y]; + } + + private function setLine(string $line): void + { + $position = $this->cursor; + $this->lines[$position->y] = $line; + } + + private function cursorEndOfLine(): void + { + $line = $this->resolveLine(); + $this->cursor->x = mb_strlen($line); + } +} diff --git a/src/Extension/TextArea/Widget/TextAreaRenderer.php b/src/Extension/TextArea/Widget/TextAreaRenderer.php new file mode 100644 index 00000000..034eb17e --- /dev/null +++ b/src/Extension/TextArea/Widget/TextAreaRenderer.php @@ -0,0 +1,120 @@ +render( + $renderer, + CompositeWidget::fromWidgets( + BlockWidget::default()->padding(Padding::horizontal(1)) + ->widget( + ScrollbarWidget::default()->state(new ScrollbarState( + contentLength: $widget->editor->lineCount(), + position: $widget->editor->cursorPosition()->y + 1, + viewportContentLength: $buffer->area()->height + )) + ), + BlockWidget::default()->padding(Padding::fromScalars(4, 0, 0, 0)) + ->widget( + RawWidget::new(function (Buffer $buffer) use ($widget): void { + $area = $buffer->area(); + $editorCursor = $widget->editor->cursorPosition(); + $state = $widget->state; + $viewportOffset = $this->getViewportOffset($editorCursor, $state, $area); + $y = 0; + foreach ($widget->editor->viewportLines( + $viewportOffset, + $area->height + ) as $line) { + $buffer->putString(Position::at(0, $y), $line, null, $buffer->area()->width); + $y++; + if ($y > $buffer->area()->height) { + break; + } + } + $state->viewportOffset = $viewportOffset; + + $this->renderCursor($buffer, $widget); + }) + ) + ), + $buffer + ); + } + + private function renderCursor(Buffer $buffer, TextAreaWidget $widget): void + { + $cursorCell = $buffer->get($this->cursorPosition($widget)); + + if ($cursorCell->char === ' ') { + $cursorCell->char = BlockSet::FULL; + $cursorCell->setStyle(Style::default()->white()->onBlack()); + + return; + } + + $cursorCell->setStyle(Style::default()->black()->onWhite()); + } + + private function cursorPosition(TextAreaWidget $widget): Position + { + return $widget->editor->cursorPosition()->change( + static fn (int $x, int $y): array => [ + $x, + max(0, $y - $widget->state->viewportOffset) + ] + ); + } + + /** + * +--------------+ + * | first line | + * +--------------+ <- viewport offset + * | second line | + * | VIEWPORT | + * | worth line | + * +--------------+ <- viewport offset + viewport height + * | █ore text | + * +--------------+ + */ + private function getViewportOffset(Position $editorCursor, TextAreaState $state, Area $area): int + { + $viewportOffset = $state->viewportOffset; + + $viewportHeight = $area->height - 1; // cursor is zero based... + $cursorOffset = $editorCursor->y; + + if ($cursorOffset > $viewportOffset + $viewportHeight) { + return $cursorOffset - $viewportHeight; + } + + if ($cursorOffset < $viewportOffset) { + return $cursorOffset; + } + + return $viewportOffset; + } +} diff --git a/src/Extension/TextArea/Widget/TextAreaState.php b/src/Extension/TextArea/Widget/TextAreaState.php new file mode 100644 index 00000000..dd402665 --- /dev/null +++ b/src/Extension/TextArea/Widget/TextAreaState.php @@ -0,0 +1,13 @@ +state = $textAreaState; + + return $this; + } + +} diff --git a/src/Model/Display/Backend/DummyBackend.php b/src/Model/Display/Backend/DummyBackend.php index 9d6140c9..5aec2e96 100644 --- a/src/Model/Display/Backend/DummyBackend.php +++ b/src/Model/Display/Backend/DummyBackend.php @@ -89,6 +89,14 @@ public function moveCursor(Position $position): void $this->cursorPosition = $position; } + /** + * @return string[] + */ + public function toLines(): array + { + return explode("\n", $this->toString()); + } + /** * @return array> */ diff --git a/tests/Example/snapshot/BarChart.snapshot b/tests/Example/snapshot/BarChart.snapshot index 31028cf0..80fedc57 100644 --- a/tests/Example/snapshot/BarChart.snapshot +++ b/tests/Example/snapshot/BarChart.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ▁▁▁▁▁▁▁▁▁▁ ██1 12█████████████████ ▁▁▁▁▁▁▁▁▁▁ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██ diff --git a/tests/Example/snapshot/Blocks.snapshot b/tests/Example/snapshot/Blocks.snapshot index 00ea1ff5..36799b92 100644 --- a/tests/Example/snapshot/Blocks.snapshot +++ b/tests/Example/snapshot/Blocks.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌Borders::ALL──────────────────────────┐Borders::NONE │Lorem ipsum dolor sit amet, consectetu│Lorem ipsum dolor sit amet, consectetur diff --git a/tests/Example/snapshot/Canvas.snapshot b/tests/Example/snapshot/Canvas.snapshot index e15f2128..c6ae0a7b 100644 --- a/tests/Example/snapshot/Canvas.snapshot +++ b/tests/Example/snapshot/Canvas.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌World─────────────────────────────────────────────────────────────────────────┐ │ ⣀⣀⡀⣀⣀⣤⣤⣤⢤⣄⣤⣤⠤⠄⠤⠤⠄⠆⠢⠰⠤⣄⠄⠄ ⢀⣀⣄⣤⣄ ⢤⡤ ⡀ ⢤⣤⣀⣀ │ diff --git a/tests/Example/snapshot/Chart.snapshot b/tests/Example/snapshot/Chart.snapshot index 6a6326ca..328f85ba 100644 --- a/tests/Example/snapshot/Chart.snapshot +++ b/tests/Example/snapshot/Chart.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌Chart 1───────────────────────────────────────────────────────────────────────┐ │ 20│ ••• ⡜⠙⡄ ••• ⢀⠏⢣ ••• ⠜⠙⠆ ••• ⢠⠋⢓ ••• ⡜⠙⡄ ••• ⢀⠏⢣ ••• │ diff --git a/tests/Example/snapshot/Colors.snapshot b/tests/Example/snapshot/Colors.snapshot index cfc11d4e..67fdcd49 100644 --- a/tests/Example/snapshot/Colors.snapshot +++ b/tests/Example/snapshot/Colors.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ BlackRedGreenYellowBlueMagentaCyanGrayDarkGrayLightRedLightGreenLightYellowLight Blue diff --git a/tests/Example/snapshot/Gauge.snapshot b/tests/Example/snapshot/Gauge.snapshot index 18071066..a9fa7e85 100644 --- a/tests/Example/snapshot/Gauge.snapshot +++ b/tests/Example/snapshot/Gauge.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌Downloading 14 files──────────────────────────────────────────────────────────┐ │ │ diff --git a/tests/Example/snapshot/Home.snapshot b/tests/Example/snapshot/Home.snapshot index e78f0848..c5fea78d 100644 --- a/tests/Example/snapshot/Home.snapshot +++ b/tests/Example/snapshot/Home.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌Event log─────────────────────────────────────────────────────────────────────┐ │ │ diff --git a/tests/Example/snapshot/List.snapshot b/tests/Example/snapshot/List.snapshot index 49ad8537..e7bfaf5a 100644 --- a/tests/Example/snapshot/List.snapshot +++ b/tests/Example/snapshot/List.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ │INFO Event │ diff --git a/tests/Example/snapshot/Sprite.snapshot b/tests/Example/snapshot/Sprite.snapshot index 751c3069..69c8df4c 100644 --- a/tests/Example/snapshot/Sprite.snapshot +++ b/tests/Example/snapshot/Sprite.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ╭──────────────────────────────────────────────────────────────────────────────╮ │▄▄ ▄ ▄ ▄▄▄ ▄▄▄▄ ▄ ▄ ▄▄▄ ▄▄▄▄ ▄ ▄▄ ▄ ▄ │ diff --git a/tests/Example/snapshot/Table.snapshot b/tests/Example/snapshot/Table.snapshot index 7410a0ed..a836c11e 100644 --- a/tests/Example/snapshot/Table.snapshot +++ b/tests/Example/snapshot/Table.snapshot @@ -1,6 +1,6 @@ ┌─────────────────────────────────────────────────────────────────────────0 FPS┐ │[q]quit [1]events [2]canvas [3]chart [4]list [5]table [6]blocks [7]sprite [8]c│ -│olors [9]images [0]scaling [!]guage ["]barchart │ +│olors [9]images [0]scaling [!]guage ["]barchart [$]editor │ └──────────────────────────────────────────────────────────────────────────────┘ ┌Table─────────────────────────────────────────────────────────────────────────┐ │ Level Event Data │ diff --git a/tests/Unit/Extension/TextArea/TextAreaExtension.php b/tests/Unit/Extension/TextArea/TextAreaExtension.php new file mode 100644 index 00000000..e69de29b diff --git a/tests/Unit/Extension/TextArea/TextEditorTest.php b/tests/Unit/Extension/TextArea/TextEditorTest.php new file mode 100644 index 00000000..a5e3b744 --- /dev/null +++ b/tests/Unit/Extension/TextArea/TextEditorTest.php @@ -0,0 +1,313 @@ +insert('H'); + $editor->insert('ello'); + $editor->insert(' World'); + self::assertEquals('Hello World', $editor->toString()); + + $editor = TextEditor::fromString('Hello World'); + $editor->insert('Hello '); + self::assertEquals('Hello Hello World', $editor->toString()); + + $editor = TextEditor::fromString(''); + $editor->insert('Hello🐈Cat'); + $editor->insert('Hai'); + self::assertEquals('Hello🐈CatHai', $editor->toString()); + } + + /** + * @dataProvider provideMultibyte + * @param Closure(TextEditor):void $mutator + */ + public function testInsertMultibyte(string $input, Closure $mutator, string $expected): void + { + $editor = TextEditor::fromString($input); + $mutator($editor); + self::assertEquals($expected, $editor->toString()); + } + /** + * @return Generator + */ + public static function provideMultibyte(): Generator + { + yield 'delete' => [ + '🐈😸😺', + function (TextEditor $editor) { + $editor->lineEnd(); + $editor->cursorRight(); + $editor->deleteBackwards(); + $editor->deleteBackwards(); + $editor->deleteBackwards(); + }, + '' + ]; + } + + public function testInsertNewLine(): void + { + $editor = TextEditor::fromString('Hello World'); + $editor->newLine(); + self::assertEquals(<<<'EOT' + + Hello World + EOT, $editor->toString()); + } + + public function testInsertNewLineBetween(): void + { + $editor = TextEditor::fromString(<<<'EOT' + Hello + World + EOT); + $editor->cursorDown(); + $editor->newLine(); + self::assertEquals(<<<'EOT' + Hello + + World + EOT, $editor->toString()); + } + + public function testInsertNewLineAtOffset(): void + { + $editor = TextEditor::fromString(<<<'EOT' + Hello + World + EOT); + $editor->cursorRight(2); + + self::assertEquals(Position::at(2, 0), $editor->cursorPosition()); + $editor->newLine(); + + self::assertEquals(<<<'EOT' + He + llo + World + EOT, $editor->toString()); + self::assertEquals(Position::at(0, 1), $editor->cursorPosition()); + } + + public function testInsertReplace(): void + { + $editor = TextEditor::fromString('Hello World'); + $editor->insert('World', 5); + + self::assertEquals('World World', $editor->toString()); + + $editor->insert(' Space', 6); + self::assertEquals('World Space', $editor->toString()); + } + + public function testInsertReplaceNegativeLength(): void + { + $this->expectException(OutOfRangeException::class); + $editor = TextEditor::fromString('Hello World'); + /** @phpstan-ignore-next-line */ + $editor->insert('World', -5); + } + + public function testDelete(): void + { + $editor = TextEditor::fromString('Hello'); + $editor->moveCursor(Position::at(2, 0)); + $editor->delete(); + self::assertEquals('Helo', $editor->toString()); + } + + public function testDeleteMovesCursorBackAtEol(): void + { + $editor = TextEditor::fromString('Hello'); + $editor->moveCursor(Position::at(4, 0)); + $editor->delete(); + self::assertEquals('Hell', $editor->toString()); + $editor->delete(); + self::assertEquals('Hel', $editor->toString()); + $editor->delete(); + $editor->delete(); + $editor->delete(); + $editor->delete(); + $editor->delete(); + $editor->delete(); + self::assertEquals('', $editor->toString()); + } + + public function testLineStartAndEnd(): void + { + $editor = TextEditor::fromString('Hello World'); + $editor->moveCursor(Position::at(7, 0)); + $editor->lineStart(); + self::assertEquals(Position::at(0, 0), $editor->cursorPosition()); + + // start of word is the start of the line + $editor = TextEditor::fromString(' Hello'); + $editor->lineStart(); + self::assertEquals(Position::at(3, 0), $editor->cursorPosition()); + + $editor = TextEditor::fromString('Hello'); + $editor->lineEnd(); + self::assertEquals(Position::at(4, 0), $editor->cursorPosition()); + } + + public function testDeleteBackwards(): void + { + $editor = TextEditor::fromString(''); + $editor->insert('Hello'); + $editor->deleteBackwards(); + self::assertEquals('Hell', $editor->toString()); + $editor->deleteBackwards(); + self::assertEquals('Hel', $editor->toString()); + + $editor->deleteBackwards(2); + self::assertEquals('H', $editor->toString()); + + $editor = TextEditor::fromString(''); + $editor->insert('Hello🐈Cat'); + $editor->deleteBackwards(4); + self::assertEquals('Hello', $editor->toString()); + } + + public function testDeleteAtZero(): void + { + $editor = TextEditor::fromString('Hello'); + $editor->deleteBackwards(4); + self::assertEquals('Hello', $editor->toString()); + } + + public function testNextWord(): void + { + $editor = TextEditor::fromString('Hello World'); + $editor->seekWordNext(); + self::assertEquals(Position::at(6, 0), $editor->cursorPosition()); + $editor->deleteBackwards(); + self::assertEquals(Position::at(5, 0), $editor->cursorPosition()); + self::assertEquals('HelloWorld', $editor->toString()); + } + + public function testNextWordCallMultipleTimes(): void + { + $editor = TextEditor::fromString('Hello World This Is Something'); + $editor->seekWordNext(); + $editor->seekWordNext(); + $editor->seekWordNext(); + self::assertEquals(Position::at(17, 0), $editor->cursorPosition()); + } + + public function testNextWordMultiline(): void + { + $editor = TextEditor::fromString( + <<<'EOT' + Hello + This + EOT + ); + $editor->seekWordNext(); + self::assertEquals(Position::at(0, 1), $editor->cursorPosition()); + } + + public function testPrevWord(): void + { + $editor = TextEditor::fromString('Hello World Goodbye'); + $editor->moveCursor(Position::at(15, 0)); + $editor->seekWordPrev(); + $editor->deleteBackwards(); + self::assertEquals('Hello WorldGoodbye', $editor->toString()); + } + + public function testPrevWordOnWordStart(): void + { + $editor = TextEditor::fromString('Hello World Goodbye'); + $editor->moveCursor(Position::at(6, 0)); + $editor->seekWordPrev(); + $editor->cursorRight(); + $editor->deleteBackwards(); + self::assertEquals('ello World Goodbye', $editor->toString()); + } + + public function testPrevWordMultiline(): void + { + $editor = TextEditor::fromString( + <<<'EOT' + Hello World + This + EOT + ); + $editor->moveCursor(Position::at(0, 1)); + $editor->seekWordPrev(); + $editor->deleteBackwards(); + self::assertEquals( + <<<'EOT' + HelloWorld + This + EOT, + $editor->toString() + ); + } + + public function testNavigate(): void + { + $editor = TextEditor::fromLines([ + '0123', + '01234567', + ]); + + self::assertEquals(Position::at(0, 0), $editor->cursorPosition()); + + $editor->cursorLeft(); + self::assertEquals(Position::at(0, 0), $editor->cursorPosition()); + + $editor->cursorRight(); + self::assertEquals(Position::at(1, 0), $editor->cursorPosition()); + $editor->cursorRight(); + self::assertEquals(Position::at(2, 0), $editor->cursorPosition()); + $editor->cursorRight(); + self::assertEquals(Position::at(3, 0), $editor->cursorPosition()); + $editor->cursorRight(); + self::assertEquals(Position::at(4, 0), $editor->cursorPosition()); + $editor->cursorRight(); + self::assertEquals(Position::at(4, 0), $editor->cursorPosition()); + + $editor->cursorLeft(); + $editor->cursorLeft(); + $editor->cursorLeft(); + $editor->cursorLeft(); + self::assertEquals(Position::at(0, 0), $editor->cursorPosition()); + + $editor->cursorDown(); + self::assertEquals(Position::at(0, 1), $editor->cursorPosition()); + + $editor->cursorDown(); + self::assertEquals(Position::at(0, 1), $editor->cursorPosition()); + + $editor->cursorUp(); + $editor->cursorUp(); + self::assertEquals(Position::at(0, 0), $editor->cursorPosition()); + } + + public function testCursorUpToShorterLine(): void + { + $area = TextEditor::fromLines([ + 'Goodbye', + 'Hello World', + ]); + $area->cursorDown(); + $area->lineEnd(); + $area->cursorUp(); + self::assertEquals(Position::at(6, 0), $area->cursorPosition()); + } +} diff --git a/tests/Unit/Extension/TextArea/Widget/TextAreaRendererTest.php b/tests/Unit/Extension/TextArea/Widget/TextAreaRendererTest.php new file mode 100644 index 00000000..edd941d9 --- /dev/null +++ b/tests/Unit/Extension/TextArea/Widget/TextAreaRendererTest.php @@ -0,0 +1,147 @@ +render(TextAreaWidget::fromString($text)), + ); + } + + public function testSrollbarAtMax(): void + { + $text = <<<'EOT' + Let us go then you and I + When the evening is spread out against the sky + Like a patient etherized upon a table + Let us go through half-deserted streets + The muttering retreats + Of restless nights in one night cheap hotels + And swadust restaurants with oyster shells + Streets that follow like a tedious argument + Of insidiuous intent + Oh! do not ask what is it! + Let us go and make our visit... + EOT + ; + $editor = TextEditor::fromString($text); + $editor->cursorDown(100); + + self::assertEquals( + [ + ' ▲ And sw', + ' ║ Street', + ' ║ Of ins', + ' █ Oh! do', + ' ▼ Let us', + ], + $this->render(TextAreaWidget::fromEditor($editor)), + ); + } + + public function testRenderScrollViewportDown(): void + { + $text = <<<'EOT' + Let us go then you and I + When the evening is spread out against the sky + Like a patient etherized upon a table + Let us go through underserted streets + The muttering retreats + Of restless nights in one night cheap hotels + EOT + ; + + $widget = TextAreaWidget::fromString($text); + $widget->editor->cursorDown(4); + self::assertEquals( + [ + ' ▲ Like a', + ' █ Let us', + ' ▼ The mu', + ], + $this->render($widget, 3), + ); + } + + public function testDoNotScrollUpUnlessCursorIsGoingOutOfBounds(): void + { + $text = <<<'EOT' + Let us go then you and I + When the evening is spread out against the sky + Like a patient etherized upon a table + Let us go through underserted streets + The muttering retreats + Of restless nights in one night cheap hotels + EOT + ; + + $widget = TextAreaWidget::fromString($text); + $widget->editor->cursorDown(5); + self::assertEquals( + [ + ' ▲ Let us', + ' █ The mu', + ' ▼ Of res', + ], + $this->render($widget, 3), + ); + $widget->editor->cursorUp(2); + self::assertEquals( + [ + ' ▲ Let us', + ' █ The mu', + ' ▼ Of res', + ], + $this->render($widget, 3), + ); + $widget->editor->cursorUp(5); + self::assertEquals( + [ + ' ▲ Let us', + ' █ When t', + ' ▼ Like a', + ], + $this->render($widget, 3), + ); + } + + /** + * @return string[] + */ + private function render(Widget $widget, int $height = 5): array + { + $backend = DummyBackend::fromDimensions(10, $height); + DisplayBuilder::default($backend)->addExtension( + new TextAreaExtension() + )->build()->draw($widget); + + return $backend->toLines(); + } +}