diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index fade522..e849385 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -11,9 +11,18 @@ jobs: runs-on: ubuntu-latest + strategy: + matrix: + php-version: [7.2, 7.3, 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5] + steps: - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + - name: Validate composer.json and composer.lock run: composer validate @@ -30,8 +39,20 @@ jobs: if: steps.composer-cache.outputs.cache-hit != 'true' run: composer install --prefer-dist --no-progress --no-suggest + - name: Install compatible PHPUnit for PHP version + run: | + VER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') + echo "Detected PHP version: $VER" + if [[ "$VER" == 7.* ]]; then + composer require --dev phpunit/phpunit:"^8.5.52" --no-interaction --no-progress + elif [[ "$VER" == "8.0" ]]; then + composer require --dev phpunit/phpunit:"^9.5" --no-interaction --no-progress + else + composer require --dev phpunit/phpunit:"^10.0" --no-interaction --no-progress + fi + # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" # Docs: https://getcomposer.org/doc/articles/scripts.md - # - name: Run test suite - # run: composer run-script test + - name: Run test suite + run: composer run-script test diff --git a/.gitignore b/.gitignore index 3a9875b..b04fc73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,26 @@ /vendor/ composer.lock + +# IDE/editor +.idea/ +.vscode/ + +# OS files +.DS_Store +Thumbs.db + +# PHPUnit cache and build artifacts +/.phpunit.result.cache +/coverage/ +/build/ + +# Logs and temp +logs/ +*.log + +# Editor swap files +*.swp +*~ + +# Example/demo outputs +example-output.txt diff --git a/.travis.yml b/.travis.yml index 8194d5d..9d824f1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,14 @@ language: php php: - - 5.6 - - 7.0 - - 7.1 - 7.2 + - 7.3 + - 7.4 + - 8.0 + - 8.1 + - 8.2 + - 8.3 + - 8.4 + - 8.5 sudo: false diff --git a/README.md b/README.md index acd2b18..d951f17 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # phpColors +![phpColors demo](assets/example.png) + [![Codacy Badge](https://api.codacy.com/project/badge/Grade/3a77e6f0248e41fda03fe6e68dcb7e86)](https://www.codacy.com/app/klevze/phpColors?utm_source=github.com&utm_medium=referral&utm_content=klevze/phpColors&utm_campaign=Badge_Grade) Adds colors to your console applications @@ -7,6 +9,11 @@ Adds colors to your console applications [View on Packagist](https://packagist.org/packages/klevze/phpcolors) +## Requirements + +- PHP 7.2 or newer (tested on 7.2–8.5) + + ## Installation `composer require klevze/phpcolors` diff --git a/Tests/phpColorsTest.php b/Tests/phpColorsTest.php index d1509c9..9d04cc3 100644 --- a/Tests/phpColorsTest.php +++ b/Tests/phpColorsTest.php @@ -5,16 +5,17 @@ class phpColorsTest extends TestCase { private $colors; - protected function setUp() + protected function setUp(): void { $this->colors = new klevze\phpColors\Colors(); } - protected function tearDown() + protected function tearDown(): void { $this->colors = null; } + public function testAdd() { $this->assertEquals("\033[41mTEST TEXT\033[0m", $this->colors->bgRed() . "TEST TEXT" . $this->colors->reset()); diff --git a/assets/example.png b/assets/example.png new file mode 100644 index 0000000..b50ca00 Binary files /dev/null and b/assets/example.png differ diff --git a/assets/image.png b/assets/image.png new file mode 100644 index 0000000..757234b Binary files /dev/null and b/assets/image.png differ diff --git a/bin/php-parse b/bin/php-parse new file mode 100644 index 0000000..a208b76 --- /dev/null +++ b/bin/php-parse @@ -0,0 +1,119 @@ +#!/usr/bin/env php +realpath = realpath($opened_path) ?: $opened_path; + $opened_path = $this->realpath; + $this->handle = fopen($this->realpath, $mode); + $this->position = 0; + + return (bool) $this->handle; + } + + public function stream_read($count) + { + $data = fread($this->handle, $count); + + if ($this->position === 0) { + $data = preg_replace('{^#!.*\r?\n}', '', $data); + } + + $this->position += strlen($data); + + return $data; + } + + public function stream_cast($castAs) + { + return $this->handle; + } + + public function stream_close() + { + fclose($this->handle); + } + + public function stream_lock($operation) + { + return $operation ? flock($this->handle, $operation) : true; + } + + public function stream_seek($offset, $whence) + { + if (0 === fseek($this->handle, $offset, $whence)) { + $this->position = ftell($this->handle); + return true; + } + + return false; + } + + public function stream_tell() + { + return $this->position; + } + + public function stream_eof() + { + return feof($this->handle); + } + + public function stream_stat() + { + return array(); + } + + public function stream_set_option($option, $arg1, $arg2) + { + return true; + } + + public function url_stat($path, $flags) + { + $path = substr($path, 17); + if (file_exists($path)) { + return stat($path); + } + + return false; + } + } + } + + if ( + (function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true)) + || (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper')) + ) { + return include("phpvfscomposer://" . __DIR__ . '/..'.'/vendor/nikic/php-parser/bin/php-parse'); + } +} + +return include __DIR__ . '/..'.'/vendor/nikic/php-parser/bin/php-parse'; diff --git a/bin/php-parse.bat b/bin/php-parse.bat new file mode 100644 index 0000000..2c5096d --- /dev/null +++ b/bin/php-parse.bat @@ -0,0 +1,5 @@ +@ECHO OFF +setlocal DISABLEDELAYEDEXPANSION +SET BIN_TARGET=%~dp0/php-parse +SET COMPOSER_RUNTIME_BIN_DIR=%~dp0 +php "%BIN_TARGET%" %* diff --git a/bin/phpunit b/bin/phpunit new file mode 100644 index 0000000..a6b823e --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,122 @@ +#!/usr/bin/env php +realpath = realpath($opened_path) ?: $opened_path; + $opened_path = 'phpvfscomposer://'.$this->realpath; + $this->handle = fopen($this->realpath, $mode); + $this->position = 0; + + return (bool) $this->handle; + } + + public function stream_read($count) + { + $data = fread($this->handle, $count); + + if ($this->position === 0) { + $data = preg_replace('{^#!.*\r?\n}', '', $data); + } + $data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data); + $data = str_replace('__FILE__', var_export($this->realpath, true), $data); + + $this->position += strlen($data); + + return $data; + } + + public function stream_cast($castAs) + { + return $this->handle; + } + + public function stream_close() + { + fclose($this->handle); + } + + public function stream_lock($operation) + { + return $operation ? flock($this->handle, $operation) : true; + } + + public function stream_seek($offset, $whence) + { + if (0 === fseek($this->handle, $offset, $whence)) { + $this->position = ftell($this->handle); + return true; + } + + return false; + } + + public function stream_tell() + { + return $this->position; + } + + public function stream_eof() + { + return feof($this->handle); + } + + public function stream_stat() + { + return array(); + } + + public function stream_set_option($option, $arg1, $arg2) + { + return true; + } + + public function url_stat($path, $flags) + { + $path = substr($path, 17); + if (file_exists($path)) { + return stat($path); + } + + return false; + } + } + } + + if ( + (function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true)) + || (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper')) + ) { + return include("phpvfscomposer://" . __DIR__ . '/..'.'/vendor/phpunit/phpunit/phpunit'); + } +} + +return include __DIR__ . '/..'.'/vendor/phpunit/phpunit/phpunit'; diff --git a/bin/phpunit.bat b/bin/phpunit.bat new file mode 100644 index 0000000..2a070cd --- /dev/null +++ b/bin/phpunit.bat @@ -0,0 +1,5 @@ +@ECHO OFF +setlocal DISABLEDELAYEDEXPANSION +SET BIN_TARGET=%~dp0/phpunit +SET COMPOSER_RUNTIME_BIN_DIR=%~dp0 +php "%BIN_TARGET%" %* diff --git a/composer.json b/composer.json index b7bdc6d..e322824 100644 --- a/composer.json +++ b/composer.json @@ -14,11 +14,11 @@ } ], "require": { - "php": ">=5.6" + "php": ">=7.2" }, "require-dev": { - "phpunit/phpunit": "^7" + "phpunit/phpunit": "^8.5.52 || ^9.5 || ^10.0" }, "config": { @@ -32,6 +32,6 @@ }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "php vendor/phpunit/phpunit/phpunit" } } diff --git a/example.php b/example.php index 36f7850..f98e5e7 100644 --- a/example.php +++ b/example.php @@ -1,94 +1,85 @@ -bgRed() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgGreen() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgYellow() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; -$line = $c->bgBlue() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgCyan() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgMagenta() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; -$line = $c->bgLightGray() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgDarkGray() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgLightRed() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; -$line = $c->bgLightGreen() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgLightYellow() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgLightBlue() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; -$line = $c->bgMagenta() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgCyan() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->bgWhite() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; -echo PHP_EOL; +// CLI options: --glyph, --steps, --bg, --space +$opts = getopt('', ['glyph::', 'steps::', 'bg::', 'space::']); +$glyph = $opts['glyph'] ?? '*'; +$steps = isset($opts['steps']) ? (int) $opts['steps'] : 16; +$useBg = isset($opts['bg']) && ($opts['bg'] === '1' || $opts['bg'] === 'true'); +$space = !isset($opts['space']) || ($opts['space'] !== '0' && $opts['space'] !== 'false'); + +// Helper to call methods dynamically and print labeled output +function printBlock(Colors $c, string $method, string $label = "TEST") { + if (!method_exists($c, $method)) { + echo "Method $method does not exist\n"; + return; + } + + echo $c->{$method}() . $label . $c->reset() . "\t"; +} + +// Backgrounds (programmatic) +$bgNames = [ + 'Black','Red','Green','Yellow','Blue','Magenta','Cyan', + 'LightGray','DarkGray','LightRed','LightGreen','LightYellow', + 'LightBlue','LightMagenta','LightCyan','White' +]; + +foreach ($bgNames as $i => $name) { + printBlock($c, 'bg' . $name, 'BG'); + // break line every 3 + if (($i + 1) % 3 === 0) { + echo PHP_EOL; + } +} -for ($i = 0; $i < 3; $i++) { - $line = $c->red($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->green($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->yellow($i) . "TEST TEXT " . $c->reset() . PHP_EOL; - echo $line; - $line = $c->blue($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->cyan($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->magenta($i) . "TEST TEXT " . $c->reset() . PHP_EOL; - echo $line; - $line = $c->lightGray($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->darkGray($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->lightRed($i) . "TEST TEXT " . $c->reset() . PHP_EOL; - echo $line; - $line = $c->lightGreen($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->lightYellow($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->lightBlue($i) . "TEST TEXT " . $c->reset() . PHP_EOL; - echo $line; - $line = $c->magenta($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->cyan($i) . "TEST TEXT " . $c->reset() . "\t"; - echo $line; - $line = $c->white($i) . "TEST TEXT " . $c->reset() . PHP_EOL; - echo $line; - echo PHP_EOL; +echo PHP_EOL . PHP_EOL; + +// Foregrounds with styles: normal, bold, dim +$fgMethods = [ + 'black','red','green','yellow','blue','cyan','magenta', + 'lightGray','darkGray','lightRed','lightGreen','lightYellow', + 'lightBlue','lightMagenta','lightCyan','white' +]; + +foreach ($fgMethods as $method) { + echo $c->{$method}() . strtoupper($method) . $c->reset() . "\t"; } -$line = $c->bold() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->underline() . "TEST TEXT " . $c->reset() . "\t"; -echo $line; -$line = $c->dim() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; +echo PHP_EOL . PHP_EOL; -// Italic -$line = $c->underline() . "TEST TEXT " . $c->italic() . "\t"; -echo $line; +// Show bold + underline + dim examples +echo $c->bold() . "BOLD TEXT" . $c->reset() . "\t"; +echo $c->underline() . "UNDERLINE" . $c->reset() . "\t"; +echo $c->dim() . "DIM TEXT" . $c->reset() . PHP_EOL; -// Blinked text -$line = $c->blink() . "TEST TEXT " . $c->reset() . PHP_EOL; -echo $line; +// Show using style parameter on foreground (e.g., bold red) +echo $c->red(Colors::BOLD) . "Red Bold" . $c->reset() . "\t"; +echo $c->green(Colors::DIM) . "Green Dim" . $c->reset() . PHP_EOL; -for ($i = 0; $i < 256; $i+=16) { - $line = "\033[38;2;250;0;" . $i . "m TEST TEXT\033[0m" . PHP_EOL; - echo $line; +// 24-bit RGB example +for ($i = 0; $i < 256; $i += 16) { + echo "\033[38;2;250;0;" . $i . "m RGB($i)\033[0m " ; } +echo PHP_EOL; + +// Additional gradients using `Colors` helpers and CLI options +echo PHP_EOL . "RGB Gradients:" . PHP_EOL; +echo $c->rgbGradientString([255,0,0], [0,0,255], $steps, $glyph, false, $space); +echo $c->rgbGradientString([0,255,0], [0,0,255], $steps, $glyph, false, $space); +echo $c->rgbGradientString([255,255,0], [255,0,255], $steps, $glyph, false, $space); + +// Background gradients (compact blocks) +echo $c->rgbGradientString([255,0,0], [0,0,255], max(16, $steps * 2), ' ', true, false); +echo $c->rgbGradientString([0,255,0], [0,0,255], max(16, $steps * 2), ' ', true, false); + +echo PHP_EOL . "Rainbow:" . PHP_EOL; +echo $c->rainbowString(48, '#'); + + diff --git a/src/Colors.php b/src/Colors.php index 1c1561b..835fa76 100644 --- a/src/Colors.php +++ b/src/Colors.php @@ -50,14 +50,109 @@ class Colors * @param string $style [0,1 or 2 for light, normal or dark] * @return string [formated string] */ + /** + * Cache for previously-built escape codes to avoid recomputing strings. + * Keys are like "{style};{color}" (style omitted when null). + * @var array + */ + private static $buildCache = []; + private function build($color, $style = null) { + // normalize parts to string (colors are stored as ints in $this->color) + $colorStr = (string) $color; + $styleStr = $style === null ? '' : (string) $style; + + $key = $styleStr === '' ? $colorStr : $styleStr . ';' . $colorStr; + + if (isset(self::$buildCache[$key])) { + return self::$buildCache[$key]; + } + $res = self::OPEN_ESCAPE_CODE; if ($style !== null) { - $res .= $style . ";"; + $res .= $styleStr . ";"; } - $res .= $color; - $res .= self::CLOSE_ESCAPE_CODE; - + $res .= $colorStr; + $res .= self::CLOSE_ESCAPE_CODE; + + self::$buildCache[$key] = $res; + return $res; } + + /** + * Return a foreground 24-bit escape sequence for RGB. + */ + public function escFg(int $r, int $g, int $b): string + { + return sprintf("\033[38;2;%d;%d;%dm", $r, $g, $b); + } + + /** + * Return a background 24-bit escape sequence for RGB. + */ + public function escBg(int $r, int $g, int $b): string + { + return sprintf("\033[48;2;%d;%d;%dm", $r, $g, $b); + } + + /** + * Build a gradient string between two RGB colors. + * Returns a string (does not print). Options: + * - $glyph: character to print for each step + * - $bg: whether to use background coloring + * - $space: whether to add a space between glyphs + */ + public function rgbGradientString(array $from, array $to, int $steps = 16, string $glyph = '*', bool $bg = false, bool $space = true): string + { + $out = ''; + for ($i = 0; $i <= $steps; $i++) { + $t = $steps === 0 ? 0.0 : $i / $steps; + $r = (int) round($from[0] + ($to[0] - $from[0]) * $t); + $g = (int) round($from[1] + ($to[1] - $from[1]) * $t); + $b = (int) round($from[2] + ($to[2] - $from[2]) * $t); + $esc = $bg ? $this->escBg($r, $g, $b) : $this->escFg($r, $g, $b); + $out .= $esc . $glyph . "\033[0m" . ($space ? ' ' : ''); + } + $out .= PHP_EOL; + return $out; + } + + /** + * Convert HSV (0..1) to RGB (0..255) + */ + public function hsvToRgb(float $h, float $s, float $v): array + { + // Normalize hue into [0,1). Use floor to handle floats correctly (avoid PHP's int-only % operator). + $h = $h - floor($h); + $i = (int) floor($h * 6); + $f = $h * 6 - $i; + $p = $v * (1 - $s); + $q = $v * (1 - $f * $s); + $t = $v * (1 - (1 - $f) * $s); + switch ($i % 6) { + case 0: $r = $v; $g = $t; $b = $p; break; + case 1: $r = $q; $g = $v; $b = $p; break; + case 2: $r = $p; $g = $v; $b = $t; break; + case 3: $r = $p; $g = $q; $b = $v; break; + case 4: $r = $t; $g = $p; $b = $v; break; + default: $r = $v; $g = $p; $b = $q; break; + } + return [(int) round($r * 255), (int) round($g * 255), (int) round($b * 255)]; + } + + /** + * Return a rainbow string of $steps glyphs using HSV sweep. + */ + public function rainbowString(int $steps = 24, string $glyph = '#'): string + { + $out = ''; + for ($i = 0; $i < $steps; $i++) { + $h = $steps > 0 ? ($i / $steps) : 0.0; + [$r, $g, $b] = $this->hsvToRgb($h, 1.0, 1.0); + $out .= $this->escFg($r, $g, $b) . $glyph . "\033[0m" . ' '; + } + $out .= PHP_EOL; + return $out; + } } \ No newline at end of file