diff --git a/packages/console/src/Components/Concerns/HasTextBuffer.php b/packages/console/src/Components/Concerns/HasTextBuffer.php index 6263d447e9..f8784e95f4 100644 --- a/packages/console/src/Components/Concerns/HasTextBuffer.php +++ b/packages/console/src/Components/Concerns/HasTextBuffer.php @@ -143,7 +143,18 @@ public function deletePreviousCharacter(): void $this->buffer->deletePreviousCharacter(); } + #[HandlesKey(Key::CMD_BACKSPACE)] + public function deleteCurrentLine(): void + { + if (! $this->bufferEnabled) { + return; + } + + $this->buffer->deleteCurrentLine(); + } + #[HandlesKey(Key::CTRL_BACKSPACE)] + #[HandlesKey(Key::ALT_BACKSPACE)] public function deletePreviousWord(): void { if (! $this->bufferEnabled) { diff --git a/packages/console/src/Components/InteractiveComponentRenderer.php b/packages/console/src/Components/InteractiveComponentRenderer.php index a77ccc571a..203eb1be03 100644 --- a/packages/console/src/Components/InteractiveComponentRenderer.php +++ b/packages/console/src/Components/InteractiveComponentRenderer.php @@ -26,6 +26,10 @@ final class InteractiveComponentRenderer private bool $shouldRerender = true; + /** @var list */ + private array $pendingKeys = []; + private string $pendingInput = ''; + public function __construct( private readonly Validator $validator, ) {} @@ -87,7 +91,7 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso } usleep(50); - $key = $console->read(16); + $key = $this->readKey($console); // If there's no keypress, continue. if ($key === '') { @@ -167,6 +171,84 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso } } + private function readKey(Console $console): string + { + while ($this->pendingKeys === []) { + $input = $console->read(16); + + if ($input === '') { + return ''; + } + + $this->pendingInput .= $input; + $this->pendingKeys = $this->splitKeys($this->pendingInput); + } + + return array_shift($this->pendingKeys); + } + + /** @return list */ + private function splitKeys(string &$input): array + { + /** @var null|list $knownKeys */ + static $knownKeys = null; + + if ($knownKeys === null) { + $knownKeys = array_map( + static fn (Key $key): string => $key->value, + Key::cases(), + ); + usort($knownKeys, static fn (string $left, string $right): int => strlen($right) <=> strlen($left)); + } + + $keys = []; + + while ($input !== '') { + foreach ($knownKeys as $knownKey) { + if (! str_starts_with($input, $knownKey)) { + continue; + } + + $keys[] = $knownKey; + $input = substr($input, strlen($knownKey)); + + continue 2; + } + + if (str_starts_with($input, "\e")) { + $matches = []; + preg_match('/^\e(?:\[[0-?]*[ -\/]*[@-~]|O.)/s', $input, $matches); + $key = $matches[0] ?? mb_substr($input, 0, 2); + } else { + $length = $this->getUtf8CharacterLength($input); + + if (strlen($input) < $length) { + break; + } + + $key = substr($input, 0, $length); + } + + $keys[] = $key; + $input = substr($input, strlen($key)); + } + + return $keys; + } + + private function getUtf8CharacterLength(string $input): int + { + $firstByte = ord($input[0]); + + return match (true) { + ($firstByte & 0x80) === 0 => 1, + ($firstByte & 0xE0) === 0xC0 => 2, + ($firstByte & 0xF0) === 0xE0 => 3, + ($firstByte & 0xF8) === 0xF0 => 4, + default => 1, + }; + } + private function renderFrames(InteractiveConsoleComponent $component, Terminal $terminal): mixed { while (true) { @@ -180,6 +262,8 @@ private function renderFrames(InteractiveConsoleComponent $component, Terminal $ continue; } + $this->shouldRerender = false; + // Rerender the frames, it could be one or more $frames = $terminal->render( component: $component, @@ -195,9 +279,6 @@ private function renderFrames(InteractiveConsoleComponent $component, Terminal $ $return = $frames->getReturn(); - // Everything's rerendered - $this->shouldRerender = false; - if ($return !== null) { return $return; } diff --git a/packages/console/src/Components/Renderers/TextInputRenderer.php b/packages/console/src/Components/Renderers/TextInputRenderer.php index 5d60068113..a1b2ce2cf6 100644 --- a/packages/console/src/Components/Renderers/TextInputRenderer.php +++ b/packages/console/src/Components/Renderers/TextInputRenderer.php @@ -10,6 +10,7 @@ use Tempest\Console\Terminal\Terminal; use Tempest\Support\Str\ImmutableString; +use function Tempest\Support\arr; use function Tempest\Support\str; final class TextInputRenderer @@ -35,14 +36,11 @@ public function render( $this->label($label); if ($hint) { - $this->offsetY++; + $this->offsetY += substr_count($hint, "\n") + 1; $this->line($this->style('fg-gray', $hint))->newLine(); } - // splits the text to an array so we can work with individual lines - $lines = str($buffer->text ?: ($placeholder ?: '')) - ->explode("\n") - ->flatMap(fn (string $line) => str($line)->chunk($this->maxLineCharacters)->toArray()) + $lines = arr($buffer->getWrappedLines($this->maxLineCharacters, $placeholder)) ->map(static fn (string $line) => str($line)->replaceEnd("\n", ' ')); // calculates scroll offset based on cursor position diff --git a/packages/console/src/Components/TextBuffer.php b/packages/console/src/Components/TextBuffer.php index 85d24400d8..4e8c6f2e1d 100644 --- a/packages/console/src/Components/TextBuffer.php +++ b/packages/console/src/Components/TextBuffer.php @@ -6,8 +6,6 @@ use Tempest\Console\Point; -use function Tempest\Support\str; - /** * Offers the ability to manipulate a string based on a cursor position. */ @@ -23,7 +21,7 @@ public function __construct( public function setText(?string $text): void { $this->text = str_replace("\r\n", "\n", $text ?? ''); - $this->cursor = mb_strlen($this->text); + $this->cursor = $this->getGraphemeLength($this->text); } public function input(string $key): void @@ -32,22 +30,19 @@ public function input(string $key): void return; } - $this->text = str($this->text) - ->insertAt($this->cursor, str_replace("\r\n", "\n", $key)) - ->toString(); + $input = str_replace("\r\n", "\n", $key); - $this->moveCursorX(mb_strlen($key)); + $this->replaceGraphemes($this->cursor, 0, $input); + $this->moveCursorX($this->getGraphemeLength($input)); } public function deleteNextCharacter(): void { - if ($this->cursor === mb_strlen($this->text)) { + if ($this->cursor === $this->getGraphemeLength($this->text)) { return; } - $this->text = str($this->text) - ->replaceAt($this->cursor, 1, '') - ->toString(); + $this->replaceGraphemes($this->cursor, 1); } public function deletePreviousCharacter(): void @@ -56,43 +51,46 @@ public function deletePreviousCharacter(): void return; } - $this->text = str($this->text) - ->replaceAt($this->cursor, -1, '') - ->toString(); - + $this->replaceGraphemes($this->cursor - 1, 1); $this->moveCursorX(-1); } + public function deleteCurrentLine(): void + { + $lines = $this->getLines(); + $linePositions = $this->getLinePositions(); + $currentLineIndex = $this->getCurrentLineIndex(); + $lineStart = $linePositions[$currentLineIndex]; + $lineLength = $this->getGraphemeLength($lines[$currentLineIndex]); + + $this->replaceGraphemes($lineStart, $lineLength); + $this->cursor = $lineStart; + } + public function moveCursorToPreviousWord(): void { if ($this->cursor === 0) { return; } - $end = $this->cursor; - $pos = $end - 1; + $graphemes = $this->getGraphemes($this->text); + $position = $this->cursor - 1; - // Ignore whitespace - while ($pos >= 0 && $this->isWhitespace($this->text[$pos])) { - $pos--; + while ($position >= 0 && $this->isWhitespace($graphemes[$position])) { + $position--; } - // If we started on a word character, keep going until we hit non-word - if ($pos >= 0 && $this->isAlphaNumeric($this->text[$pos])) { - while ($pos >= 0 && $this->isAlphaNumeric($this->text[$pos])) { - $pos--; + if ($position >= 0 && $this->isAlphaNumeric($graphemes[$position])) { + while ($position >= 0 && $this->isAlphaNumeric($graphemes[$position])) { + $position--; } - - $pos++; // Move back to include the first character of the word - } elseif ($pos >= 0) { // If we started on a non-word character, we delete symbols - while ($pos >= 0 && $this->isSymbol($this->text[$pos])) { - $pos--; + } elseif ($position >= 0) { + while ($position >= 0 && $this->isSymbol($graphemes[$position])) { + $position--; } - - $pos++; // Move back to include the first character of the word } - $this->cursor = $pos; + $this->cursor = $position + 1; } public function deletePreviousWord(): void @@ -100,36 +98,35 @@ public function deletePreviousWord(): void $previousCursor = $this->cursor; $this->moveCursorToPreviousWord(); - - $this->text = substr($this->text, 0, $this->cursor) . substr($this->text, $previousCursor); + $this->replaceGraphemes($this->cursor, $previousCursor - $this->cursor); } public function moveCursorToNextWord(): void { - if ($this->cursor >= mb_strlen($this->text)) { + $graphemes = $this->getGraphemes($this->text); + $length = count($graphemes); + + if ($this->cursor >= $length) { return; } - $start = $this->cursor; - $pos = $start; + $position = $this->cursor; - // Skip leading whitespace - while ($pos < mb_strlen($this->text) && $this->isWhitespace($this->text[$pos])) { - $pos++; + while ($position < $length && $this->isWhitespace($graphemes[$position])) { + $position++; } - // If we start on a word, keep going until we hit non-word - if ($pos < mb_strlen($this->text) && $this->isAlphaNumeric($this->text[$pos])) { - while ($pos < mb_strlen($this->text) && $this->isAlphaNumeric($this->text[$pos])) { - $pos++; + if ($position < $length && $this->isAlphaNumeric($graphemes[$position])) { + while ($position < $length && $this->isAlphaNumeric($graphemes[$position])) { + $position++; } - } elseif ($pos < mb_strlen($this->text)) { // If we started on a non-word character, just delete that - while ($pos < mb_strlen($this->text) && $this->isSymbol($this->text[$pos])) { - $pos++; + } elseif ($position < $length) { + while ($position < $length && $this->isSymbol($graphemes[$position])) { + $position++; } } - $this->cursor = $pos; + $this->cursor = $position; } public function deleteNextWord(): void @@ -137,14 +134,13 @@ public function deleteNextWord(): void $previousCursor = $this->cursor; $this->moveCursorToNextWord(); - - $this->text = substr($this->text, 0, $previousCursor) . substr($this->text, $this->cursor); + $this->replaceGraphemes($previousCursor, $this->cursor - $previousCursor); $this->cursor = $previousCursor; } public function setCursorIndex(int $index): void { - $this->cursor = min(max(0, $index), mb_strlen($this->text ?? '')); + $this->cursor = min(max(0, $index), $this->getGraphemeLength($this->text)); } public function moveCursorX(int $offset): void @@ -169,7 +165,7 @@ public function moveCursorY(int $offset): void } $xOffset = $this->cursor - $linePositions[$currentLineIndex]; - $newPosition = $linePositions[$targetLineIndex] + min($xOffset, mb_strlen($lines[$targetLineIndex])); + $newPosition = $linePositions[$targetLineIndex] + min($xOffset, $this->getGraphemeLength($lines[$targetLineIndex])); $this->setCursorIndex($newPosition); } @@ -199,7 +195,7 @@ public function moveCursorToEndOfLine(): void $currentLine = $lines[$currentLineIndex]; $lineStart = $linePositions[$currentLineIndex]; - $this->setCursorIndex($lineStart + mb_strlen($currentLine)); + $this->setCursorIndex($lineStart + $this->getGraphemeLength($currentLine)); } public function moveCursorToStart(): void @@ -209,65 +205,73 @@ public function moveCursorToStart(): void public function moveCursorToEnd(): void { - $this->setCursorIndex(mb_strlen($this->text)); + $this->setCursorIndex($this->getGraphemeLength($this->text)); } - // This method returns the X and Y coordinates of the cursor, relative to the text only. - // The difficulty resides in the fact that some lines are wrapped (due to `$maxLineCharacters`) - // and some lines are simply using `\n`, resulting in potential one-off cursor positioning errors. - // Good luck refactoring that! public function getRelativeCursorPosition(?int $maxLineCharacters = null): Point { - $cursorPosition = $this->cursor; - $lines = str($this->text ?? '')->explode("\n"); + $x = 0; + $y = 0; - $yPosition = 0; - $xPosition = 0; - $lineIndex = 0; - $charIndex = 0; + foreach (array_slice($this->getGraphemes($this->text), 0, $this->cursor) as $grapheme) { + if ($grapheme === "\n") { + $x = 0; + $y++; - foreach ($lines as $line) { - $splitLines = str($line)->chunk($maxLineCharacters ?? mb_strlen($line))->toArray(); + continue; + } - foreach ($splitLines as $splitLineIndex => $splitLine) { - $lineLength = mb_strlen($splitLine); + $width = $this->getGraphemeWidth($grapheme); - // If the cursor is within this line, update the x position and return. - if (($charIndex + $lineLength) >= $cursorPosition) { - $xPosition = $cursorPosition - $charIndex; + if ($maxLineCharacters !== null && $x > 0 && ($x + $width) > $maxLineCharacters) { + $x = 0; + $y++; + } - return new Point($xPosition, $yPosition); - } + $x += $width; + } + + return new Point($x, $y); + } + + /** + * @return list + */ + public function getWrappedLines(int $maximumWidth, ?string $fallback = null): array + { + $maximumWidth = max(1, $maximumWidth); + $wrappedLines = []; + + foreach (explode("\n", $this->text ?: $fallback ?? '') as $line) { + $wrappedLine = ''; + $wrappedLineWidth = 0; - // If the cursor is not within this line, update the character index and y position. - $charIndex += $lineLength; + foreach ($this->getGraphemes($line) as $grapheme) { + $width = $this->getGraphemeWidth($grapheme); - // If this is not the last split line, increment the y position. - if ($splitLineIndex < (count($splitLines) - 1)) { - $yPosition++; + if ($wrappedLine !== '' && ($wrappedLineWidth + $width) > $maximumWidth) { + $wrappedLines[] = $wrappedLine; + $wrappedLine = ''; + $wrappedLineWidth = 0; } - } - // If this is not the last line, increment the y position and reset the character index. - if ($lineIndex < (count($lines) - 1)) { - $yPosition++; - $charIndex += 1; // Account for the newline character + $wrappedLine .= $grapheme; + $wrappedLineWidth += $width; } - $lineIndex++; + $wrappedLines[] = $wrappedLine; } - // If the cursor is beyond the last line, set the x position to the length of the last line. - $xPosition = str($lines->last())->length(); - - return new Point($xPosition, $yPosition); + return $wrappedLines; } + /** @return list */ private function getLines(): array { - return str($this->text)->explode("\n")->toArray(); + return explode("\n", $this->text ?? ''); } + /** @return list */ private function getLinePositions(): array { $lines = $this->getLines(); @@ -276,7 +280,7 @@ private function getLinePositions(): array foreach ($lines as $line) { $positions[] = $position; - $position += mb_strlen($line) + 1; // +1 for newline + $position += $this->getGraphemeLength($line) + 1; } return $positions; @@ -289,7 +293,7 @@ private function getCurrentLineIndex(): int foreach ($linePositions as $index => $startPosition) { $nextPosition = ($index + 1) < count($linePositions) ? $linePositions[$index + 1] - : mb_strlen($this->text) + 1; + : $this->getGraphemeLength($this->text) + 1; if ($this->cursor >= $startPosition && $this->cursor < $nextPosition) { return $index; @@ -299,18 +303,56 @@ private function getCurrentLineIndex(): int return count($linePositions) - 1; // Default to last line if not found } - private function isWhitespace(string $char): bool + private function isWhitespace(string $grapheme): bool + { + return preg_match('/^\s+$/u', $grapheme) === 1; + } + + private function isAlphaNumeric(string $grapheme): bool + { + return preg_match('/[\p{L}\p{N}_]/u', $grapheme) === 1; + } + + private function isSymbol(string $grapheme): bool { - return preg_match('/\s/', $char) === 1; + return ! $this->isWhitespace($grapheme) && ! $this->isAlphaNumeric($grapheme); } - private function isAlphaNumeric(string $char): bool + /** @return list */ + private function getGraphemes(?string $text): array { - return preg_match('/[\w]/', $char) === 1; + /** @var array{0: list} $matches */ + $matches = []; + preg_match_all('/\X/u', $text ?? '', $matches); + + return $matches[0]; } - private function isSymbol(string $char): bool + private function getGraphemeLength(?string $text): int { - return preg_match('/[^\w\s]/', $char) === 1; + return count($this->getGraphemes($text)); + } + + private function getGraphemeWidth(string $grapheme): int + { + $hasEmojiPresentation = preg_match('/[\p{Emoji_Presentation}\x{20E3}]/u', $grapheme) === 1; + $hasEmojiVariation = preg_match('/\p{Extended_Pictographic}\x{FE0F}/u', $grapheme) === 1; + + if ($hasEmojiPresentation || $hasEmojiVariation) { + return 2; + } + + $printable = preg_replace('/[\p{M}\p{Cf}\p{Emoji_Modifier}]/u', '', $grapheme); + + return mb_strwidth($printable ?? $grapheme, 'UTF-8'); + } + + private function replaceGraphemes(int $position, int $length, string $replacement = ''): void + { + $graphemes = $this->getGraphemes($this->text); + + array_splice($graphemes, $position, $length, $this->getGraphemes($replacement)); + + $this->text = implode('', $graphemes); } } diff --git a/packages/console/src/Key.php b/packages/console/src/Key.php index 39cedb9239..2bf3f97f33 100644 --- a/packages/console/src/Key.php +++ b/packages/console/src/Key.php @@ -16,6 +16,8 @@ enum Key: string case ENTER = "\n"; case ALT_ENTER = "\e\n"; case BACKSPACE = "\x7F"; + case ALT_BACKSPACE = "\e\x7F"; + case CMD_BACKSPACE = "\x15"; case CTRL_BACKSPACE = "\x17"; case DELETE = "\e[3~"; case CTRL_DELETE = "\ed"; diff --git a/packages/console/src/Terminal/Terminal.php b/packages/console/src/Terminal/Terminal.php index 186b862fdd..3f21d91a9b 100644 --- a/packages/console/src/Terminal/Terminal.php +++ b/packages/console/src/Terminal/Terminal.php @@ -123,6 +123,7 @@ public function render(InteractiveConsoleComponent $component, array $validation } if ($this->previousRender !== $content) { + $this->cursor->hide(); $this->clear(); $this->write($content); $this->resetInitialCursor(); diff --git a/packages/console/tests/TerminalTest.php b/packages/console/tests/TerminalTest.php new file mode 100644 index 0000000000..422c41482d --- /dev/null +++ b/packages/console/tests/TerminalTest.php @@ -0,0 +1,79 @@ +createStub(Console::class); + $console + ->method('write') + ->willReturnCallback(function (string $_) use (&$events, $console): Console { + $events[] = 'console:write'; + + return $console; + }); + + $cursor = $this->createStub(Cursor::class); + $cursor + ->method('hide') + ->willReturnCallback(function () use (&$events, $cursor): Cursor { + $events[] = 'cursor:hide'; + + return $cursor; + }); + $cursor + ->method('place') + ->willReturnCallback(function (Point $_) use (&$events, $cursor): Cursor { + $events[] = 'cursor:place'; + + return $cursor; + }); + $cursor + ->method('clearAfter') + ->willReturnCallback(function () use (&$events, $cursor): Cursor { + $events[] = 'cursor:clearAfter'; + + return $cursor; + }); + $cursor + ->method('show') + ->willReturnCallback(function () use (&$events, $cursor): Cursor { + $events[] = 'cursor:show'; + + return $cursor; + }); + + $terminal = new Terminal($console); + $terminal->disableTty(); + $terminal->cursor = $cursor; + + iterator_to_array($terminal->render(new TextInputComponent(label: 'Name'))); + + $this->assertSame( + [ + 'cursor:hide', + 'cursor:place', + 'cursor:clearAfter', + 'console:write', + 'cursor:place', + 'cursor:show', + ], + $events, + ); + } +} diff --git a/packages/console/tests/TextBufferTest.php b/packages/console/tests/TextBufferTest.php index e492dc8f3f..9a8b21905b 100644 --- a/packages/console/tests/TextBufferTest.php +++ b/packages/console/tests/TextBufferTest.php @@ -16,6 +16,9 @@ final class TextBufferTest extends TestCase { #[TestWith(['Hello', 5])] #[TestWith(['', 0])] + #[TestWith(["e\u{0301}", 1])] + #[TestWith(['πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 1])] + #[TestWith(['δ½ ε₯½', 2])] #[Test] public function construct(string $text, int $cursor): void { @@ -43,6 +46,8 @@ public function set_text(string $initialText, ?string $nextText = null, ?string #[TestWith([5, 11, 'Scott ', 'Leon Kennedy', 'Leon Scott Kennedy'])] #[TestWith([6, 7, '.', 'Leon S', 'Leon S.'])] #[TestWith([0, 1, '0', '123', '0123'])] + #[TestWith([1, 2, "e\u{0301}", 'δ½ a', "δ½ e\u{0301}a"])] + #[TestWith([1, 2, 'πŸ‘πŸ½', 'δ½ a', 'δ½ πŸ‘πŸ½a'])] #[Test] public function input(int $initialCursor, int $expectedCursor, string $input, string $initialText, string $expectedText): void { @@ -55,6 +60,21 @@ public function input(int $initialCursor, int $expectedCursor, string $input, st $this->assertSame($expectedCursor, $buffer->cursor); } + #[TestWith([13, 0, 'complete line', ''])] + #[TestWith([8, 6, "first\nsecond\nthird", "first\n\nthird"])] + #[TestWith([3, 2, "δ½ \nπŸ‘πŸ½x\nz", "δ½ \n\nz"])] + #[Test] + public function delete_current_line(int $initialCursor, int $expectedCursor, string $initialText, string $expectedText): void + { + $buffer = new TextBuffer($initialText); + + $buffer->cursor = $initialCursor; + $buffer->deleteCurrentLine(); + + $this->assertSame($expectedText, $buffer->text); + $this->assertSame($expectedCursor, $buffer->cursor); + } + #[TestWith([11, 8, 'foo-bar-baz', 'foo-bar-'])] #[TestWith([8, 7, 'foo-bar-', 'foo-bar'])] #[TestWith([7, 4, 'foo-bar', 'foo-'])] @@ -64,6 +84,9 @@ public function input(int $initialCursor, int $expectedCursor, string $input, st #[TestWith([12, 11, 'Hey! Listen!', 'Hey! Listen'])] #[TestWith([14, 11, 'My name is Joe', 'My name is '])] #[TestWith([7, 5, '$foo = ', '$foo '])] + #[TestWith([10, 7, 'ΠΏΡ€ΠΈΠ²Π΅Ρ‚ ΠΌΠΈΡ€', 'ΠΏΡ€ΠΈΠ²Π΅Ρ‚ '])] + #[TestWith([3, 0, 'ζ—₯本θͺž', ''])] + #[TestWith([2, 1, 'aπŸ‘πŸ½', 'a'])] #[Test] public function delete_previous_word(int $initialCursor, int $expectedCursor, string $initialText, string $expectedText): void { @@ -87,6 +110,8 @@ public function delete_previous_word(int $initialCursor, int $expectedCursor, st #[TestWith([3, 3, 'foo-bar-baz', 'foobar-baz'])] #[TestWith([4, 4, 'foo-bar-baz', 'foo--baz'])] #[TestWith([3, 3, 'foo--baz', 'foobaz'])] + #[TestWith([0, 0, 'ζ—₯本θͺž', ''])] + #[TestWith([0, 0, 'πŸ‘πŸ½a', 'a'])] #[Test] public function delete_next_word(int $initialCursor, int $expectedCursor, string $initialText, string $expectedText): void { @@ -105,6 +130,9 @@ public function delete_next_word(int $initialCursor, int $expectedCursor, string #[TestWith([3, 3, 'abc', 'abc'])] #[TestWith([0, 0, '', ''])] #[TestWith([0, 0, '-', ''])] + #[TestWith([0, 0, "e\u{0301}x", 'x'])] + #[TestWith([0, 0, 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦x', 'x'])] + #[TestWith([0, 0, 'πŸ‘πŸ½x', 'x'])] #[Test] public function delete_next_character(int $initialCursor, int $expectedCursor, string $initialText, string $expectedText): void { @@ -123,6 +151,9 @@ public function delete_next_character(int $initialCursor, int $expectedCursor, s #[TestWith([3, 2, 'abc', 'ab'])] #[TestWith([0, 0, '', ''])] #[TestWith([1, 0, '-', ''])] + #[TestWith([1, 0, "e\u{0301}x", 'x'])] + #[TestWith([1, 0, 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦x', 'x'])] + #[TestWith([1, 0, 'πŸ‘πŸ½x', 'x'])] #[Test] public function delete_previous_character(int $initialCursor, int $expectedCursor, string $initialText, string $expectedText): void { @@ -275,6 +306,18 @@ public function move_cursor_y(int $initialCursor, int $offsetY, int $expectedPos $this->assertSame($expectedPosition, $buffer->cursor); } + #[TestWith(['δ½ ab', 3, ['δ½ a', 'b']])] + #[TestWith(["e\u{0301}abc", 3, ["e\u{0301}ab", 'c']])] + #[TestWith(['πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ab', 3, ['πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦a', 'b']])] + #[TestWith(["a\n\nδ½ b", 2, ['a', '', 'δ½ ', 'b']])] + #[Test] + public function wrapped_lines(string $text, int $maximumWidth, array $expected): void + { + $buffer = new TextBuffer($text); + + $this->assertSame($expected, $buffer->getWrappedLines($maximumWidth)); + } + #[TestWith(['123', 0, [0, 0]])] #[TestWith(["123\n456", 4, [0, 1]])] #[TestWith(["123\n456", 5, [1, 1]])] @@ -283,6 +326,15 @@ public function move_cursor_y(int $initialCursor, int $offsetY, int $expectedPos #[TestWith(["different\nline\nlength", 10, [0, 1]])] #[TestWith(["different\nline\nlength", 11, [1, 1]])] #[TestWith(["different\nline\nlength", 21, [6, 2]])] + #[TestWith(['δ½ a', 1, [2, 0]])] + #[TestWith(["e\u{0301}x", 1, [1, 0]])] + #[TestWith(['πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦x', 1, [2, 0]])] + #[TestWith(['Β©x', 1, [1, 0]])] + #[TestWith(['❀x', 1, [1, 0]])] + #[TestWith(['©️x', 1, [2, 0]])] + #[TestWith(['❀️x', 1, [2, 0]])] + #[TestWith(['πŸ‡ΊπŸ‡Έx', 1, [2, 0]])] + #[TestWith(['1️⃣x', 1, [2, 0]])] #[Test] public function relative_cursor_index(string $initialText, int $cursor, array $expectedPoint): void { @@ -300,6 +352,9 @@ public function relative_cursor_index(string $initialText, int $cursor, array $e #[TestWith(["different\nline\nlength", 5, 9, [4, 1]])] #[TestWith(["different\nline\nlength", 5, 13, [3, 2]])] #[TestWith(["different\nline\nlength", 5, 21, [1, 4]])] + #[TestWith(['δ½ ab', 3, 3, [1, 1]])] + #[TestWith(["e\u{0301}abc", 3, 4, [1, 1]])] + #[TestWith(['πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ab', 3, 3, [1, 1]])] #[Test] public function relative_cursor_index_with_wrapping(string $initialText, int $maxLineWidth, int $cursor, array $expectedPoint): void {