From f8e10ab551380042c478432826011ac1d2ac6ca7 Mon Sep 17 00:00:00 2001 From: Joe Jollands Date: Thu, 15 Jan 2026 10:11:55 +0000 Subject: [PATCH] Fix interactive demo summary output --- README.md | 66 +++++ docs/feature-todo.md | 41 +++ docs/guides/QUICK-REFERENCE.md | 25 +- src/Concerns/HasInteractiveMenus.php | 46 ++++ .../Commands/TartInteractiveDemoCommand.php | 58 ++++ src/Laravel/StyledCommand.php | 2 + src/Laravel/TartServiceProvider.php | 1 + src/Support/InteractiveMenu.php | 256 ++++++++++++++++++ src/Support/KeyPress.php | 71 +++++ src/Support/TerminalControl.php | 81 ++++++ src/Support/TerminalInput.php | 49 ++++ src/Support/TerminalMode.php | 40 +++ src/Symfony/StyledCommand.php | 2 + tests/Unit/KeyPressTest.php | 39 +++ tests/Unit/TerminalControlTest.php | 39 +++ 15 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 docs/feature-todo.md create mode 100644 src/Concerns/HasInteractiveMenus.php create mode 100644 src/Laravel/Commands/TartInteractiveDemoCommand.php create mode 100644 src/Support/InteractiveMenu.php create mode 100644 src/Support/KeyPress.php create mode 100644 src/Support/TerminalControl.php create mode 100644 src/Support/TerminalInput.php create mode 100644 src/Support/TerminalMode.php create mode 100644 tests/Unit/KeyPressTest.php create mode 100644 tests/Unit/TerminalControlTest.php diff --git a/README.md b/README.md index c070414..8edfe89 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,44 @@ new DeployCommand('app:deploy', [ ]); ``` +## Theme Configuration + +TART ships with a default theme, but you can customize colors, highlight behavior, and layout width. The `max_line_width` setting is especially useful for matching the width of your terminal and for how blocks, progress bars, and banners wrap long text. + +### Theme Options + +- `class` - The theme class to use (defaults to `IGC\Tart\Themes\Theme`) +- `color` - Primary color used by blocks and accents (default: `blue`) +- `text_color` - Default text color inside themed blocks (default: `white`) +- `highlight_color` - Accent/highlight color (default: `yellow`) +- `max_line_width` - Maximum line width for formatting (default: `72`) +- `colors` - Palette used for random or multi-color effects (default: `['red', 'green', 'yellow', 'cyan', 'white']`) + +### Example: Custom Theme via Config + +```php +new DeployCommand('app:deploy', [ + 'theme' => [ + 'class' => \IGC\Tart\Themes\Theme::class, + 'color' => 'magenta', + 'text_color' => 'white', + 'highlight_color' => 'yellow', + 'max_line_width' => 100, + 'colors' => ['magenta', 'cyan', 'white'], + ], +]); +``` + +### Example: Fluent Theme Updates + +```php +$theme = \IGC\Tart\Themes\Theme::make('green') + ->withTextColor('white') + ->withHighlightColor('yellow') + ->withMaxWidth(90) + ->withColors(['green', 'cyan', 'white']); +``` + ### Laravel Auto-Discovery & Demo Commands Laravel 9+ automatically discovers the `IGC\Tart\Laravel\TartServiceProvider`, which registers several Artisan demo commands. After installing the package you can immediately preview different aspects of the styling toolkit: @@ -196,6 +234,9 @@ php artisan tart:demo-full --theme=error # Fluent APIs showcase php artisan tart:fluent-demo + +# Interactive menu demo (menus, checkbox, radio) +php artisan tart:interactive-demo ``` Need manual control? Add TART to Composer's `dont-discover` list and register the provider in `config/app.php`: @@ -255,6 +296,31 @@ $this->stat('Completed in 2.5s'); // Stat block $this->footer('Process', 'Time: 2.5s'); // Footer ``` +## Recommended API Usage + +TART supports both fluent APIs and the original, traditional methods. The fluent API is the preferred approach going forward because it is more expressive and easier to read. The traditional methods remain supported for backward compatibility. + +**Preferred (fluent)** + +```php +$this->logo() + ->text('MY APP') + ->boxed() + ->color('cyan') + ->render(); + +$this->success('Deploy complete!'); +``` + +**Legacy (still supported)** + +```php +$this->displayTextLogo('MY APP', 'box', ['text_color' => 'cyan']); +$this->success('Deploy complete!'); +``` + +If you're migrating existing code, you can adopt fluent methods incrementally while keeping the original APIs for older commands. + ### Logo Creation 🎨 ```php diff --git a/docs/feature-todo.md b/docs/feature-todo.md new file mode 100644 index 0000000..9ead8df --- /dev/null +++ b/docs/feature-todo.md @@ -0,0 +1,41 @@ +# TUI Feature Research TODOs + +## Interactive Menus & Navigable Lists +- [x] Prototype a raw-mode input loop that detects arrow key escape sequences (ESC [ A/B/C/D) and Enter to select the highlighted item. +- [x] Implement a menu renderer that highlights the focused item (e.g., inverse colors or a leading `>` marker) and supports redrawing on selection changes. +- [x] Add cursor hide/show and safe terminal cleanup to avoid leaving the cursor hidden after menu exit. +- [x] Evaluate redraw strategies: full-screen clear (`\033[H\033[2J`) vs. partial updates with cursor movement to reduce flicker. + +## Multiple-Choice Inputs (Checkbox/Radio) +- [x] Extend the menu loop to toggle checkboxes via Space/Enter and render markers like `[x]` / `[ ]`. +- [x] Implement radio-style selection (single choice) where selecting one item clears the previous choice. +- [x] Store selection state in an array of booleans (checkbox) or a single index (radio) and expose selected values. + +## Text Input Fields +- [ ] Decide between readline-based prompts (simple) vs. raw-mode character capture for embedded inputs. +- [ ] Prototype raw-mode text input with Backspace handling (support ASCII 127 and 8) and optional masking for passwords. +- [ ] Ensure terminal settings are restored after input (save `stty -g`, restore on exit). + +## Keyboard Input Handling +- [x] Build a small key parser for escape sequences (arrows, escape, enter) with optional timeout logic for lone ESC. +- [x] Add `stream_select()` support to allow non-blocking reads for animations/spinners during input loops. +- [ ] Document key mappings and supported terminals (ANSI/VT100 assumptions). + +## Cursor & Screen Control +- [x] Create helpers for cursor movement (`ESC[;H`, `ESC[A/B/C/D`), line clearing (`ESC[K`), and full-screen clear. +- [x] Add cursor hide/show helpers (`ESC[?25l` / `ESC[?25h`) and use them during redraws. +- [ ] Explore buffer-based redraws (compose full screen in a string, output once) to minimize flicker. + +## Text Formatting Beyond Colors +- [x] Add support for bold/underline/reverse video SGR codes and a reset helper (`ESC[0m`). +- [ ] Consider 256-color or truecolor extensions for richer themes where terminals support it. + +## Library Ecosystem Evaluation +- [ ] Review php-school/cli-menu for menu and checkbox patterns that can inform API design. +- [ ] Evaluate php-tui/php-tui terminal/event handling patterns for raw mode and event parsing. +- [ ] Decide whether to depend on external libraries or keep a minimal internal implementation. + +## Integration & UX +- [x] Define a public API for menus, checkbox lists, and text inputs that fits TART’s fluent style. +- [x] Add safeguards to prevent leaving the terminal in raw mode after exceptions (try/finally wrapper). +- [ ] Provide Laravel/Symfony usage examples once core components are stable. diff --git a/docs/guides/QUICK-REFERENCE.md b/docs/guides/QUICK-REFERENCE.md index 561eba4..9229127 100644 --- a/docs/guides/QUICK-REFERENCE.md +++ b/docs/guides/QUICK-REFERENCE.md @@ -17,6 +17,30 @@ $email = $this->prompt('Email?', null, function($value) { $password = $this->password('Enter password'); ``` +## 🧭 Interactive Menus (NEW!) + +```php +// Single-select menu +$choice = $this->menu('Pick a deployment target', [ + 'Staging', + 'Production', +]); + +// Checkbox menu (multi-select) +$selected = $this->checkboxMenu('Select features', [ + 'Spinners', + 'Progress bars', + 'Tables', +]); + +// Radio menu (single-select) +$theme = $this->radioMenu('Choose a theme', [ + 'Default', + 'Success', + 'Error', +]); +``` + ## πŸ“ Lists (NEW!) ```php @@ -304,4 +328,3 @@ public function handle() --- **Quick Start:** Extend `StyledCommand` and use any method above! - diff --git a/src/Concerns/HasInteractiveMenus.php b/src/Concerns/HasInteractiveMenus.php new file mode 100644 index 0000000..881a346 --- /dev/null +++ b/src/Concerns/HasInteractiveMenus.php @@ -0,0 +1,46 @@ + $items + */ + public function menu(string $title, array $items, int $defaultIndex = 0): string + { + $menu = new InteractiveMenu($this->getOutput()); + + return $menu->select($title, $items, $defaultIndex); + } + + /** + * Present a multi-select checkbox menu and return the chosen values. + * + * @param array $items + * @param array $defaults + * @return array + */ + public function checkboxMenu(string $title, array $items, array $defaults = []): array + { + $menu = new InteractiveMenu($this->getOutput()); + + return $menu->checkbox($title, $items, $defaults); + } + + /** + * Present a single-select radio menu and return the chosen value. + * + * @param array $items + */ + public function radioMenu(string $title, array $items, int $defaultIndex = 0): string + { + $menu = new InteractiveMenu($this->getOutput()); + + return $menu->radio($title, $items, $defaultIndex); + } +} diff --git a/src/Laravel/Commands/TartInteractiveDemoCommand.php b/src/Laravel/Commands/TartInteractiveDemoCommand.php new file mode 100644 index 0000000..eb6bc31 --- /dev/null +++ b/src/Laravel/Commands/TartInteractiveDemoCommand.php @@ -0,0 +1,58 @@ +bad('Interactive demo requires a TTY terminal.'); + + return self::FAILURE; + } + + $this->header('INTERACTIVE MENU DEMO'); + $this->say('Use ↑/↓ to move, Space to toggle, Enter to confirm.'); + $this->br(); + + $environment = $this->menu('Pick a deployment environment', [ + 'Staging', + 'Production', + 'Development', + ]); + + $features = $this->checkboxMenu('Select TART features to enable', [ + 'Spinners', + 'Progress bars', + 'Tables', + 'Blocks', + 'Lists', + ], ['Spinners', 'Tables']); + + $theme = $this->radioMenu('Choose a theme preset', [ + 'Default', + 'Success', + 'Error', + ]); + + $this->title('Selection Summary'); + $this->good("Environment selected: {$environment}"); + if ($features === []) { + $this->notice('No features selected.'); + } else { + $this->good('Selected features: ' . implode(', ', $features)); + } + $this->success("Theme selected: {$theme}"); + $this->br(); + $this->success('Interactive demo complete!'); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/StyledCommand.php b/src/Laravel/StyledCommand.php index fd0ab94..a6d3fb6 100644 --- a/src/Laravel/StyledCommand.php +++ b/src/Laravel/StyledCommand.php @@ -7,6 +7,7 @@ use IGC\Tart\Concerns\HasColoredOutput; use IGC\Tart\Concerns\HasEnhancedInput; use IGC\Tart\Concerns\HasInteractivity; +use IGC\Tart\Concerns\HasInteractiveMenus; use IGC\Tart\Concerns\HasLineBuilding; use IGC\Tart\Concerns\HasLists; use IGC\Tart\Concerns\HasProgressBars; @@ -27,6 +28,7 @@ abstract class StyledCommand extends Command implements StyledCommandInterface use HasBlocks; use HasLineBuilding; use HasInteractivity; + use HasInteractiveMenus; use HasEnhancedInput; use HasLists; use HasTables; diff --git a/src/Laravel/TartServiceProvider.php b/src/Laravel/TartServiceProvider.php index 6da3d90..e9253d3 100644 --- a/src/Laravel/TartServiceProvider.php +++ b/src/Laravel/TartServiceProvider.php @@ -35,6 +35,7 @@ public function boot(): void Commands\TartFluentDemoCommand::class, Commands\TartTestCommand::class, Commands\TartNewFeaturesDemo::class, + Commands\TartInteractiveDemoCommand::class, ]); } diff --git a/src/Support/InteractiveMenu.php b/src/Support/InteractiveMenu.php new file mode 100644 index 0000000..781158e --- /dev/null +++ b/src/Support/InteractiveMenu.php @@ -0,0 +1,256 @@ +output = $output; + $this->input = $input ?? new TerminalInput(); + } + + public function setHighlightColor(string $color, string $textColor = 'black'): self + { + $this->highlightColor = $color; + $this->highlightTextColor = $textColor; + + return $this; + } + + /** + * @param array $items + */ + public function select(string $title, array $items, int $defaultIndex = 0): string + { + return $this->runSelection($title, $items, $defaultIndex, 'menu'); + } + + /** + * @param array $items + * @param array $defaults + * @return array + */ + public function checkbox(string $title, array $items, array $defaults = []): array + { + [$keys, $labels, $isAssoc] = $this->normalizeItems($items); + $selected = []; + + foreach ($defaults as $default) { + if ($isAssoc) { + $index = array_search($default, $keys, true); + } else { + $index = array_search($default, $labels, true); + } + + if ($index === false && is_int($default) && isset($labels[$default])) { + $index = $default; + } + + if ($index !== false) { + $selected[$index] = true; + } + } + + $result = $this->runCheckboxSelection($title, $items, $selected); + + return $result; + } + + /** + * @param array $items + */ + public function radio(string $title, array $items, int $defaultIndex = 0): string + { + return $this->runSelection($title, $items, $defaultIndex, 'radio'); + } + + /** + * @param array $items + */ + private function runSelection(string $title, array $items, int $defaultIndex, string $mode): string + { + [$keys, $labels, $isAssoc] = $this->normalizeItems($items); + $count = count($labels); + + if ($count === 0) { + throw new RuntimeException('Menu items cannot be empty.'); + } + + $index = max(0, min($defaultIndex, $count - 1)); + $terminal = new TerminalMode(); + + $terminal->enableRawMode(); + + try { + $this->output->write(TerminalControl::hideCursor()); + $this->render($title, $labels, $index, [], $mode); + + while (true) { + $key = $this->input->readKey(); + if ($key === null) { + continue; + } + + if ($key->type === KeyPress::UP) { + $index = max(0, $index - 1); + } elseif ($key->type === KeyPress::DOWN) { + $index = min($count - 1, $index + 1); + } elseif ($key->type === KeyPress::ENTER) { + $selectedKey = $keys[$index]; + + return $isAssoc ? (string) $selectedKey : (string) $labels[$index]; + } elseif ($key->type === KeyPress::ESCAPE) { + $selectedKey = $keys[$index]; + + return $isAssoc ? (string) $selectedKey : (string) $labels[$index]; + } elseif ($mode === 'radio' && $key->type === KeyPress::SPACE) { + $selectedKey = $keys[$index]; + + return $isAssoc ? (string) $selectedKey : (string) $labels[$index]; + } + + $this->render($title, $labels, $index, [], $mode); + } + } finally { + $this->output->write(TerminalControl::showCursor()); + $terminal->restore(); + } + } + + /** + * @param array $items + * @param array $selected + * @return array + */ + private function runCheckboxSelection(string $title, array $items, array $selected): array + { + [$keys, $labels, $isAssoc] = $this->normalizeItems($items); + $count = count($labels); + + if ($count === 0) { + throw new RuntimeException('Menu items cannot be empty.'); + } + + $index = 0; + $terminal = new TerminalMode(); + + $terminal->enableRawMode(); + + try { + $this->output->write(TerminalControl::hideCursor()); + $this->render($title, $labels, $index, $selected, 'checkbox'); + + while (true) { + $key = $this->input->readKey(); + if ($key === null) { + continue; + } + + if ($key->type === KeyPress::UP) { + $index = max(0, $index - 1); + } elseif ($key->type === KeyPress::DOWN) { + $index = min($count - 1, $index + 1); + } elseif ($key->type === KeyPress::SPACE) { + $selected[$index] = !($selected[$index] ?? false); + } elseif ($key->type === KeyPress::ENTER || $key->type === KeyPress::ESCAPE) { + return $this->selectedValues($keys, $labels, $selected, $isAssoc); + } + + $this->render($title, $labels, $index, $selected, 'checkbox'); + } + } finally { + $this->output->write(TerminalControl::showCursor()); + $terminal->restore(); + } + } + + /** + * @param array $keys + * @param array $labels + * @param array $selected + * @return array + */ + private function selectedValues(array $keys, array $labels, array $selected, bool $isAssoc): array + { + $values = []; + foreach ($selected as $index => $isSelected) { + if ($isSelected) { + $values[] = $isAssoc ? $keys[$index] : $labels[$index]; + } + } + + return $values; + } + + /** + * @param array $items + * @return array{0: array, 1: array, 2: bool} + */ + private function normalizeItems(array $items): array + { + $keys = array_keys($items); + $labels = array_values($items); + $isAssoc = $keys !== range(0, count($keys) - 1); + + return [$keys, $labels, $isAssoc]; + } + + /** + * @param array $labels + * @param array $selected + */ + private function render(string $title, array $labels, int $index, array $selected, string $mode): void + { + $this->output->write(TerminalControl::clearScreenAndHome()); + + if ($title !== '') { + $this->output->writeln($title); + $this->output->writeln(''); + } + + foreach ($labels as $current => $label) { + $marker = $this->markerFor($mode, $current, $selected, $current === $index); + $line = "{$marker} {$label}"; + + if ($current === $index) { + $line = sprintf( + '%s', + $this->highlightTextColor, + $this->highlightColor, + $line + ); + } + + $this->output->writeln($line); + } + } + + /** + * @param array $selected + */ + private function markerFor(string $mode, int $index, array $selected, bool $isActive): string + { + if ($mode === 'checkbox') { + return ($selected[$index] ?? false) ? '[x]' : '[ ]'; + } + + if ($mode === 'radio') { + return $isActive ? '(o)' : '( )'; + } + + if ($mode === 'menu') { + return $isActive ? '>' : ' '; + } + + return ' '; + } +} diff --git a/src/Support/KeyPress.php b/src/Support/KeyPress.php new file mode 100644 index 0000000..36bb92d --- /dev/null +++ b/src/Support/KeyPress.php @@ -0,0 +1,71 @@ +type = $type; + $this->char = $char; + } + + public static function fromBytes(string $bytes): self + { + if ($bytes === "\n" || $bytes === "\r") { + return new self(self::ENTER); + } + + if ($bytes === "\x7f" || $bytes === "\x08") { + return new self(self::BACKSPACE); + } + + if ($bytes === ' ') { + return new self(self::SPACE); + } + + if ($bytes === "\x1b[A") { + return new self(self::UP); + } + + if ($bytes === "\x1b[B") { + return new self(self::DOWN); + } + + if ($bytes === "\x1b[C") { + return new self(self::RIGHT); + } + + if ($bytes === "\x1b[D") { + return new self(self::LEFT); + } + + if ($bytes === "\x1b") { + return new self(self::ESCAPE); + } + + if ($bytes !== '') { + return new self(self::CHAR, $bytes); + } + + return new self(self::CHAR, ''); + } + + public function isChar(): bool + { + return $this->type === self::CHAR && $this->char !== null; + } +} diff --git a/src/Support/TerminalControl.php b/src/Support/TerminalControl.php new file mode 100644 index 0000000..ea0eae0 --- /dev/null +++ b/src/Support/TerminalControl.php @@ -0,0 +1,81 @@ + 0) { + $read = [STDIN]; + $write = null; + $except = null; + $ready = stream_select($read, $write, $except, 0, $timeoutMs * 1000); + + if ($ready === 0 || $ready === false) { + return null; + } + } + + $char = stream_get_contents(STDIN, 1); + if ($char === false || $char === '') { + return null; + } + + if ($char === "\x1b") { + $sequence = $char . $this->readAdditionalEscapeBytes(); + + return KeyPress::fromBytes($sequence); + } + + return KeyPress::fromBytes($char); + } + + private function readAdditionalEscapeBytes(): string + { + $read = [STDIN]; + $write = null; + $except = null; + $ready = stream_select($read, $write, $except, 0, 10000); + + if ($ready === 0 || $ready === false) { + return ''; + } + + $bytes = stream_get_contents(STDIN, 2); + + return $bytes === false ? '' : $bytes; + } +} diff --git a/src/Support/TerminalMode.php b/src/Support/TerminalMode.php new file mode 100644 index 0000000..c058df1 --- /dev/null +++ b/src/Support/TerminalMode.php @@ -0,0 +1,40 @@ +sttyMode = shell_exec('stty -g 2>/dev/null'); + if ($this->sttyMode === null || $this->sttyMode === '') { + throw new RuntimeException('Unable to read terminal settings via stty.'); + } + + $meta = stream_get_meta_data(STDIN); + $this->stdinBlocking = $meta['blocked'] ?? null; + stream_set_blocking(STDIN, true); + + shell_exec('stty -icanon -echo min 1 time 0'); + } + + public function restore(): void + { + if ($this->sttyMode !== null) { + shell_exec(sprintf('stty %s', trim($this->sttyMode))); + } + + if ($this->stdinBlocking !== null) { + stream_set_blocking(STDIN, $this->stdinBlocking); + } + } +} diff --git a/src/Symfony/StyledCommand.php b/src/Symfony/StyledCommand.php index 7b14bce..dcf87df 100644 --- a/src/Symfony/StyledCommand.php +++ b/src/Symfony/StyledCommand.php @@ -7,6 +7,7 @@ use IGC\Tart\Concerns\HasColoredOutput; use IGC\Tart\Concerns\HasEnhancedInput; use IGC\Tart\Concerns\HasInteractivity; +use IGC\Tart\Concerns\HasInteractiveMenus; use IGC\Tart\Concerns\HasLineBuilding; use IGC\Tart\Concerns\HasLists; use IGC\Tart\Concerns\HasProgressBars; @@ -28,6 +29,7 @@ abstract class StyledCommand extends Command implements StyledCommandInterface use HasBlocks; use HasLineBuilding; use HasInteractivity; + use HasInteractiveMenus; use HasEnhancedInput; use HasLists; use HasTables; diff --git a/tests/Unit/KeyPressTest.php b/tests/Unit/KeyPressTest.php new file mode 100644 index 0000000..1c6ed0f --- /dev/null +++ b/tests/Unit/KeyPressTest.php @@ -0,0 +1,39 @@ +assertSame(KeyPress::UP, KeyPress::fromBytes("\x1b[A")->type); + $this->assertSame(KeyPress::DOWN, KeyPress::fromBytes("\x1b[B")->type); + $this->assertSame(KeyPress::RIGHT, KeyPress::fromBytes("\x1b[C")->type); + $this->assertSame(KeyPress::LEFT, KeyPress::fromBytes("\x1b[D")->type); + } + + public function test_parses_enter_escape_and_space(): void + { + $this->assertSame(KeyPress::ENTER, KeyPress::fromBytes("\n")->type); + $this->assertSame(KeyPress::ESCAPE, KeyPress::fromBytes("\x1b")->type); + $this->assertSame(KeyPress::SPACE, KeyPress::fromBytes(' ')->type); + } + + public function test_parses_backspace_variants(): void + { + $this->assertSame(KeyPress::BACKSPACE, KeyPress::fromBytes("\x7f")->type); + $this->assertSame(KeyPress::BACKSPACE, KeyPress::fromBytes("\x08")->type); + } + + public function test_parses_character_input(): void + { + $result = KeyPress::fromBytes('a'); + + $this->assertSame(KeyPress::CHAR, $result->type); + $this->assertSame('a', $result->char); + $this->assertTrue($result->isChar()); + } +} diff --git a/tests/Unit/TerminalControlTest.php b/tests/Unit/TerminalControlTest.php new file mode 100644 index 0000000..a65e13f --- /dev/null +++ b/tests/Unit/TerminalControlTest.php @@ -0,0 +1,39 @@ +assertSame("\033[2J", TerminalControl::clearScreen()); + $this->assertSame("\033[H", TerminalControl::home()); + $this->assertSame("\033[H\033[2J", TerminalControl::clearScreenAndHome()); + } + + public function test_cursor_movement_sequences(): void + { + $this->assertSame("\033[5;10H", TerminalControl::moveCursor(5, 10)); + $this->assertSame("\033[2A", TerminalControl::moveUp(2)); + $this->assertSame("\033[3B", TerminalControl::moveDown(3)); + $this->assertSame("\033[4C", TerminalControl::moveRight(4)); + $this->assertSame("\033[5D", TerminalControl::moveLeft(5)); + } + + public function test_cursor_visibility_sequences(): void + { + $this->assertSame("\033[?25l", TerminalControl::hideCursor()); + $this->assertSame("\033[?25h", TerminalControl::showCursor()); + } + + public function test_text_styles(): void + { + $this->assertSame("\033[1m", TerminalControl::bold()); + $this->assertSame("\033[4m", TerminalControl::underline()); + $this->assertSame("\033[7m", TerminalControl::reverse()); + $this->assertSame("\033[0m", TerminalControl::reset()); + } +}