From 1a55c4b747777d2068fde1f00968808b2fc0f346 Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Thu, 7 Nov 2024 22:12:55 -0600 Subject: [PATCH 1/3] Marquee long processes. Add ANSI aware helpers --- src/Console/Commands/About.php | 2 +- src/Helpers/AnsiAware.php | 124 +++++++++++++++++++++++++++++ src/Prompt/Dashboard.php | 6 ++ src/Prompt/Renderer.php | 38 ++++++++- src/Stubs/SoloServiceProvider.stub | 8 +- src/Support/Frames.php | 30 +++++++ tests/Unit/AnsiAwareTest.php | 79 ++++++++++++++++++ 7 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 src/Support/Frames.php diff --git a/src/Console/Commands/About.php b/src/Console/Commands/About.php index 74592a9..2ce5b59 100644 --- a/src/Console/Commands/About.php +++ b/src/Console/Commands/About.php @@ -48,7 +48,7 @@ public function handle() • Twitter: https://twitter.com/aarondfrancis • Website: https://aaronfrancis.com • YouTube: https://youtube.com/@aarondfrancis -• GitHub: https://github.com/aarondfrancis/solo +• GitHub: https://github.com/aarondfrancis/solo EOT; diff --git a/src/Helpers/AnsiAware.php b/src/Helpers/AnsiAware.php index ec557ee..8b44a51 100644 --- a/src/Helpers/AnsiAware.php +++ b/src/Helpers/AnsiAware.php @@ -9,6 +9,130 @@ class AnsiAware { + public static function mb_strlen($string): int + { + // Regular expression to match ANSI escape sequences + $ansiEscapeSequence = '/\x1b\[[0-9;]*[A-Za-z]/'; + + // Remove ANSI escape sequences + $plainString = preg_replace($ansiEscapeSequence, '', $string); + + // Return length of the plain string + return mb_strlen($plainString); + } + + public static function substr($string, $start, $length = null): string + { + $ansiEscapeSequence = '/(\x1b\[[0-9;]*[mGK])/'; + $parts = preg_split($ansiEscapeSequence, $string, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + $currentPos = 0; // Position in printable characters. + $substringParts = []; // Parts of the substring. + $openAnsiCodes = []; // Array of open ANSI codes. + $collecting = false; // Whether we are collecting substring parts. + $startPos = $start; // Starting position. + + // If $length is null, we need to go till the end of the string. + $endPos = is_null($length) ? PHP_INT_MAX : $start + $length; + + foreach ($parts as $part) { + if (preg_match($ansiEscapeSequence, $part)) { + // It's an ANSI code. + // Update $openAnsiCodes accordingly. + if (str_contains($part, 'm')) { + // It's an SGR code. + $sgrParams = substr($part, 2, -1); // Remove "\e[" and "m" + $sgrCodes = explode(';', $sgrParams); + + foreach ($sgrCodes as $code) { + $code = intval($code); + if ($code == 0) { + // Reset all attributes. + $openAnsiCodes = []; + } else { + if (($code >= 30 && $code <= 37) || ($code >= 90 && $code <= 97)) { + // Set foreground color. + // Remove any existing foreground color codes. + $openAnsiCodes = array_filter($openAnsiCodes, function ($c) { + return !(($c >= 30 && $c <= 37) || ($c >= 90 && $c <= 97)); + }); + } else { + if (($code >= 40 && $code <= 47) || ($code >= 100 && $code <= 107)) { + // Set background color. + // Remove any existing background color codes. + $openAnsiCodes = array_filter($openAnsiCodes, function ($c) { + return !(($c >= 40 && $c <= 47) || ($c >= 100 && $c <= 107)); + }); + } + } + $openAnsiCodes[] = $code; + } + } + } + // If we are collecting, we need to include this ANSI code. + if ($collecting) { + $substringParts[] = $part; + } + + continue; + } + + // It's a printable text part. + $partLength = mb_strlen($part); + + if ($currentPos + $partLength <= $startPos) { + // This part is entirely before the start position. + $currentPos += $partLength; + + continue; + } + + if ($currentPos >= $endPos) { + // We have already reached or passed the end position. + break; + } + + // Now, part of this $part is within the desired range. + $partStart = 0; + $partEnd = $partLength; + + if ($currentPos < $startPos) { + // The desired start position is within this part. + $partStart = $startPos - $currentPos; + } + + if ($currentPos + $partLength > $endPos) { + // The desired end position is within this part. + $partEnd = $endPos - $currentPos; + } + + // Extract the substring from this part. + $substring = mb_substr($part, $partStart, $partEnd - $partStart); + + // If we are just starting to collect, prepend open ANSI codes. + if (!$collecting) { + $collecting = true; + if (!empty($openAnsiCodes)) { + // Build the ANSI code string. + $ansiCodeString = "\e[" . implode(';', $openAnsiCodes) . 'm'; + $substringParts[] = $ansiCodeString; + } + } + + $substringParts[] = $substring; + + $currentPos += $partLength; + } + + // Close any open ANSI codes. + if ($collecting && !empty($openAnsiCodes)) { + // Append reset code. + $substringParts[] = "\e[0m"; + } + + return implode('', $substringParts); + } + public static function wordwrap($string, $width = 75, $break = PHP_EOL, $cut = false): string { $ansiEscapeSequence = '/(\x1b\[[0-9;]*[mGK])/'; diff --git a/src/Prompt/Dashboard.php b/src/Prompt/Dashboard.php index 05c0488..2961bb7 100644 --- a/src/Prompt/Dashboard.php +++ b/src/Prompt/Dashboard.php @@ -9,6 +9,7 @@ use AaronFrancis\Solo\Commands\Command; use AaronFrancis\Solo\Facades\Solo; +use AaronFrancis\Solo\Support\Frames; use Chewie\Concerns\CreatesAnAltScreen; use Chewie\Concerns\Loops; use Chewie\Concerns\RegistersRenderers; @@ -34,6 +35,8 @@ class Dashboard extends Prompt public int $height; + public Frames $frames; + public static function start(): void { (new static)->run(); @@ -48,6 +51,8 @@ public function __construct() pcntl_signal(SIGWINCH, [$this, 'handleResize']); + $this->frames = new Frames; + $this->commands = collect(Solo::commands())->each(function (Command $command) { $command->setDimensions($this->width, $this->height); $command->autostart(); @@ -143,6 +148,7 @@ protected function showDashboard(): void $this->render(); $listener->once(); + $this->frames->next(); }, 25_000); // @TODO reconsider using? diff --git a/src/Prompt/Renderer.php b/src/Prompt/Renderer.php index da6810d..b92f3d5 100644 --- a/src/Prompt/Renderer.php +++ b/src/Prompt/Renderer.php @@ -10,6 +10,7 @@ use AaronFrancis\Solo\Commands\Command; use AaronFrancis\Solo\Contracts\Theme; use AaronFrancis\Solo\Facades\Solo; +use AaronFrancis\Solo\Helpers\AnsiAware; use Chewie\Concerns\Aligns; use Chewie\Concerns\DrawsHotkeys; use Chewie\Output\Util; @@ -139,9 +140,42 @@ protected function renderProcessState(): void ? $this->theme->processRunning(' Running: ') : $this->theme->processStopped(' Stopped: '); - $state .= $this->theme->dim($this->currentCommand->command); + $command = $this->marquee( + $this->dim($this->currentCommand->command), + $this->width - AnsiAware::mb_strlen($state), + $this->dashboard->frames->current(buffer: 6) + ); + + $this->line($state . $command); + } + + protected function marquee(string $string, int $width, int $frame) + { + $length = AnsiAware::mb_strlen($string); + + if ($length <= $width) { + return $string; + } + + // Maximum starting position + $maxPos = $length - $width; + + // Define the sequence of positions for the marquee effect + $starts = array_merge( + [0, 0], // Pause at start for one frame + range(1, $maxPos), // Move forward + [$maxPos, $maxPos], // Pause at end for one frame + range($maxPos - 1, 0, -1), // Move backward + [0] // Pause at start again before repeating + ); + + $totalFrames = count($starts); + + // Calculate the current index in the positions array + $index = $frame % $totalFrames; + $start = $starts[$index]; - $this->line($state); + return AnsiAware::substr($string, $start, $width); } protected function renderContentPane(): void diff --git a/src/Stubs/SoloServiceProvider.stub b/src/Stubs/SoloServiceProvider.stub index 13347be..799c234 100644 --- a/src/Stubs/SoloServiceProvider.stub +++ b/src/Stubs/SoloServiceProvider.stub @@ -11,10 +11,6 @@ class SoloServiceProvider extends SoloApplicationServiceProvider public function register() { Solo::useTheme('dark') - // FQCNs of trusted classes that can add commands. - ->allowCommandsAddedFrom([ - // - ]) // Commands that auto start. ->addCommands([ EnhancedTailCommand::make('Logs', 'tail -f -n 100 ' . storage_path('logs/laravel.log')), @@ -27,6 +23,10 @@ class SoloServiceProvider extends SoloApplicationServiceProvider 'Queue' => 'php artisan queue:listen --tries=1', // 'Reverb' => 'php artisan reverb:start', // 'Pint' => 'pint --ansi', + ]) + // FQCNs of trusted classes that can add commands. + ->allowCommandsAddedFrom([ + // ]); } diff --git a/src/Support/Frames.php b/src/Support/Frames.php new file mode 100644 index 0000000..9ee3d23 --- /dev/null +++ b/src/Support/Frames.php @@ -0,0 +1,30 @@ + + * @link https://aaronfrancis.com + * @link https://twitter.com/aarondfrancis + */ + +declare(strict_types=1); + +namespace AaronFrancis\Solo\Support; + +class Frames +{ + protected int $current = 0; + + public function next() + { + $this->current++; + } + + public function current($buffer = 1) + { + return floor($this->current / $buffer); + } + + public function frame(array $frames, $buffer = 1) + { + return $frames[$this->current($buffer) % count($frames)]; + } +} diff --git a/tests/Unit/AnsiAwareTest.php b/tests/Unit/AnsiAwareTest.php index c4c8528..e5a48e9 100644 --- a/tests/Unit/AnsiAwareTest.php +++ b/tests/Unit/AnsiAwareTest.php @@ -15,6 +15,85 @@ class AnsiAwareTest extends Base { use Colors; + #[Test] + public function ansi_mb_strlen(): void + { + $string = 'Hello ' . $this->green('World!'); + $this->assertEquals(12, AnsiAware::mb_strlen($string)); + + $string = 'Hello ' . $this->bgRed($this->green('World!')); + $this->assertEquals(12, AnsiAware::mb_strlen($string)); + + $string = 'Hello ' . $this->bold($this->bgRed($this->green('World!'))); + $this->assertEquals(12, AnsiAware::mb_strlen($string)); + + $string = 'Hello ' . $this->bold($this->bgRed($this->green('█orld!'))); + $this->assertEquals(12, AnsiAware::mb_strlen($string)); + } + + #[Test] + public function ansi_substr_foreground(): void + { + $string = 'Hello ' . $this->green('World!'); + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals("lo \e[32mWo\e[39m\e[0m", $substring); + } + + #[Test] + public function ansi_substr_background(): void + { + $string = 'Hello ' . $this->bgGreen('World!'); + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals("lo \e[42mWo\e[49m\e[0m", $substring); + } + + #[Test] + public function ansi_substr_fore_and_background(): void + { + $string = 'Hello ' . $this->bgGreen($this->white('World!')); + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals("lo \e[42m\e[37mWo\e[39m\e[49m\e[0m", $substring); + } + + #[Test] + public function ansi_substr_bold(): void + { + $string = 'Hello ' . $this->bold('World!'); + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals("lo \e[1mWo\e[22m\e[0m", $substring); + } + + #[Test] + public function ansi_substr_overlap(): void + { + $string = 'Hell' . $this->bgGreen('o ' . $this->blue('World!')); + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals("l\e[42mo \e[34mWo\e[39m\e[49m\e[0m", $substring); + } + + #[Test] + public function ansi_substr_overlap_multibyte(): void + { + $string = 'Hell' . $this->bgGreen('█ ' . $this->blue('World!')); + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals("l\e[42m█ \e[34mWo\e[39m\e[49m\e[0m", $substring); + } + + #[Test] + public function ansi_substr_no_style(): void + { + $string = 'Hello World'; + $substring = AnsiAware::substr($string, 3, 5); + + $this->assertEquals('lo Wo', $substring); + } + #[Test] public function it_wraps_a_basic_line(): void { From 3f79b1b4cc1d8d7836f06afb317d14ef7a73c34a Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Fri, 8 Nov 2024 08:36:45 -0600 Subject: [PATCH 2/3] Remove my header comment --- pint.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pint.json b/pint.json index ff4f380..ce172c2 100644 --- a/pint.json +++ b/pint.json @@ -11,12 +11,6 @@ "function_declaration": { "closure_fn_spacing": "none" }, - "trailing_comma_in_multiline": false, - "header_comment": { - "header": "@author Aaron Francis \n@link https://aaronfrancis.com\n@link https://twitter.com/aarondfrancis", - "comment_type": "PHPDoc", - "location": "after_open", - "separate": "bottom" - } + "trailing_comma_in_multiline": false } } \ No newline at end of file From 8951a14ce26be5d772b9876686a53ceeae391e25 Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Fri, 8 Nov 2024 08:44:20 -0600 Subject: [PATCH 3/3] fix pint --- pint.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pint.json b/pint.json index ce172c2..2644610 100644 --- a/pint.json +++ b/pint.json @@ -11,6 +11,9 @@ "function_declaration": { "closure_fn_spacing": "none" }, + "phpdoc_separation": { + "groups": [["author","link"],["param","return"]] + }, "trailing_comma_in_multiline": false } } \ No newline at end of file