fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled

- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Attributes;
use App\Framework\Icon\Icon;
use Attribute;
/**
* AdminPage Attribute
*
* Provides metadata for admin pages to enable automatic discovery and navigation.
* This attribute is optional - pages without it will be auto-discovered with default values.
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class AdminPage
{
public function __construct(
public ?string $title = null,
public string|Icon|null $icon = null,
public ?string $section = null,
public int $order = 0,
public bool $hidden = false,
public ?string $description = null
) {
}
/**
* Get icon as Icon object
*/
public function getIcon(): ?Icon
{
if ($this->icon === null) {
return null;
}
if ($this->icon instanceof Icon) {
return $this->icon;
}
return Icon::fromString($this->icon);
}
}

View File

@@ -16,13 +16,25 @@ final readonly class AdminResource
public string $name,
public string $singularName,
public string $pluralName,
public string $icon = 'file',
public string|Icon $icon = 'file',
public bool $enableApi = true,
public bool $enableCrud = true,
public array $permissions = [],
) {
}
/**
* Get icon as Icon object
*/
public function getIcon(): Icon
{
if ($this->icon instanceof Icon) {
return $this->icon;
}
return Icon::fromString($this->icon);
}
public function getApiEndpoint(): string
{
return "/admin/api/{$this->name}";

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Attributes;
use App\Framework\Icon\Icon;
use Attribute;
/**
* AdminSection Attribute
*
* Provides metadata for admin sections to enable automatic grouping and navigation.
* Can be applied to controllers to define section-level configuration.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class AdminSection
{
public function __construct(
public ?string $name = null,
public string|Icon|null $icon = null,
public int $order = 0,
public ?string $description = null
) {
}
/**
* Get icon as Icon object
*/
public function getIcon(): ?Icon
{
if ($this->icon === null) {
return null;
}
if ($this->icon instanceof Icon) {
return $this->icon;
}
return Icon::fromString($this->icon);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Api;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
use App\Framework\HttpClient\ClientResponse;
@@ -15,7 +14,7 @@ class ApiException extends FrameworkException
int $code,
private readonly ClientResponse $response
) {
parent::__construct($message, ExceptionContext::empty(), $code);
parent::__construct($message, $code);
}
public function getResponse(): ClientResponse

View File

@@ -63,7 +63,7 @@ final readonly class ApiGateway
$operationId = "api_gateway.{$service}.{$requestName}." . uniqid();
$snapshot = $this->operationTracker->startOperation(
operationId: $operationId,
category: PerformanceCategory::HTTP,
category: PerformanceCategory::API,
contextData: [
'service' => $service,
'request_name' => $requestName,
@@ -175,7 +175,7 @@ final readonly class ApiGateway
private function buildClientRequest(ApiRequest $request): ClientRequest
{
// Convert timeout Duration to seconds for ClientOptions
$timeoutSeconds = $request->getTimeout()->toSeconds();
$timeoutSeconds = (int) $request->getTimeout()->toSeconds();
$options = new ClientOptions(
timeout: $timeoutSeconds,
@@ -276,7 +276,7 @@ final readonly class ApiGateway
private function recordMetrics(
string $service,
string $requestName,
\App\Framework\Performance\PerformanceSnapshot $snapshot,
\App\Framework\Performance\ValueObjects\PerformanceSnapshot $snapshot,
bool $success,
int $retryAttempts,
bool $circuitBreakerTriggered

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Async;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -14,6 +13,6 @@ class AsyncTimeoutException extends FrameworkException
{
public function __construct(string $message = 'Async operation timed out', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, ExceptionContext::empty(), $code, $previous);
parent::__construct($message, $code, $previous);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\AsyncExamples\Http;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -18,12 +17,6 @@ class HttpException extends FrameworkException
?\Throwable $previous = null,
public readonly ?HttpResponse $response = null
) {
$context = ExceptionContext::forOperation('http.request', 'http')
->withData([
'response_code' => $response?->statusCode ?? null,
'response_headers' => $response?->headers ?? null,
]);
parent::__construct($message, $context, $code, $previous);
parent::__construct($message, $code, $previous);
}
}

View File

@@ -21,25 +21,12 @@ final readonly class RouteAuthorizationServiceInitializer
{
// Configure namespace-based access control
$namespaceConfig = [
// Example: Block all Admin controllers by default
// 'App\Application\Admin\*' => [
// 'visibility' => 'admin', // IP-based restriction
// 'access_policy' => NamespaceAccessPolicy::blocked() // Block all
// ],
// Example: Block Admin except specific controllers
// 'App\Application\Admin\*' => [
// 'visibility' => 'public',
// 'access_policy' => NamespaceAccessPolicy::blockedExcept(
// \App\Application\Admin\LoginController::class,
// \App\Application\Admin\HealthCheckController::class
// )
// ],
// Example: IP restriction only (no namespace blocking)
// 'App\Application\Admin\*' => [
// 'visibility' => 'admin', // Only admin IPs allowed
// ],
// All Admin routes are automatically protected via IP-based restriction
// No need for explicit #[Auth] attributes in Admin controllers
'App\Application\Admin\*' => [
'visibility' => 'admin', // IP-based restriction (localhost/WireGuard)
// No access_policy - all Admin routes are accessible but IP-restricted
],
];
return new RouteAuthorizationService(

View File

@@ -14,6 +14,7 @@ use App\Framework\Cache\Driver\RedisCache;
use App\Framework\Cache\Metrics\CacheMetrics;
use App\Framework\Cache\Metrics\CacheMetricsInterface;
use App\Framework\Cache\Metrics\MetricsDecoratedCache;
use App\Framework\Core\PathProvider;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
@@ -69,7 +70,8 @@ final readonly class CacheInitializer
error_log('Redis not available, falling back to file cache: ' . $e->getMessage());
$redisCache = new GeneralCache(new FileCache(), $serializer, $compression);
$pathProvider = $this->container->get(PathProvider::class);
$redisCache = new GeneralCache(new FileCache(pathProvider: $pathProvider), $serializer, $compression);
}
#$redisCache->clear();

View File

@@ -9,25 +9,52 @@ use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult;
use App\Framework\Cache\Contracts\Scannable;
use App\Framework\Core\PathProvider;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Filesystem\LockableStorage;
use App\Framework\Filesystem\Storage;
final readonly class FileCache implements CacheDriver, Scannable
{
private const string CACHE_PATH = '/var/www/html/storage/cache';
private string $cachePath;
public function __construct(
private Storage $fileSystem = new FileStorage(),
?PathProvider $pathProvider = null,
?string $cachePath = null,
) {
$this->fileSystem->createDirectory(self::CACHE_PATH);
$this->cachePath = $this->normalizeCachePath(
$cachePath
?? $pathProvider?->getCachePath()
?? $this->detectCachePath()
);
// Create cache directory with explicit error handling
try {
$this->fileSystem->createDirectory($this->cachePath, 0755, true);
// Verify directory is writable after creation
if (!is_dir($this->cachePath)) {
error_log("FileCache: Failed to create cache directory: {$this->cachePath}");
} elseif (!is_writable($this->cachePath)) {
error_log("FileCache: Cache directory is not writable: {$this->cachePath}");
// Try to fix permissions (only if we can)
@chmod($this->cachePath, 0755);
if (!is_writable($this->cachePath)) {
error_log("FileCache: Could not fix permissions for cache directory: {$this->cachePath}");
}
}
} catch (\Throwable $e) {
error_log("FileCache: Failed to create cache directory '{$this->cachePath}': " . $e->getMessage());
// Continue anyway - operations will fail gracefully
}
}
private function getFileName(CacheKey $key, ?int $expiresAt): string
{
$hash = $this->getHashForKey($key);
return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '_' . ($expiresAt ?? 0) . '.cache.php';
return $this->cachePath . DIRECTORY_SEPARATOR . $hash . '_' . ($expiresAt ?? 0) . '.cache.php';
}
/**
@@ -47,7 +74,7 @@ final readonly class FileCache implements CacheDriver, Scannable
private function getFilesForKey(CacheKey $key): array
{
try {
$allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH);
$allFiles = $this->fileSystem->listDirectory($this->cachePath);
} catch (\App\Framework\Filesystem\Exceptions\DirectoryCreateException $e) {
// Directory doesn't exist - return empty array
return [];
@@ -61,7 +88,7 @@ final readonly class FileCache implements CacheDriver, Scannable
// Filter files matching the hash pattern
return array_filter($allFiles, function (string $file) use ($hash): bool {
// Convert relative path to absolute if needed
$fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file;
$fullPath = str_starts_with($file, '/') ? $file : $this->cachePath . DIRECTORY_SEPARATOR . $file;
$basename = basename($fullPath);
// Match: hash_*.cache.php
@@ -74,7 +101,7 @@ final readonly class FileCache implements CacheDriver, Scannable
{
$hash = $this->getHashForKey($key);
return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '.lock';
return $this->cachePath . DIRECTORY_SEPARATOR . $hash . '.lock';
}
/**
@@ -177,7 +204,7 @@ final readonly class FileCache implements CacheDriver, Scannable
foreach ($files as $file) {
// Convert to absolute path if needed
$fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file;
$fullPath = str_starts_with($file, '/') ? $file : $this->cachePath . DIRECTORY_SEPARATOR . $file;
$basename = basename($fullPath);
// Extract expiration time from filename: hash_expires.cache.php
@@ -292,9 +319,19 @@ final readonly class FileCache implements CacheDriver, Scannable
$file = $this->getFileName($item->key, $expiresAt);
// Store value directly as string (no serialization)
$this->fileSystem->put($file, $item->value);
return true;
try {
$this->fileSystem->put($file, $item->value);
return true;
} catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) {
error_log("FileCache: Permission denied writing cache file '{$file}': " . $e->getMessage());
return false;
} catch (\App\Framework\Filesystem\Exceptions\DirectoryCreateException $e) {
error_log("FileCache: Failed to create directory for cache file '{$file}': " . $e->getMessage());
return false;
} catch (\Throwable $e) {
error_log("FileCache: Failed to write cache file '{$file}': " . $e->getMessage());
return false;
}
});
$success = $success && $result;
@@ -359,7 +396,7 @@ final readonly class FileCache implements CacheDriver, Scannable
public function clear(): bool
{
try {
$allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH);
$allFiles = $this->fileSystem->listDirectory($this->cachePath);
} catch (\Throwable $e) {
// Directory doesn't exist or can't be accessed - nothing to clear
return true;
@@ -374,7 +411,7 @@ final readonly class FileCache implements CacheDriver, Scannable
foreach ($cacheFiles as $file) {
try {
// Convert to absolute path if needed
$fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file;
$fullPath = str_starts_with($file, '/') ? $file : $this->cachePath . DIRECTORY_SEPARATOR . $file;
$this->fileSystem->delete($fullPath);
} catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) {
// Permission denied - continue with other files (graceful degradation)
@@ -397,7 +434,7 @@ final readonly class FileCache implements CacheDriver, Scannable
public function scan(string $pattern, int $limit = 1000): array
{
try {
$allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH);
$allFiles = $this->fileSystem->listDirectory($this->cachePath);
} catch (\Throwable $e) {
// Directory doesn't exist or can't be accessed - return empty
return [];
@@ -419,7 +456,7 @@ final readonly class FileCache implements CacheDriver, Scannable
}
// Convert to absolute path if needed
$fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file;
$fullPath = str_starts_with($file, '/') ? $file : $this->cachePath . DIRECTORY_SEPARATOR . $file;
$key = $this->fileToKey($fullPath);
if (preg_match($regex, $key)) {
@@ -434,7 +471,7 @@ final readonly class FileCache implements CacheDriver, Scannable
public function scanPrefix(string $prefix, int $limit = 1000): array
{
try {
$allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH);
$allFiles = $this->fileSystem->listDirectory($this->cachePath);
} catch (\Throwable $e) {
// Directory doesn't exist or can't be accessed - return empty
return [];
@@ -455,7 +492,7 @@ final readonly class FileCache implements CacheDriver, Scannable
}
// Convert to absolute path if needed
$fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file;
$fullPath = str_starts_with($file, '/') ? $file : $this->cachePath . DIRECTORY_SEPARATOR . $file;
$key = $this->fileToKey($fullPath);
if (str_starts_with($key, $prefix)) {
@@ -470,7 +507,7 @@ final readonly class FileCache implements CacheDriver, Scannable
public function getAllKeys(int $limit = 1000): array
{
try {
$allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH);
$allFiles = $this->fileSystem->listDirectory($this->cachePath);
} catch (\Throwable $e) {
// Directory doesn't exist or can't be accessed - return empty
return [];
@@ -491,7 +528,7 @@ final readonly class FileCache implements CacheDriver, Scannable
}
// Convert to absolute path if needed
$fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file;
$fullPath = str_starts_with($file, '/') ? $file : $this->cachePath . DIRECTORY_SEPARATOR . $file;
$keys[] = $this->fileToKey($fullPath);
$count++;
}
@@ -508,6 +545,31 @@ final readonly class FileCache implements CacheDriver, Scannable
];
}
private function normalizeCachePath(string $path): string
{
$normalized = rtrim($path, DIRECTORY_SEPARATOR);
return $normalized === '' ? DIRECTORY_SEPARATOR : $normalized;
}
private function detectCachePath(): string
{
$basePath = getenv('APP_BASE_PATH');
if (is_string($basePath) && $basePath !== '') {
return rtrim($basePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'storage/cache';
}
$workingDirectory = getcwd();
if (is_string($workingDirectory) && $workingDirectory !== '') {
return rtrim($workingDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'storage/cache';
}
$projectRoot = dirname(__DIR__, 4);
return rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'storage/cache';
}
/**
* Convert filename back to cache key
*/

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Framework\CircuitBreaker;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -22,33 +20,10 @@ final class CircuitBreakerException extends FrameworkException
) {
$message = "Circuit breaker for service '{$service}' is {$state->value}. Service unavailable after {$failureCount} failures.";
$context = new ExceptionContext(
operation: 'circuit_breaker_check',
component: 'CircuitBreaker',
data: [
'service' => $service,
'state' => $state->value,
'failure_count' => $failureCount,
'retry_after_seconds' => $retryAfterSeconds,
],
metadata: [
'requires_alert' => true,
'recoverable' => true,
'error_code' => ErrorCode::SERVICE_CIRCUIT_OPEN->value,
'http_status' => 503,
'additional_headers' => [
'Retry-After' => (string) $retryAfterSeconds,
],
]
);
parent::__construct(
message: $message,
context: $context,
code: 503,
previous: $previous,
errorCode: ErrorCode::SERVICE_CIRCUIT_OPEN,
retryAfter: $retryAfterSeconds
previous: $previous
);
}
}

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Framework\CommandBus;
use App\Framework\Core\AttributeMapper;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\ReflectionLegacy\WrappedReflectionClass;
use App\Framework\ReflectionLegacy\WrappedReflectionMethod;
use RuntimeException;
final class CommandHandlerMapper implements AttributeMapper

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Config\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
class RequiredEnvironmentVariableException extends FrameworkException
@@ -14,10 +13,6 @@ class RequiredEnvironmentVariableException extends FrameworkException
*/
public function __construct(string $key)
{
parent::__construct(
message: "Required environment variable '$key' is not set.",
context: ExceptionContext::forOperation('config_validation', 'Configuration')
->withData(['environment_variable' => $key])
);
parent::__construct("Required environment variable '$key' is not set.");
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
/**
* Base interface for all animations
*/
interface Animation
{
/**
* Start the animation
*/
public function start(): void;
/**
* Stop the animation
*/
public function stop(): void;
/**
* Pause the animation
*/
public function pause(): void;
/**
* Resume the animation
*/
public function resume(): void;
/**
* Update the animation with delta time
* @param float $deltaTime Time elapsed since last update in seconds
* @return bool Returns true if animation is still active, false if finished
*/
public function update(float $deltaTime): bool;
/**
* Check if animation is currently active
*/
public function isActive(): bool;
/**
* Get current animation progress (0.0-1.0)
*/
public function getProgress(): float;
/**
* Get animation duration in seconds
*/
public function getDuration(): float;
/**
* Get animation delay in seconds
*/
public function getDelay(): float;
/**
* Check if animation loops
*/
public function isLooping(): bool;
/**
* Set callback for when animation starts
*/
public function onStart(callable $callback): self;
/**
* Set callback for when animation completes
*/
public function onComplete(callable $callback): self;
/**
* Set callback for when animation is paused
*/
public function onPause(callable $callback): self;
/**
* Set callback for when animation is resumed
*/
public function onResume(callable $callback): self;
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
use App\Framework\Console\Animation\Types\BaseAnimation;
use Closure;
/**
* Fluent builder for creating and configuring animations
*/
final class AnimationBuilder
{
private ?float $duration = null;
private float $delay = 0.0;
private bool $loop = false;
private ?EasingFunction $easing = null;
/**
* @var Closure[]
*/
private array $onStartCallbacks = [];
/**
* @var Closure[]
*/
private array $onCompleteCallbacks = [];
/**
* @var Closure[]
*/
private array $onPauseCallbacks = [];
/**
* @var Closure[]
*/
private array $onResumeCallbacks = [];
/**
* @param Animation|class-string<Animation> $animation The animation instance or class name to build
*/
public function __construct(
private Animation|string $animation
) {
}
/**
* Set animation duration
*/
public function duration(float $seconds): self
{
$this->duration = max(0.0, $seconds);
return $this;
}
/**
* Set animation delay
*/
public function delay(float $seconds): self
{
$this->delay = max(0.0, $seconds);
return $this;
}
/**
* Enable or disable looping
*/
public function loop(bool $loop = true): self
{
$this->loop = $loop;
return $this;
}
/**
* Set easing function
*/
public function easing(EasingFunction $easing): self
{
$this->easing = $easing;
return $this;
}
/**
* Add callback for when animation starts
*/
public function onStart(callable $callback): self
{
$this->onStartCallbacks[] = Closure::fromCallable($callback);
return $this;
}
/**
* Add callback for when animation completes
*/
public function onComplete(callable $callback): self
{
$this->onCompleteCallbacks[] = Closure::fromCallable($callback);
return $this;
}
/**
* Add callback for when animation is paused
*/
public function onPause(callable $callback): self
{
$this->onPauseCallbacks[] = Closure::fromCallable($callback);
return $this;
}
/**
* Add callback for when animation is resumed
*/
public function onResume(callable $callback): self
{
$this->onResumeCallbacks[] = Closure::fromCallable($callback);
return $this;
}
/**
* Build and return the configured animation
*/
public function build(): Animation
{
$animation = $this->createAnimation();
// Apply configuration
if ($animation instanceof BaseAnimation) {
if ($this->duration !== null) {
// Use reflection to set duration if possible
// For now, animations should be created with proper duration in constructor
}
// Apply callbacks
foreach ($this->onStartCallbacks as $callback) {
$animation->onStart($callback);
}
foreach ($this->onCompleteCallbacks as $callback) {
$animation->onComplete($callback);
}
foreach ($this->onPauseCallbacks as $callback) {
$animation->onPause($callback);
}
foreach ($this->onResumeCallbacks as $callback) {
$animation->onResume($callback);
}
}
return $animation;
}
/**
* Create animation instance
*/
private function createAnimation(): Animation
{
if ($this->animation instanceof Animation) {
return $this->animation;
}
// If it's a class name, we need to create it
// This is a simplified version - in practice, you'd need more parameters
if (is_string($this->animation)) {
throw new \InvalidArgumentException('Class name animation creation requires additional parameters. Use AnimationFactory or create instance directly.');
}
return $this->animation;
}
/**
* Create a new builder for a fade animation
*/
public static function fade(float $startOpacity = 0.0, float $endOpacity = 1.0): self
{
$animation = AnimationFactory::fade(1.0, $startOpacity, $endOpacity);
return new self($animation);
}
/**
* Create a new builder for a slide animation
*/
public static function slide(Types\SlideDirection $direction, int $distance): self
{
$animation = AnimationFactory::slide($direction, $distance);
return new self($animation);
}
/**
* Create a new builder for a typewriter animation
*/
public static function typewriter(string $text): self
{
$animation = AnimationFactory::typewriter($text);
return new self($animation);
}
/**
* Create a new builder for a pulse animation
*/
public static function pulse(): self
{
$animation = AnimationFactory::pulse();
return new self($animation);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
use App\Framework\Console\Animation\Types\FadeAnimation;
use App\Framework\Console\Animation\Types\MarqueeAnimation;
use App\Framework\Console\Animation\Types\PulseAnimation;
use App\Framework\Console\Animation\Types\SlideAnimation;
use App\Framework\Console\Animation\Types\SpinnerAnimation;
use App\Framework\Console\Animation\Types\TypewriterAnimation;
use App\Framework\Console\SpinnerStyle;
/**
* Factory for creating animations easily
*/
final readonly class AnimationFactory
{
/**
* Create a fade animation
*/
public static function fade(
float $duration = 1.0,
float $startOpacity = 0.0,
float $endOpacity = 1.0,
EasingFunction $easing = EasingFunction::EASE_IN_OUT
): FadeAnimation {
return new FadeAnimation($duration, 0.0, false, $startOpacity, $endOpacity, $easing);
}
/**
* Create a fade-in animation
*/
public static function fadeIn(float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_IN): FadeAnimation
{
return FadeAnimation::fadeIn($duration, $easing);
}
/**
* Create a fade-out animation
*/
public static function fadeOut(float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_OUT): FadeAnimation
{
return FadeAnimation::fadeOut($duration, $easing);
}
/**
* Create a slide animation
*/
public static function slide(
Types\SlideDirection $direction,
int $distance,
float $duration = 1.0,
EasingFunction $easing = EasingFunction::EASE_OUT
): SlideAnimation {
return new SlideAnimation($direction, $distance, $duration, 0.0, false, 0, $easing);
}
/**
* Create a slide-in from left
*/
public static function slideInFromLeft(int $distance, float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_OUT): SlideAnimation
{
return SlideAnimation::slideInFromLeft($distance, $duration, $easing);
}
/**
* Create a slide-in from right
*/
public static function slideInFromRight(int $distance, float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_OUT): SlideAnimation
{
return SlideAnimation::slideInFromRight($distance, $duration, $easing);
}
/**
* Create a typewriter animation
*/
public static function typewriter(
string $text,
float $charactersPerSecond = 10.0
): TypewriterAnimation {
return new TypewriterAnimation($text, $charactersPerSecond);
}
/**
* Create a marquee animation
*/
public static function marquee(
string $text,
int $width = 80,
float $speed = 1.0,
bool $loop = true
): MarqueeAnimation {
return new MarqueeAnimation($text, $width, $speed, $loop, true);
}
/**
* Create a pulse animation
*/
public static function pulse(
float $duration = 1.0,
float $scaleStart = 1.0,
float $scaleEnd = 1.2,
float $pulseSpeed = 2.0
): PulseAnimation {
return new PulseAnimation($duration, 0.0, true, $scaleStart, $scaleEnd, $pulseSpeed);
}
/**
* Create a spinner animation
*/
public static function spinner(
SpinnerStyle|array $frames,
string $message = 'Loading...',
float $frameInterval = 0.1
): SpinnerAnimation {
return new SpinnerAnimation($frames, $message, $frameInterval, true);
}
/**
* Create a keyframe animation
*/
public static function keyframe(
array $keyframes,
float $duration = 1.0,
bool $loop = false
): KeyframeAnimation {
return new KeyframeAnimation($keyframes, $duration, 0.0, $loop);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
/**
* Value object for animation keyframes
*/
final readonly class AnimationFrame
{
/**
* @param float $time Animation time (0.0-1.0) when this keyframe occurs
* @param mixed $value The value at this keyframe
* @param EasingFunction $easing Easing function to use when interpolating to next keyframe
*/
public function __construct(
public float $time,
public mixed $value,
public EasingFunction $easing = EasingFunction::LINEAR
) {
if ($time < 0.0 || $time > 1.0) {
throw new \InvalidArgumentException("Animation frame time must be between 0.0 and 1.0. Got: {$time}");
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
/**
* Manages multiple animations simultaneously
* Handles updates and lifecycle of all registered animations
*/
final class AnimationManager
{
/**
* @var Animation[]
*/
private array $animations = [];
private float $lastUpdateTime = 0.0;
/**
* Add an animation to the manager
*/
public function add(Animation $animation): void
{
$this->animations[] = $animation;
// Auto-start if enabled
if ($animation->getDelay() <= 0.0) {
$animation->start();
}
}
/**
* Remove an animation from the manager
*/
public function remove(Animation $animation): void
{
$key = array_search($animation, $this->animations, true);
if ($key !== false) {
unset($this->animations[$key]);
$this->animations = array_values($this->animations); // Re-index array
$animation->stop();
}
}
/**
* Update all animations with delta time
* @param float $deltaTime Time elapsed since last update in seconds
*/
public function update(float $deltaTime): void
{
$currentTime = microtime(true);
if ($this->lastUpdateTime === 0.0) {
$this->lastUpdateTime = $currentTime;
return;
}
// Calculate actual delta time
$actualDelta = $currentTime - $this->lastUpdateTime;
$this->lastUpdateTime = $currentTime;
// Update all animations
$finishedAnimations = [];
foreach ($this->animations as $animation) {
// Handle delayed animations
if ($animation->getDelay() > 0.0 && !$animation->isActive()) {
// This should be handled by the animation itself, but we check here too
continue;
}
$stillActive = $animation->update($actualDelta);
if (!$stillActive && !$animation->isLooping()) {
$finishedAnimations[] = $animation;
}
}
// Remove finished non-looping animations
foreach ($finishedAnimations as $animation) {
$this->remove($animation);
}
}
/**
* Clear all animations
*/
public function clear(): void
{
foreach ($this->animations as $animation) {
$animation->stop();
}
$this->animations = [];
}
/**
* Get all active animations
*
* @return Animation[]
*/
public function getActiveAnimations(): array
{
return array_filter($this->animations, fn(Animation $animation) => $animation->isActive());
}
/**
* Get count of active animations
*/
public function getActiveCount(): int
{
return count($this->getActiveAnimations());
}
/**
* Get total count of animations (including paused/finished)
*/
public function getTotalCount(): int
{
return count($this->animations);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
/**
* Enum for animation states
*/
enum AnimationState: string
{
case IDLE = 'idle';
case RUNNING = 'running';
case PAUSED = 'paused';
case FINISHED = 'finished';
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
use App\Framework\Console\Animation\Types\BaseAnimation;
/**
* Sequence type enum
*/
enum SequenceType: string
{
case PARALLEL = 'parallel'; // All animations run simultaneously
case SEQUENTIAL = 'sequential'; // Animations run one after another
}
/**
* Composite animation that combines multiple animations
* Can run animations in parallel or sequentially
*/
final class CompositeAnimation extends BaseAnimation
{
/**
* @var Animation[]
*/
private array $animations = [];
private SequenceType $sequenceType = SequenceType::PARALLEL;
private int $currentAnimationIndex = 0;
/**
* @param Animation[] $animations
*/
public function __construct(
array $animations = [],
SequenceType $sequenceType = SequenceType::PARALLEL,
float $delay = 0.0,
bool $loop = false
) {
// Calculate total duration based on sequence type
$totalDuration = $this->calculateTotalDuration($animations, $sequenceType);
parent::__construct($totalDuration, $delay, $loop);
$this->animations = $animations;
$this->sequenceType = $sequenceType;
$this->currentAnimationIndex = 0;
}
protected function updateAnimation(float $progress): void
{
if ($this->sequenceType === SequenceType::PARALLEL) {
// Update all animations in parallel
foreach ($this->animations as $animation) {
if (!$animation->isActive() && $animation->getDelay() <= 0.0) {
$animation->start();
}
$animation->update(0.016); // Approximate delta time
}
} else {
// Update animations sequentially
$this->updateSequential($progress);
}
}
/**
* Update animations sequentially
*/
private function updateSequential(float $progress): void
{
if (empty($this->animations)) {
return;
}
$totalDuration = $this->getDuration();
$elapsedTime = $progress * $totalDuration;
$accumulatedTime = 0.0;
$activeAnimationIndex = -1;
// Find which animation should be active
for ($i = 0; $i < count($this->animations); $i++) {
$animation = $this->animations[$i];
$animationDuration = $animation->getDuration() + $animation->getDelay();
if ($elapsedTime >= $accumulatedTime && $elapsedTime < $accumulatedTime + $animationDuration) {
$activeAnimationIndex = $i;
break;
}
$accumulatedTime += $animationDuration;
}
// Start/update active animation
if ($activeAnimationIndex >= 0) {
$activeAnimation = $this->animations[$activeAnimationIndex];
if (!$activeAnimation->isActive()) {
$activeAnimation->start();
}
// Calculate local progress for this animation
$localElapsed = $elapsedTime - $accumulatedTime;
$localProgress = min(1.0, $localElapsed / max(0.001, $activeAnimation->getDuration()));
$activeAnimation->update(0.016); // Approximate delta time
}
}
public function getCurrentValue(): array
{
$values = [];
foreach ($this->animations as $index => $animation) {
$values[$index] = $animation instanceof BaseAnimation ? $animation->getCurrentValue() : null;
}
return $values;
}
/**
* Add an animation to the composite
*/
public function addAnimation(Animation $animation): self
{
$this->animations[] = $animation;
// Recalculate total duration
$this->duration = $this->calculateTotalDuration($this->animations, $this->sequenceType);
return $this;
}
/**
* Set sequence type
*/
public function setSequenceType(SequenceType $type): self
{
$this->sequenceType = $type;
$this->duration = $this->calculateTotalDuration($this->animations, $type);
return $this;
}
/**
* Get all animations
*
* @return Animation[]
*/
public function getAnimations(): array
{
return $this->animations;
}
/**
* Calculate total duration based on sequence type
*/
private function calculateTotalDuration(array $animations, SequenceType $type): float
{
if (empty($animations)) {
return 1.0;
}
if ($type === SequenceType::PARALLEL) {
// Parallel: use longest animation duration
$maxDuration = 0.0;
foreach ($animations as $animation) {
$totalTime = $animation->getDuration() + $animation->getDelay();
$maxDuration = max($maxDuration, $totalTime);
}
return $maxDuration;
} else {
// Sequential: sum all durations
$totalDuration = 0.0;
foreach ($animations as $animation) {
$totalDuration += $animation->getDuration() + $animation->getDelay();
}
return $totalDuration;
}
}
public function start(): void
{
parent::start();
if ($this->sequenceType === SequenceType::PARALLEL) {
// Start all animations in parallel
foreach ($this->animations as $animation) {
if ($animation->getDelay() <= 0.0) {
$animation->start();
}
}
} else {
// Start first animation in sequence
if (!empty($this->animations)) {
$this->currentAnimationIndex = 0;
$firstAnimation = $this->animations[0];
if ($firstAnimation->getDelay() <= 0.0) {
$firstAnimation->start();
}
}
}
}
public function stop(): void
{
parent::stop();
foreach ($this->animations as $animation) {
$animation->stop();
}
}
public function pause(): void
{
parent::pause();
foreach ($this->animations as $animation) {
if ($animation->isActive()) {
$animation->pause();
}
}
}
public function resume(): void
{
parent::resume();
foreach ($this->animations as $animation) {
if ($animation->isActive()) {
$animation->resume();
}
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
/**
* Enum for easing functions
* Transforms animation progress (0.0-1.0) with various easing curves
*/
enum EasingFunction: string
{
case LINEAR = 'linear';
case EASE_IN = 'ease_in';
case EASE_OUT = 'ease_out';
case EASE_IN_OUT = 'ease_in_out';
case EASE_IN_QUAD = 'ease_in_quad';
case EASE_OUT_QUAD = 'ease_out_quad';
case EASE_IN_OUT_QUAD = 'ease_in_out_quad';
case BOUNCE = 'bounce';
case ELASTIC = 'elastic';
/**
* Apply easing function to transform progress value (0.0-1.0)
*/
public function apply(float $t): float
{
// Clamp t to [0, 1]
$t = max(0.0, min(1.0, $t));
return match ($this) {
self::LINEAR => $t,
self::EASE_IN => $t * $t,
self::EASE_OUT => $t * (2.0 - $t),
self::EASE_IN_OUT => $t < 0.5 ? 2.0 * $t * $t : -1.0 + (4.0 - 2.0 * $t) * $t,
self::EASE_IN_QUAD => $t * $t,
self::EASE_OUT_QUAD => $t * (2.0 - $t),
self::EASE_IN_OUT_QUAD => $t < 0.5 ? 2.0 * $t * $t : -1.0 + (4.0 - 2.0 * $t) * $t,
self::BOUNCE => $this->bounce($t),
self::ELASTIC => $this->elastic($t),
};
}
/**
* Bounce easing function
*/
private function bounce(float $t): float
{
if ($t < 1.0 / 2.75) {
return 7.5625 * $t * $t;
} elseif ($t < 2.0 / 2.75) {
$t -= 1.5 / 2.75;
return 7.5625 * $t * $t + 0.75;
} elseif ($t < 2.5 / 2.75) {
$t -= 2.25 / 2.75;
return 7.5625 * $t * $t + 0.9375;
} else {
$t -= 2.625 / 2.75;
return 7.5625 * $t * $t + 0.984375;
}
}
/**
* Elastic easing function
*/
private function elastic(float $t): float
{
if ($t === 0.0 || $t === 1.0) {
return $t;
}
$p = 0.3;
$s = $p / 4.0;
return pow(2.0, -10.0 * $t) * sin(($t - $s) * (2.0 * M_PI) / $p) + 1.0;
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
use App\Framework\Console\Animation\Types\BaseAnimation;
/**
* Generic keyframe-based animation
* Interpolates values between keyframes with easing
*/
final class KeyframeAnimation extends BaseAnimation
{
/**
* @var AnimationFrame[]
*/
private array $keyframes = [];
private mixed $currentValue = null;
public function __construct(
array $keyframes,
float $duration = 1.0,
float $delay = 0.0,
bool $loop = false
) {
parent::__construct($duration, $delay, $loop);
// Validate and sort keyframes
if (empty($keyframes)) {
throw new \InvalidArgumentException('KeyframeAnimation requires at least one keyframe');
}
$this->keyframes = $this->sortKeyframes($keyframes);
// Initialize current value with first keyframe
$this->currentValue = $this->keyframes[0]->value;
}
protected function updateAnimation(float $progress): void
{
$this->currentValue = $this->getValueAt($progress);
}
public function getCurrentValue(): mixed
{
return $this->currentValue;
}
/**
* Get interpolated value at given time (0.0-1.0)
*/
public function getValueAt(float $time): mixed
{
$time = max(0.0, min(1.0, $time));
// Find surrounding keyframes
$keyframeCount = count($this->keyframes);
if ($keyframeCount === 1) {
return $this->keyframes[0]->value;
}
// Find the two keyframes that surround the time
$prevKeyframe = null;
$nextKeyframe = null;
for ($i = 0; $i < $keyframeCount; $i++) {
$keyframe = $this->keyframes[$i];
if ($keyframe->time >= $time) {
$nextKeyframe = $keyframe;
if ($i > 0) {
$prevKeyframe = $this->keyframes[$i - 1];
} else {
$prevKeyframe = $keyframe;
}
break;
}
$prevKeyframe = $keyframe;
}
// If we didn't find a next keyframe, use the last one
if ($nextKeyframe === null) {
$prevKeyframe = $this->keyframes[$keyframeCount - 1];
$nextKeyframe = $prevKeyframe;
}
// If both are the same, return the value
if ($prevKeyframe === $nextKeyframe || $prevKeyframe->time === $nextKeyframe->time) {
return $prevKeyframe->value;
}
// Interpolate between keyframes
$range = $nextKeyframe->time - $prevKeyframe->time;
$localTime = $range > 0 ? ($time - $prevKeyframe->time) / $range : 0.0;
// Apply easing from previous keyframe
$easedTime = $prevKeyframe->easing->apply($localTime);
return $this->interpolate($prevKeyframe->value, $nextKeyframe->value, $easedTime);
}
/**
* Interpolate between two values
*/
private function interpolate(mixed $start, mixed $end, float $t): mixed
{
// Numeric interpolation
if (is_numeric($start) && is_numeric($end)) {
return $start + ($end - $start) * $t;
}
// String interpolation (character by character)
if (is_string($start) && is_string($end)) {
// For strings, we'll just return the start or end based on progress
// More complex string interpolation could be added here
return $t < 0.5 ? $start : $end;
}
// Array interpolation (for numeric arrays)
if (is_array($start) && is_array($end) && count($start) === count($end)) {
$result = [];
foreach ($start as $key => $startValue) {
if (isset($end[$key]) && is_numeric($startValue) && is_numeric($end[$key])) {
$result[$key] = $this->interpolate($startValue, $end[$key], $t);
} else {
$result[$key] = $t < 0.5 ? $startValue : $end[$key];
}
}
return $result;
}
// Default: return end value if t >= 0.5, else start
return $t < 0.5 ? $start : $end;
}
/**
* Sort keyframes by time
*
* @param AnimationFrame[] $keyframes
* @return AnimationFrame[]
*/
private function sortKeyframes(array $keyframes): array
{
usort($keyframes, fn(AnimationFrame $a, AnimationFrame $b) => $a->time <=> $b->time);
// Ensure first keyframe is at time 0.0
if (count($keyframes) > 0 && $keyframes[0]->time > 0.0) {
array_unshift($keyframes, new AnimationFrame(0.0, $keyframes[0]->value));
}
// Ensure last keyframe is at time 1.0
$lastIndex = count($keyframes) - 1;
if ($lastIndex >= 0 && $keyframes[$lastIndex]->time < 1.0) {
$keyframes[] = new AnimationFrame(1.0, $keyframes[$lastIndex]->value);
}
return $keyframes;
}
/**
* Get all keyframes
*
* @return AnimationFrame[]
*/
public function getKeyframes(): array
{
return $this->keyframes;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
use App\Framework\Console\Animation\Types\BaseAnimation;
/**
* Base class for text animations
* Integrates with TextWriter for animated text output
*/
abstract class TextAnimation
{
/**
* Render current frame of animation
* Returns the text to display at current animation state
*/
abstract public function renderFrame(): string;
/**
* Check if animation is complete
*/
abstract public function isComplete(): bool;
/**
* Get the animation instance
*/
abstract public function getAnimation(): Animation;
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation;
use App\Framework\Console\Animation\Types\FadeAnimation;
use App\Framework\Console\Animation\Types\PulseAnimation;
use App\Framework\Console\Animation\Types\SlideAnimation;
use App\Framework\Console\Components\TuiState;
/**
* Specialized renderer for TUI animations
* Manages animations for UI elements (menu items, buttons, etc.)
*/
final class TuiAnimationRenderer
{
/**
* @var array<string, Animation> Element ID => Animation mapping
*/
private array $elementAnimations = [];
/**
* @var array<string, mixed> Element ID => Current animated value
*/
private array $elementValues = [];
public function __construct(
private readonly AnimationManager $animationManager
) {
}
/**
* Animate a UI element
*
* @param string $elementId Unique identifier for the element
* @param Animation $animation The animation to apply
*/
public function animateElement(string $elementId, Animation $animation): void
{
// Stop existing animation for this element if any
if (isset($this->elementAnimations[$elementId])) {
$this->animationManager->remove($this->elementAnimations[$elementId]);
}
// Add new animation
$this->elementAnimations[$elementId] = $animation;
$this->animationManager->add($animation);
// Initialize element value
if ($animation instanceof Types\BaseAnimation) {
$this->elementValues[$elementId] = $animation->getCurrentValue();
}
}
/**
* Animate element with fade-in
*/
public function fadeInElement(string $elementId, float $duration = 0.5): void
{
$animation = AnimationFactory::fadeIn($duration);
$this->animateElement($elementId, $animation);
}
/**
* Animate element with fade-out
*/
public function fadeOutElement(string $elementId, float $duration = 0.5): void
{
$animation = AnimationFactory::fadeOut($duration);
$this->animateElement($elementId, $animation);
}
/**
* Animate element with pulse
*/
public function pulseElement(string $elementId, float $duration = 1.0): void
{
$animation = AnimationFactory::pulse($duration);
$this->animateElement($elementId, $animation);
}
/**
* Animate element with slide-in
*/
public function slideInElement(string $elementId, Types\SlideDirection $direction, int $distance, float $duration = 0.5): void
{
$animation = AnimationFactory::slide($direction, $distance, $duration);
$this->animateElement($elementId, $animation);
}
/**
* Stop animation for an element
*/
public function stopElement(string $elementId): void
{
if (isset($this->elementAnimations[$elementId])) {
$this->animationManager->remove($this->elementAnimations[$elementId]);
unset($this->elementAnimations[$elementId]);
unset($this->elementValues[$elementId]);
}
}
/**
* Update all element animations
* Should be called from render loop
*/
public function update(float $deltaTime): void
{
// Update element values from animations
foreach ($this->elementAnimations as $elementId => $animation) {
if ($animation instanceof Types\BaseAnimation) {
$this->elementValues[$elementId] = $animation->getCurrentValue();
}
}
}
/**
* Get current animated value for an element
*/
public function getElementValue(string $elementId): mixed
{
return $this->elementValues[$elementId] ?? null;
}
/**
* Check if element has active animation
*/
public function hasAnimation(string $elementId): bool
{
return isset($this->elementAnimations[$elementId]) && $this->elementAnimations[$elementId]->isActive();
}
/**
* Get opacity for element (if fade animation is active)
*/
public function getElementOpacity(string $elementId): float
{
$animation = $this->elementAnimations[$elementId] ?? null;
if ($animation instanceof FadeAnimation) {
return $animation->getOpacity();
}
return 1.0; // Default: fully opaque
}
/**
* Get position offset for element (if slide animation is active)
*/
public function getElementPosition(string $elementId): int
{
$animation = $this->elementAnimations[$elementId] ?? null;
if ($animation instanceof SlideAnimation) {
return $animation->getPosition();
}
return 0; // Default: no offset
}
/**
* Get scale factor for element (if pulse animation is active)
*/
public function getElementScale(string $elementId): float
{
$animation = $this->elementAnimations[$elementId] ?? null;
if ($animation instanceof PulseAnimation) {
return $animation->getScale();
}
return 1.0; // Default: no scaling
}
/**
* Clear all element animations
*/
public function clear(): void
{
foreach (array_keys($this->elementAnimations) as $elementId) {
$this->stopElement($elementId);
}
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
use App\Framework\Console\Animation\Animation;
use App\Framework\Console\Animation\AnimationState;
use Closure;
/**
* Base implementation for animations
* Provides common functionality for all animation types
*/
abstract class BaseAnimation implements Animation
{
protected AnimationState $state = AnimationState::IDLE;
protected float $elapsedTime = 0.0;
protected float $duration = 1.0;
protected float $delay = 0.0;
protected bool $loop = false;
protected float $progress = 0.0;
/**
* @var Closure[]
*/
protected array $onStartCallbacks = [];
/**
* @var Closure[]
*/
protected array $onCompleteCallbacks = [];
/**
* @var Closure[]
*/
protected array $onPauseCallbacks = [];
/**
* @var Closure[]
*/
protected array $onResumeCallbacks = [];
public function __construct(
float $duration = 1.0,
float $delay = 0.0,
bool $loop = false
) {
$this->duration = max(0.0, $duration);
$this->delay = max(0.0, $delay);
$this->loop = $loop;
}
public function start(): void
{
if ($this->state === AnimationState::RUNNING) {
return;
}
$this->state = AnimationState::RUNNING;
$this->elapsedTime = 0.0;
$this->progress = 0.0;
$this->triggerCallbacks($this->onStartCallbacks);
}
public function stop(): void
{
$this->state = AnimationState::FINISHED;
$this->elapsedTime = 0.0;
$this->progress = 0.0;
}
public function pause(): void
{
if ($this->state === AnimationState::RUNNING) {
$this->state = AnimationState::PAUSED;
$this->triggerCallbacks($this->onPauseCallbacks);
}
}
public function resume(): void
{
if ($this->state === AnimationState::PAUSED) {
$this->state = AnimationState::RUNNING;
$this->triggerCallbacks($this->onResumeCallbacks);
}
}
public function update(float $deltaTime): bool
{
if ($this->state !== AnimationState::RUNNING) {
return $this->state === AnimationState::RUNNING;
}
// Handle delay
if ($this->elapsedTime < $this->delay) {
$this->elapsedTime += $deltaTime;
return true;
}
// Calculate animation time (after delay)
$animationTime = $this->elapsedTime - $this->delay;
$this->elapsedTime += $deltaTime;
if ($animationTime >= $this->duration) {
if ($this->loop) {
// Loop: reset and continue
$this->elapsedTime = $this->delay;
$this->progress = 0.0;
$this->triggerCallbacks($this->onStartCallbacks);
return true;
} else {
// Finished
$this->progress = 1.0;
$this->state = AnimationState::FINISHED;
$this->triggerCallbacks($this->onCompleteCallbacks);
return false;
}
}
// Update progress
$this->progress = min(1.0, $animationTime / $this->duration);
// Update animation-specific logic
$this->updateAnimation($this->progress);
return true;
}
public function isActive(): bool
{
return $this->state === AnimationState::RUNNING;
}
public function getProgress(): float
{
return $this->progress;
}
public function getDuration(): float
{
return $this->duration;
}
public function getDelay(): float
{
return $this->delay;
}
public function isLooping(): bool
{
return $this->loop;
}
public function onStart(callable $callback): self
{
$this->onStartCallbacks[] = Closure::fromCallable($callback);
return $this;
}
public function onComplete(callable $callback): self
{
$this->onCompleteCallbacks[] = Closure::fromCallable($callback);
return $this;
}
public function onPause(callable $callback): self
{
$this->onPauseCallbacks[] = Closure::fromCallable($callback);
return $this;
}
public function onResume(callable $callback): self
{
$this->onResumeCallbacks[] = Closure::fromCallable($callback);
return $this;
}
/**
* Update animation-specific logic
* Called during update() with current progress (0.0-1.0)
*/
abstract protected function updateAnimation(float $progress): void;
/**
* Get current animation value
* Returns the current interpolated value based on progress
*/
abstract public function getCurrentValue(): mixed;
/**
* Trigger callbacks
*/
protected function triggerCallbacks(array $callbacks): void
{
foreach ($callbacks as $callback) {
$callback($this);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
use App\Framework\Console\Animation\EasingFunction;
/**
* Fade animation for fading text/elements in or out
*/
final class FadeAnimation extends BaseAnimation
{
private float $startOpacity = 0.0;
private float $endOpacity = 1.0;
private float $currentOpacity = 0.0;
private EasingFunction $easing;
public function __construct(
float $duration = 1.0,
float $delay = 0.0,
bool $loop = false,
float $startOpacity = 0.0,
float $endOpacity = 1.0,
EasingFunction $easing = EasingFunction::EASE_IN_OUT
) {
parent::__construct($duration, $delay, $loop);
$this->startOpacity = max(0.0, min(1.0, $startOpacity));
$this->endOpacity = max(0.0, min(1.0, $endOpacity));
$this->easing = $easing;
$this->currentOpacity = $this->startOpacity;
}
protected function updateAnimation(float $progress): void
{
$easedProgress = $this->easing->apply($progress);
$this->currentOpacity = $this->startOpacity + ($this->endOpacity - $this->startOpacity) * $easedProgress;
}
public function getCurrentValue(): float
{
return $this->currentOpacity;
}
/**
* Get current opacity (0.0-1.0)
*/
public function getOpacity(): float
{
return $this->currentOpacity;
}
/**
* Create fade-in animation
*/
public static function fadeIn(float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_IN): self
{
return new self($duration, 0.0, false, 0.0, 1.0, $easing);
}
/**
* Create fade-out animation
*/
public static function fadeOut(float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_OUT): self
{
return new self($duration, 0.0, false, 1.0, 0.0, $easing);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
/**
* Marquee animation for scrolling text (horizontal)
*/
final class MarqueeAnimation extends BaseAnimation
{
private string $text = '';
private int $width = 80;
private float $speed = 1.0;
private int $position = 0;
private bool $continuous = true;
public function __construct(
string $text,
int $width = 80,
float $speed = 1.0,
bool $loop = true,
bool $continuous = true
) {
// Calculate duration based on text length and width
$textLength = mb_strlen($text);
$scrollDistance = $textLength + $width;
$duration = $scrollDistance / max(0.1, $speed);
parent::__construct($duration, 0.0, $loop);
$this->text = $text;
$this->width = max(1, $width);
$this->speed = max(0.1, $speed);
$this->continuous = $continuous;
$this->position = 0;
}
protected function updateAnimation(float $progress): void
{
$textLength = mb_strlen($this->text);
if ($this->continuous) {
// Continuous scrolling: wrap around
$totalDistance = $textLength + $this->width;
$this->position = (int) floor($progress * $totalDistance) % ($textLength + $this->width);
} else {
// One-time scroll
$totalDistance = $textLength + $this->width;
$this->position = (int) floor($progress * $totalDistance);
}
}
public function getCurrentValue(): string
{
$textLength = mb_strlen($this->text);
$displayText = $this->text . str_repeat(' ', $this->width);
if ($this->position >= $textLength + $this->width && !$this->continuous) {
// Scrolled past end, show empty
return str_repeat(' ', $this->width);
}
$displayText = mb_substr($displayText, $this->position, $this->width);
return mb_substr($displayText . str_repeat(' ', $this->width), 0, $this->width);
}
/**
* Get current displayed text (with proper padding)
*/
public function getDisplayText(): string
{
return $this->getCurrentValue();
}
/**
* Get current scroll position
*/
public function getPosition(): int
{
return $this->position;
}
/**
* Create fast marquee
*/
public static function fast(string $text, int $width = 80): self
{
return new self($text, $width, 5.0, true, true);
}
/**
* Create slow marquee
*/
public static function slow(string $text, int $width = 80): self
{
return new self($text, $width, 0.5, true, true);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
use App\Framework\Console\Animation\EasingFunction;
/**
* Pulse animation for pulsing effects (e.g., highlighting)
* Uses sine wave for continuous pulsing
*/
final class PulseAnimation extends BaseAnimation
{
private float $scaleStart = 1.0;
private float $scaleEnd = 1.2;
private float $pulseSpeed = 2.0;
private float $currentScale = 1.0;
public function __construct(
float $duration = 1.0,
float $delay = 0.0,
bool $loop = true,
float $scaleStart = 1.0,
float $scaleEnd = 1.2,
float $pulseSpeed = 2.0
) {
parent::__construct($duration, $delay, $loop);
$this->scaleStart = $scaleStart;
$this->scaleEnd = $scaleEnd;
$this->pulseSpeed = $pulseSpeed;
$this->currentScale = $this->scaleStart;
}
protected function updateAnimation(float $progress): void
{
// Use sine wave for smooth pulsing
$sineValue = sin($progress * M_PI * 2.0 * $this->pulseSpeed);
// Normalize sine from [-1, 1] to [0, 1]
$normalized = ($sineValue + 1.0) / 2.0;
// Interpolate between start and end scale
$this->currentScale = $this->scaleStart + ($this->scaleEnd - $this->scaleStart) * $normalized;
}
public function getCurrentValue(): float
{
return $this->currentScale;
}
/**
* Get current scale factor
*/
public function getScale(): float
{
return $this->currentScale;
}
/**
* Create a gentle pulse animation
*/
public static function gentle(float $duration = 2.0): self
{
return new self($duration, 0.0, true, 1.0, 1.1, 1.0);
}
/**
* Create a strong pulse animation
*/
public static function strong(float $duration = 1.0): self
{
return new self($duration, 0.0, true, 1.0, 1.5, 2.0);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
use App\Framework\Console\Animation\EasingFunction;
/**
* Slide animation for sliding elements in different directions
*/
final class SlideAnimation extends BaseAnimation
{
private SlideDirection $direction;
private int $startPosition = 0;
private int $endPosition = 0;
private int $distance = 0;
private int $currentPosition = 0;
private EasingFunction $easing;
public function __construct(
SlideDirection $direction,
int $distance,
float $duration = 1.0,
float $delay = 0.0,
bool $loop = false,
int $startPosition = 0,
EasingFunction $easing = EasingFunction::EASE_OUT
) {
parent::__construct($duration, $delay, $loop);
$this->direction = $direction;
$this->distance = $distance;
$this->startPosition = $startPosition;
$this->endPosition = $startPosition + $distance;
$this->easing = $easing;
$this->currentPosition = $this->startPosition;
}
protected function updateAnimation(float $progress): void
{
$easedProgress = $this->easing->apply($progress);
$this->currentPosition = (int) round($this->startPosition + ($this->endPosition - $this->startPosition) * $easedProgress);
}
public function getCurrentValue(): int
{
return $this->currentPosition;
}
/**
* Get current position
*/
public function getPosition(): int
{
return $this->currentPosition;
}
/**
* Get slide direction
*/
public function getDirection(): SlideDirection
{
return $this->direction;
}
/**
* Create slide-in from left
*/
public static function slideInFromLeft(int $distance, float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_OUT): self
{
return new self(SlideDirection::LEFT, $distance, $duration, 0.0, false, -$distance, $easing);
}
/**
* Create slide-in from right
*/
public static function slideInFromRight(int $distance, float $duration = 1.0, EasingFunction $easing = EasingFunction::EASE_OUT): self
{
return new self(SlideDirection::RIGHT, $distance, $duration, 0.0, false, $distance, $easing);
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
use App\Framework\Console\Animation\EasingFunction;
use App\Framework\Console\SpinnerStyle;
/**
* Spinner animation that extends the animation system
* Compatible with existing Spinner API
*/
final class SpinnerAnimation extends BaseAnimation
{
/**
* @var string[]
*/
private array $frames = [];
private int $currentFrameIndex = 0;
private string $message = '';
private float $frameInterval = 0.1;
private float $frameAccumulator = 0.0;
public function __construct(
array|SpinnerStyle $frames,
string $message = 'Loading...',
float $frameInterval = 0.1,
bool $loop = true
) {
parent::__construct(1.0, 0.0, $loop);
// Handle SpinnerStyle enum
if ($frames instanceof SpinnerStyle) {
$this->frames = $frames->getFrames();
} else {
$this->frames = $frames;
}
if (empty($this->frames)) {
throw new \InvalidArgumentException('SpinnerAnimation requires at least one frame');
}
$this->message = $message;
$this->frameInterval = max(0.01, $frameInterval);
$this->currentFrameIndex = 0;
$this->frameAccumulator = 0.0;
}
protected function updateAnimation(float $progress): void
{
// Calculate frame based on progress and frame interval
$frameCount = count($this->frames);
$totalFrames = $this->duration / $this->frameInterval;
$currentFrame = (int) floor($progress * $totalFrames) % $frameCount;
$this->currentFrameIndex = $currentFrame;
}
public function getCurrentValue(): string
{
if (empty($this->frames)) {
return '';
}
return $this->frames[$this->currentFrameIndex];
}
/**
* Get current frame
*/
public function getCurrentFrame(): string
{
return $this->getCurrentValue();
}
/**
* Get message
*/
public function getMessage(): string
{
return $this->message;
}
/**
* Set message
*/
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
/**
* Get formatted display string (frame + message)
*/
public function getDisplayString(): string
{
$frame = $this->getCurrentFrame();
return $frame . ' ' . $this->message;
}
/**
* Create spinner from SpinnerStyle
*/
public static function fromStyle(
SpinnerStyle $style,
string $message = 'Loading...',
float $frameInterval = 0.1
): self {
return new self($style, $message, $frameInterval, true);
}
/**
* Create custom spinner
*/
public static function custom(
array $frames,
string $message = 'Loading...',
float $frameInterval = 0.1
): self {
return new self($frames, $message, $frameInterval, true);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Animation\Types;
/**
* Typewriter animation for character-by-character text display
*/
final class TypewriterAnimation extends BaseAnimation
{
private string $text = '';
private float $charactersPerSecond = 10.0;
private int $currentPosition = 0;
private string $currentText = '';
public function __construct(
string $text,
float $charactersPerSecond = 10.0,
float $delay = 0.0
) {
$textLength = mb_strlen($text);
$duration = $textLength / max(0.1, $charactersPerSecond);
parent::__construct($duration, $delay, false);
$this->text = $text;
$this->charactersPerSecond = max(0.1, $charactersPerSecond);
$this->currentPosition = 0;
$this->currentText = '';
}
protected function updateAnimation(float $progress): void
{
$textLength = mb_strlen($this->text);
$targetPosition = (int) floor($progress * $textLength);
$this->currentPosition = min($targetPosition, $textLength);
$this->currentText = mb_substr($this->text, 0, $this->currentPosition);
}
public function getCurrentValue(): string
{
return $this->currentText;
}
/**
* Get current displayed text
*/
public function getText(): string
{
return $this->currentText;
}
/**
* Get full text that will be displayed
*/
public function getFullText(): string
{
return $this->text;
}
/**
* Check if animation is complete (all characters displayed)
*/
public function isComplete(): bool
{
return $this->currentPosition >= mb_strlen($this->text);
}
/**
* Create fast typewriter animation
*/
public static function fast(string $text): self
{
return new self($text, 20.0);
}
/**
* Create slow typewriter animation
*/
public static function slow(string $text): self
{
return new self($text, 5.0);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Ansi;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\Terminal\OscMode;
/**
* Centralized ANSI sequence generator for all escape sequences.
* Provides methods for colors, cursor control, screen control, links, and titles.
* Uses existing Enums where possible.
*/
final readonly class AnsiSequenceGenerator
{
/**
* Generate ANSI color sequence
*/
public function color(ConsoleColor $color): string
{
return $color->toAnsi();
}
/**
* Generate ANSI format sequence
*/
public function format(ConsoleFormat $format): string
{
return $format->toAnsi();
}
/**
* Generate ANSI style sequence (color + format + background)
*/
public function style(
?ConsoleColor $color = null,
?ConsoleFormat $format = null,
?ConsoleColor $background = null
): string {
$codes = [];
if ($format !== null) {
$codes[] = $format->value;
}
if ($color !== null) {
$codes[] = $color->value;
}
if ($background !== null) {
$codes[] = $background->value;
}
if (empty($codes)) {
return '';
}
return "\033[" . implode(';', $codes) . 'm';
}
/**
* Generate truecolor (24-bit RGB) foreground color sequence
*/
public function truecolorForeground(int $r, int $g, int $b): string
{
return "\033[38;2;{$r};{$g};{$b}m";
}
/**
* Generate truecolor (24-bit RGB) background color sequence
*/
public function truecolorBackground(int $r, int $g, int $b): string
{
return "\033[48;2;{$r};{$g};{$b}m";
}
/**
* Generate reset sequence
*/
public function reset(): string
{
return ConsoleColor::RESET->toAnsi();
}
/**
* Generate cursor control sequence using CursorControlCode enum
*/
public function cursor(CursorControlCode $code, int ...$params): string
{
return $code->format(...$params);
}
/**
* Generate screen control sequence using ScreenControlCode enum
*/
public function screen(ScreenControlCode $code, int ...$params): string
{
return $code->format(...$params);
}
/**
* Generate screen clear sequence using ScreenControlCode enum
*/
public function clearScreen(): string
{
return ScreenControlCode::CLEAR_ALL->format();
}
/**
* Generate clickable link sequence (OSC 8)
* Format: \e]8;;URL\aText\e]8;;\a
*/
public function link(string $url, string $text): string
{
return "\033]8;;{$url}\033\\{$text}\033]8;;\033\\";
}
/**
* Generate window title sequence (OSC) using OscMode enum
*/
public function title(string $title, OscMode $mode = OscMode::BOTH): string
{
return "\033]{$mode->value};{$title}\007";
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Ansi;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\Terminal\TerminalCapabilities;
/**
* Formatter for clickable links using OSC 8 escape sequences.
* Format: \e]8;;URL\aText\e]8;;\a
*/
final readonly class LinkFormatter
{
public function __construct(
private AnsiSequenceGenerator $ansiGenerator,
private TerminalCapabilities $capabilities
) {
}
/**
* Create a clickable link
* Returns plain text if terminal doesn't support links
*/
public function createLink(string $url, string $text): string
{
if (!$this->capabilities->supportsLinks()) {
return $text;
}
return $this->ansiGenerator->link($url, $text);
}
/**
* Create a clickable link with custom formatting
* Applies style to the link text while maintaining clickability
*/
public function createStyledLink(
string $url,
string $text,
?ConsoleStyle $style = null
): string {
if (!$this->capabilities->supportsLinks()) {
// If no link support, just return styled text
if ($style !== null) {
return $style->apply($text);
}
return $text;
}
// Apply style to text, then wrap in link
$styledText = $style !== null ? $style->apply($text) : $text;
// Create link with styled text
// Note: The link sequence wraps the styled text
return $this->ansiGenerator->link($url, $styledText);
}
/**
* Create a clickable file link (file:// protocol)
*/
public function createFileLink(string $filePath, string $text): string
{
$url = 'file://' . $filePath;
return $this->createLink($url, $text);
}
/**
* Create a clickable file link with line number for PhpStorm terminals
*
* Format: \e]8;;file://$file#$line\e\\$file:$line\e]8;;\e\\
* This format is recognized by PhpStorm and other JetBrains IDEs.
*
* @param string $filePath Absolute or relative file path
* @param int $line Line number (1-based)
* @param string|null $text Optional display text (defaults to "$filePath:$line")
* @return string ANSI-encoded clickable link
*/
public function createFileLinkWithLine(string $filePath, int $line, ?string $text = null): string
{
// Use absolute path for file:// URL
$absolutePath = realpath($filePath);
if ($absolutePath === false) {
$absolutePath = $filePath;
}
// Normalize path separators for file:// URLs
$normalizedPath = str_replace('\\', '/', $absolutePath);
// Create file:// URL with line number anchor
$url = 'file://' . $normalizedPath . '#' . $line;
// Default text is file:line format
$displayText = $text ?? ($filePath . ':' . $line);
return $this->createLink($url, $displayText);
}
/**
* Create a clickable HTTP/HTTPS link
*/
public function createHttpLink(string $url, string $text): string
{
// Ensure URL has protocol
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
$url = 'https://' . $url;
}
return $this->createLink($url, $text);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Ansi;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\Terminal\TerminalCapabilities;
/**
* RGB Color Value Object for truecolor support
*/
final readonly class RgbColor
{
public function __construct(
public int $r,
public int $g,
public int $b
) {
// Validate RGB values (0-255)
if ($r < 0 || $r > 255 || $g < 0 || $g > 255 || $b < 0 || $b > 255) {
throw new \InvalidArgumentException('RGB values must be between 0 and 255');
}
}
/**
* Create from hex color string (e.g., "#FF5733" or "FF5733")
*/
public static function fromHex(string $hex): self
{
$hex = ltrim($hex, '#');
if (strlen($hex) !== 6 || !ctype_xdigit($hex)) {
throw new \InvalidArgumentException('Invalid hex color format');
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return new self($r, $g, $b);
}
/**
* Convert to hex string
*/
public function toHex(): string
{
return sprintf('#%02X%02X%02X', $this->r, $this->g, $this->b);
}
/**
* Get ANSI sequence for this color (foreground)
*/
public function toAnsiForeground(AnsiSequenceGenerator $generator, TerminalCapabilities $capabilities): string
{
if ($capabilities->supportsTruecolor()) {
return $generator->truecolorForeground($this->r, $this->g, $this->b);
}
// Fallback to nearest 256-color palette or standard color
return $this->toNearestColor()->toAnsi();
}
/**
* Get ANSI sequence for this color (background)
*/
public function toAnsiBackground(AnsiSequenceGenerator $generator, TerminalCapabilities $capabilities): string
{
if ($capabilities->supportsTruecolor()) {
return $generator->truecolorBackground($this->r, $this->g, $this->b);
}
// Fallback to nearest 256-color palette or standard color
return $this->toNearestBackgroundColor()->toAnsi();
}
/**
* Convert to nearest standard ConsoleColor
*/
public function toNearestColor(): ConsoleColor
{
// Simple conversion: find closest standard color
// This is a basic implementation - could be improved with better color distance calculation
$colors = [
ConsoleColor::BLACK,
ConsoleColor::RED,
ConsoleColor::GREEN,
ConsoleColor::YELLOW,
ConsoleColor::BLUE,
ConsoleColor::MAGENTA,
ConsoleColor::CYAN,
ConsoleColor::WHITE,
];
$minDistance = PHP_INT_MAX;
$nearest = ConsoleColor::WHITE;
foreach ($colors as $color) {
$distance = $this->colorDistance($color);
if ($distance < $minDistance) {
$minDistance = $distance;
$nearest = $color;
}
}
return $nearest;
}
/**
* Convert to nearest background ConsoleColor
*/
public function toNearestBackgroundColor(): ConsoleColor
{
// Similar to toNearestColor but for background colors
$bgColors = [
ConsoleColor::BG_BLACK,
ConsoleColor::BG_RED,
ConsoleColor::BG_GREEN,
ConsoleColor::BG_YELLOW,
ConsoleColor::BG_BLUE,
ConsoleColor::BG_MAGENTA,
ConsoleColor::BG_CYAN,
ConsoleColor::BG_WHITE,
];
$minDistance = PHP_INT_MAX;
$nearest = ConsoleColor::BG_WHITE;
foreach ($bgColors as $color) {
$distance = $this->colorDistance($color);
if ($distance < $minDistance) {
$minDistance = $distance;
$nearest = $color;
}
}
return $nearest;
}
/**
* Calculate color distance (simple Euclidean distance)
*/
private function colorDistance(ConsoleColor $color): float
{
// Map standard colors to RGB values
$colorMap = [
ConsoleColor::BLACK => [0, 0, 0],
ConsoleColor::RED => [255, 0, 0],
ConsoleColor::GREEN => [0, 255, 0],
ConsoleColor::YELLOW => [255, 255, 0],
ConsoleColor::BLUE => [0, 0, 255],
ConsoleColor::MAGENTA => [255, 0, 255],
ConsoleColor::CYAN => [0, 255, 255],
ConsoleColor::WHITE => [255, 255, 255],
ConsoleColor::GRAY => [128, 128, 128],
ConsoleColor::BRIGHT_RED => [255, 85, 85],
ConsoleColor::BRIGHT_GREEN => [85, 255, 85],
ConsoleColor::BRIGHT_YELLOW => [255, 255, 85],
ConsoleColor::BRIGHT_BLUE => [85, 85, 255],
ConsoleColor::BRIGHT_MAGENTA => [255, 85, 255],
ConsoleColor::BRIGHT_CYAN => [85, 255, 255],
ConsoleColor::BRIGHT_WHITE => [255, 255, 255],
];
$bgColorMap = [
ConsoleColor::BG_BLACK => [0, 0, 0],
ConsoleColor::BG_RED => [255, 0, 0],
ConsoleColor::BG_GREEN => [0, 255, 0],
ConsoleColor::BG_YELLOW => [255, 255, 0],
ConsoleColor::BG_BLUE => [0, 0, 255],
ConsoleColor::BG_MAGENTA => [255, 0, 255],
ConsoleColor::BG_CYAN => [0, 255, 255],
ConsoleColor::BG_WHITE => [255, 255, 255],
];
$allColors = array_merge($colorMap, $bgColorMap);
$rgb = $allColors[$color] ?? [128, 128, 128];
$dr = $this->r - $rgb[0];
$dg = $this->g - $rgb[1];
$db = $this->b - $rgb[2];
return sqrt($dr * $dr + $dg * $dg + $db * $db);
}
}

View File

@@ -2,15 +2,19 @@
namespace App\Framework\Console\Ansi;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\Terminal\TerminalCapabilities;
/**
* Truecolor Style Value Object
*/
final readonly class TruecolorStyle
{
public function __construct(
private ?RgbColor $foreground = null,
private ?RgbColor $background = null,
private ?\App\Framework\Console\ConsoleFormat $format = null
private ?RgbColor $foreground = null,
private ?RgbColor $background = null,
private ?ConsoleFormat $format = null
) {}
/**
@@ -19,7 +23,7 @@ final readonly class TruecolorStyle
public static function create(
?RgbColor $foreground = null,
?RgbColor $background = null,
?\App\Framework\Console\ConsoleFormat $format = null
?ConsoleFormat $format = null
): self
{
return new self($foreground, $background, $format);

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Console\Exceptions\CommandNotFoundException;
use App\Framework\Console\Exceptions\DuplicateCommandException;
use ArrayIterator;
use Countable;
use IteratorAggregate;
@@ -26,10 +26,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
foreach ($commands as $command) {
if (isset($commandMap[$command->name])) {
throw FrameworkException::create(
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Duplicate command name '{$command->name}'"
)->withData(['command_name' => $command->name]);
throw new DuplicateCommandException($command->name);
}
$commandMap[$command->name] = $command;
@@ -46,10 +43,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
public function add(ConsoleCommand $command): self
{
if ($this->has($command->name)) {
throw FrameworkException::create(
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Command '{$command->name}' already exists"
)->withData(['command_name' => $command->name]);
throw new DuplicateCommandException($command->name);
}
$allCommands = array_values($this->commands);
@@ -66,10 +60,7 @@ final readonly class CommandList implements IteratorAggregate, Countable
public function get(string $name): ConsoleCommand
{
if (! $this->has($name)) {
throw FrameworkException::create(
ConsoleErrorCode::COMMAND_NOT_FOUND,
"Command '{$name}' not found"
)->withData(['command_name' => $name]);
throw new CommandNotFoundException($name);
}
return $this->commands[$name];

View File

@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Exceptions\CommandExecutionException;
use App\Framework\Console\Exceptions\CommandMetadataNotFoundException;
use App\Framework\Console\Exceptions\InvalidCommandConfigurationException;
use App\Framework\Console\Performance\ConsolePerformanceCollector;
use App\Framework\Console\Progress\ProgressMiddleware;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
@@ -52,10 +53,7 @@ final readonly class CommandRegistry
public function getDiscoveredAttribute(string $commandName): DiscoveredAttribute
{
if (! isset($this->discoveredAttributes[$commandName])) {
throw FrameworkException::create(
ConsoleErrorCode::COMMAND_NOT_FOUND,
"No discovered attribute found for command '{$commandName}'"
)->withData(['command_name' => $commandName]);
throw new CommandMetadataNotFoundException($commandName);
}
return $this->discoveredAttributes[$commandName];
@@ -99,14 +97,7 @@ final readonly class CommandRegistry
// Validate command structure
if (! is_object($instance) || ! method_exists($instance, $methodName)) {
throw FrameworkException::create(
ConsoleErrorCode::INVALID_COMMAND_STRUCTURE,
"Invalid command configuration for '{$commandName}'"
)->withData([
'command_name' => $commandName,
'class_name' => $className,
'method_name' => $methodName,
]);
throw new InvalidCommandConfigurationException($commandName, $className, $methodName);
}
// Get reflection method for parameter resolution
@@ -127,14 +118,7 @@ final readonly class CommandRegistry
return $this->normalizeCommandResult($result);
} catch (Throwable $e) {
throw FrameworkException::create(
ConsoleErrorCode::EXECUTION_FAILED,
"Failed to execute command '{$commandName}': {$e->getMessage()}"
)->withData([
'command_name' => $commandName,
'error_message' => $e->getMessage(),
'error_type' => get_class($e),
]);
throw new CommandExecutionException($commandName, $e);
}
}
@@ -155,15 +139,27 @@ final readonly class CommandRegistry
private function discoverCommands(DiscoveryRegistry $discoveryRegistry): void
{
/** @var array<string, ConsoleCommand> $commands */
$commands = [];
$discoveredAttributes = [];
$duplicateCommands = [];
/** @var DiscoveredAttribute $discoveredAttribute */
foreach ($discoveryRegistry->attributes->get(ConsoleCommand::class) as $discoveredAttribute) {
try {
$registeredCommand = $this->registerDiscoveredCommand($discoveredAttribute);
$commands[] = $registeredCommand;
$discoveredAttributes[$registeredCommand->name] = $discoveredAttribute;
$commandName = $registeredCommand->name;
if (isset($commands[$commandName])) {
$original = $discoveredAttributes[$commandName]->className->getFullyQualified();
$duplicateCommands[$commandName] ??= [$original];
$duplicateCommands[$commandName][] = $discoveredAttribute->className->getFullyQualified();
continue;
}
$commands[$commandName] = $registeredCommand;
$discoveredAttributes[$commandName] = $discoveredAttribute;
} catch (Throwable $e) {
// Log warning but continue with other commands
@@ -179,7 +175,25 @@ final readonly class CommandRegistry
}
}
$this->commandList = new CommandList(...$commands);
if ($duplicateCommands !== []) {
$duplicateSummary = [];
foreach ($duplicateCommands as $name => $classes) {
$duplicateSummary[] = "{$name}: " . implode(', ', array_unique($classes));
}
$message = "Duplizierte Console-Kommandos wurden ignoriert:\n - " . implode("\n - ", $duplicateSummary);
error_log($message);
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('Duplicate console commands detected', LogContext::withData([
'duplicates' => $duplicateCommands,
'component' => 'CommandRegistry',
]));
}
}
$this->commandList = new CommandList(...array_values($commands));
$this->discoveredAttributes = $discoveredAttributes;
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Logging\Logger;
use App\Framework\Quality\CodeQuality\CodeQualityScanner;
use App\Framework\Quality\CodeQuality\Results\CodeQualityReport;
use App\Framework\Quality\CodeQuality\Rules\DisallowInheritanceRule;
use App\Framework\Quality\CodeQuality\Rules\FinalClassRule;
use App\Framework\Quality\CodeQuality\Rules\ReadonlyGetterRule;
use App\Framework\Quality\CodeQuality\Rules\ReadonlyPropertyRule;
use App\Framework\Quality\CodeQuality\Rules\RequireStringableImplementationRule;
use App\Framework\Reflection\ReflectionService;
#[CommandGroup(
name: 'Quality',
description: 'Code quality inspections and automated rule checks',
icon: '🧹',
priority: 80
)]
final readonly class CodeQualityScanCommand
{
private CodeQualityScanner $scanner;
public function __construct(
FileScanner $fileScanner,
ReflectionService $reflectionService,
?Logger $logger = null
) {
$this->scanner = new CodeQualityScanner(
$fileScanner,
$reflectionService,
[
new RequireStringableImplementationRule(),
new FinalClassRule(),
new ReadonlyPropertyRule(),
new ReadonlyGetterRule(),
new DisallowInheritanceRule(),
],
$logger
);
}
#[ConsoleCommand('quality:scan', 'Check framework classes against predefined rules')]
public function scan(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$pathArgument = $input->getArgument(0);
$directory = $pathArgument !== null
? FilePath::create($pathArgument)
: FilePath::create(getcwd() . '/src');
$skipErrors = $input->hasOption('skip-errors');
if (! $directory->exists() || ! $directory->isDirectory()) {
$output->writeLine(
sprintf('❌ Directory "%s" does not exist or is not a directory.', $directory->toString()),
ConsoleColor::RED
);
return ExitCode::INVALID_INPUT;
}
$output->writeLine(
sprintf('🔍 Scanning %s for code quality violations...', $directory->toString()),
ConsoleColor::BRIGHT_CYAN
);
$report = $this->scanner->scan($directory, $skipErrors);
if ($report->hasErrors()) {
$output->newLine();
$headerColor = $skipErrors ? ConsoleColor::YELLOW : ConsoleColor::BRIGHT_RED;
$headerIcon = $skipErrors ? '⚠️' : '❌';
$output->writeLine(
sprintf(
'%s Encountered %d file/class error(s) while scanning.',
$headerIcon,
count($report->errors())
),
$headerColor
);
foreach ($report->errors() as $error) {
$output->writeLine(' • ' . $error, $headerColor);
}
if (! $skipErrors) {
return ExitCode::FAILURE;
}
}
$this->renderReport($report, $output);
return $report->hasViolations() ? ExitCode::FAILURE : ExitCode::SUCCESS;
}
private function renderReport(CodeQualityReport $report, ConsoleOutputInterface $output): void
{
$output->newLine();
$summaryColor = $report->hasViolations() ? ConsoleColor::BRIGHT_RED : ConsoleColor::GREEN;
$summaryIcon = $report->hasViolations() ? '❌' : '✅';
$summaryMessage = $report->hasViolations()
? sprintf('Found %d violation(s).', $report->countViolations())
: 'No violations found.';
$output->writeLine(
sprintf(
'%s %s Scanned %d files, %d classes in %.2fs',
$summaryIcon,
$summaryMessage,
$report->inspectedFiles(),
$report->inspectedClasses(),
$report->durationSeconds()
),
$summaryColor
);
if (! $report->hasViolations()) {
return;
}
$output->newLine();
foreach ($report->groupedByRule() as $ruleName => $violations) {
$output->writeLine(
sprintf('%s (%d)', $ruleName, count($violations)),
ConsoleColor::BRIGHT_YELLOW
);
foreach ($violations as $violation) {
$lineInfo = $violation->line() !== null ? ':' . $violation->line() : '';
$output->writeLine(
sprintf(
' • %s',
$violation->className()->getFullyQualified()
),
ConsoleColor::WHITE
);
$output->writeLine(
sprintf(
' ↳ %s%s',
$violation->filePath()->toString(),
$lineInfo
),
ConsoleColor::GRAY
);
$output->writeLine(
sprintf(' %s', $violation->message()),
ConsoleColor::BRIGHT_RED
);
$output->newLine();
}
}
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Commands;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Database\Browser\Registry\DatabaseRegistry;
use App\Framework\Database\Browser\Registry\TableRegistry;
use App\Framework\Display\Components\Console\Table;
final readonly class DatabaseBrowserCommand
{
public function __construct(
private DatabaseRegistry $databaseRegistry,
private TableRegistry $tableRegistry,
) {
}
#[ConsoleCommand('db:browse', 'Browse database tables and schema')]
public function browse(
ConsoleInput $input,
ConsoleOutput $output,
?string $table = null,
?string $format = null,
bool $compact = false,
bool $verbose = false
): int {
$format = $format ?? 'table';
try {
if ($table !== null) {
return $this->showTableDetails($output, $table, $format, $compact, $verbose);
}
return $this->showTablesList($output, $format, $compact);
} catch (\Exception $e) {
$output->writeError("Error: {$e->getMessage()}");
return 1;
}
}
private function showTablesList(ConsoleOutput $output, string $format, bool $compact): int
{
$database = $this->databaseRegistry->getCurrentDatabase();
$tables = $this->tableRegistry->getAllTables();
if ($format === 'json') {
$output->writeLine(json_encode([
'database' => $database->toArray(),
'tables' => array_map(fn ($t) => $t->toArray(), $tables),
], JSON_PRETTY_PRINT));
return 0;
}
// Show database info
$output->writeLine("Database: {$database->name}", ConsoleStyle::create(
color: ConsoleColor::BRIGHT_CYAN,
format: \App\Framework\Console\ConsoleFormat::BOLD
));
$output->newLine();
// Create table
$table = new Table(
headerStyle: ConsoleStyle::create(
color: ConsoleColor::BRIGHT_WHITE,
format: \App\Framework\Console\ConsoleFormat::BOLD
),
borderStyle: ConsoleStyle::create(color: ConsoleColor::GRAY)
);
if ($compact) {
$table->setHeaders(['Name', 'Rows', 'Size (MB)']);
foreach ($tables as $t) {
$table->addRow([
$t->name,
$t->rowCount !== null ? number_format($t->rowCount) : 'N/A',
$t->sizeMb !== null ? number_format($t->sizeMb, 2) : 'N/A',
]);
}
} else {
$table->setHeaders(['Name', 'Rows', 'Size (MB)', 'Engine', 'Collation']);
foreach ($tables as $t) {
$table->addRow([
$t->name,
$t->rowCount !== null ? number_format($t->rowCount) : 'N/A',
$t->sizeMb !== null ? number_format($t->sizeMb, 2) : 'N/A',
$t->engine ?? 'N/A',
$t->collation ?? 'N/A',
]);
}
}
$output->write($table->render());
$output->newLine();
$output->writeLine("Total tables: " . count($tables), ConsoleStyle::create(color: ConsoleColor::GRAY));
return 0;
}
private function showTableDetails(
ConsoleOutput $output,
string $tableName,
string $format,
bool $compact,
bool $verbose
): int {
$tableSchema = $this->tableRegistry->getTableSchema($tableName);
if ($tableSchema === null) {
$output->writeError("Table '{$tableName}' not found");
return 1;
}
if ($format === 'json') {
$output->writeLine(json_encode($tableSchema->toArray(), JSON_PRETTY_PRINT));
return 0;
}
// Show table info
$output->writeLine("Table: {$tableSchema->name}", ConsoleStyle::create(
color: ConsoleColor::BRIGHT_CYAN,
format: \App\Framework\Console\ConsoleFormat::BOLD
));
$output->writeLine("Rows: " . ($tableSchema->rowCount !== null ? number_format($tableSchema->rowCount) : 'N/A'));
$output->writeLine("Size: " . ($tableSchema->sizeMb !== null ? number_format($tableSchema->sizeMb, 2) . ' MB' : 'N/A'));
$output->writeLine("Engine: " . ($tableSchema->engine ?? 'N/A'));
$output->newLine();
// Show columns
$output->writeLine("Columns:", ConsoleStyle::create(
color: ConsoleColor::BRIGHT_YELLOW,
format: \App\Framework\Console\ConsoleFormat::BOLD
));
$columnsTable = new Table(
headerStyle: ConsoleStyle::create(
color: ConsoleColor::BRIGHT_WHITE,
format: \App\Framework\Console\ConsoleFormat::BOLD
),
borderStyle: ConsoleStyle::create(color: ConsoleColor::GRAY)
);
if ($compact) {
$columnsTable->setHeaders(['Name', 'Type', 'Nullable', 'Key']);
foreach ($tableSchema->columns as $column) {
$key = $column->key ?? '';
$keyColor = match ($key) {
'PRI' => ConsoleColor::BRIGHT_GREEN,
'UNI' => ConsoleColor::BRIGHT_YELLOW,
'MUL' => ConsoleColor::YELLOW,
default => ConsoleColor::GRAY,
};
$columnsTable->addRow([
$column->name,
$column->type,
$column->nullable ? 'Yes' : 'No',
$key !== '' ? $key : '-',
]);
}
} else {
$columnsTable->setHeaders(['Name', 'Type', 'Nullable', 'Default', 'Key', 'Extra']);
foreach ($tableSchema->columns as $column) {
$columnsTable->addRow([
$column->name,
$column->type,
$column->nullable ? 'Yes' : 'No',
$column->default ?? '-',
$column->key ?? '-',
$column->extra ?? '-',
]);
}
}
$output->write($columnsTable->render());
$output->newLine();
// Show indexes if verbose
if ($verbose && !empty($tableSchema->indexes)) {
$output->writeLine("Indexes:", ConsoleStyle::create(
color: ConsoleColor::BRIGHT_YELLOW,
format: \App\Framework\Console\ConsoleFormat::BOLD
));
$indexesTable = new Table(
headerStyle: ConsoleStyle::create(
color: ConsoleColor::BRIGHT_WHITE,
format: \App\Framework\Console\ConsoleFormat::BOLD
),
borderStyle: ConsoleStyle::create(color: ConsoleColor::GRAY)
);
$indexesTable->setHeaders(['Name', 'Columns', 'Unique', 'Type']);
foreach ($tableSchema->indexes as $index) {
$indexesTable->addRow([
$index->name,
implode(', ', $index->columns),
$index->unique ? 'Yes' : 'No',
$index->type ?? '-',
]);
}
$output->write($indexesTable->render());
$output->newLine();
}
// Show foreign keys if verbose
if ($verbose && !empty($tableSchema->foreignKeys)) {
$output->writeLine("Foreign Keys:", ConsoleStyle::create(
color: ConsoleColor::BRIGHT_YELLOW,
format: \App\Framework\Console\ConsoleFormat::BOLD
));
$fkTable = new Table(
headerStyle: ConsoleStyle::create(
color: ConsoleColor::BRIGHT_WHITE,
format: \App\Framework\Console\ConsoleFormat::BOLD
),
borderStyle: ConsoleStyle::create(color: ConsoleColor::GRAY)
);
$fkTable->setHeaders(['Name', 'Column', 'Referenced Table', 'Referenced Column']);
foreach ($tableSchema->foreignKeys as $fk) {
$fkTable->addRow([
$fk->name,
$fk->column,
$fk->referencedTable,
$fk->referencedColumn,
]);
}
$output->write($fkTable->render());
}
return 0;
}
}

View File

@@ -68,20 +68,38 @@ final readonly class HotReloadCommand
});
// Handle signals for graceful shutdown
if (extension_loaded('pcntl')) {
pcntl_signal(SIGINT, function () use ($hotReloadServer, $output) {
try {
$pcntlService = $container->get(\App\Framework\Pcntl\PcntlService::class);
$pcntlService->registerSignal(\App\Framework\Pcntl\ValueObjects\Signal::SIGINT, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received interrupt signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
pcntl_signal(SIGTERM, function () use ($hotReloadServer, $output) {
$pcntlService->registerSignal(\App\Framework\Pcntl\ValueObjects\Signal::SIGTERM, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received termination signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
} catch (\Throwable $e) {
// Fallback to direct pcntl_signal if PCNTL service not available
if (extension_loaded('pcntl')) {
pcntl_signal(SIGINT, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received interrupt signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
pcntl_signal(SIGTERM, function () use ($hotReloadServer, $output) {
$output->writeln('');
$output->info('Received termination signal, shutting down...');
$hotReloadServer->stop();
exit(0);
});
}
}
$output->success('Hot Reload server is running!');
@@ -104,8 +122,14 @@ final readonly class HotReloadCommand
sleep(1);
// Process signals if available
if (extension_loaded('pcntl')) {
pcntl_signal_dispatch();
try {
$pcntlService = $container->get(\App\Framework\Pcntl\PcntlService::class);
$pcntlService->dispatchSignals();
} catch (\Throwable $e) {
// Fallback to direct dispatch if PCNTL service not available
if (extension_loaded('pcntl')) {
pcntl_signal_dispatch();
}
}
}

View File

@@ -152,8 +152,14 @@ final readonly class ConsoleDialog
while ($running) {
// Handle signals
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
try {
$pcntlService = $this->container->get(\App\Framework\Pcntl\PcntlService::class);
$pcntlService->dispatchSignals();
} catch (\Throwable $e) {
// Fallback to direct dispatch if PCNTL service not available
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
}
// Read input

View File

@@ -14,16 +14,28 @@ use App\Framework\Console\Screen\MouseControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\SimpleWorkflowExecutor;
use App\Framework\Console\TuiView;
use App\Framework\Console\Animation\AnimationManager;
use App\Framework\Console\Components\EventLoop\EventLoop;
use App\Framework\Console\Components\EventLoop\EventLoopConfig;
use App\Framework\Console\Components\Handlers\KeyboardEventHandler;
use App\Framework\Console\Components\Handlers\MouseEventHandler;
use App\Framework\DI\Container;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Pcntl\PcntlService;
use App\Framework\Pcntl\ValueObjects\Signal;
use Throwable;
/**
* Main TUI orchestrator - coordinates specialized components
* Refactored to follow Single Responsibility Principle
* Refactored to use EventLoop for stable event handling
*/
final readonly class ConsoleTUI
{
private InputParser $inputParser;
private EventLoop $eventLoop;
private MouseEventHandler $mouseHandler;
private KeyboardEventHandler $keyboardHandler;
private AnimationManager $animationManager;
public function __construct(
private ConsoleOutputInterface $output,
@@ -31,13 +43,43 @@ final readonly class ConsoleTUI
private DiscoveryRegistry $discoveryRegistry,
private TuiState $state,
private TuiRenderer $renderer,
private TuiInputHandler $inputHandler,
private TuiCommandExecutor $commandExecutor,
private CommandHistory $commandHistory,
private CommandGroupRegistry $groupRegistry,
private SimpleWorkflowExecutor $workflowExecutor
) {
$this->inputParser = new InputParser();
$eventBuffer = new EventBuffer();
$this->inputParser = new InputParser($eventBuffer);
// Create animation manager
$this->animationManager = new AnimationManager();
// Initialize renderer with animation manager if it doesn't have one
// Note: We can't replace the readonly renderer, but we can ensure it has animation support
// The renderer will use the animation manager if provided in constructor
// If renderer was created without animation manager, animations won't work for that renderer instance
// In that case, animations will still work via the animation manager directly
// Create event loop with animation manager
$config = new EventLoopConfig(
enableSignalDispatch: true,
minRenderInterval: 0.016, // ~60 FPS max
errorHandler: function (Throwable $e) {
// Log error but don't break loop
error_log("TUI Error: " . $e->getMessage());
}
);
$this->eventLoop = new EventLoop($eventBuffer, $config, $this->animationManager);
// Create specialized handlers
$this->mouseHandler = new MouseEventHandler(
$this->renderer->getMenuBar(),
$this->commandExecutor
);
$this->keyboardHandler = new KeyboardEventHandler(
$this->commandExecutor,
$this->renderer->getMenuBar()
);
}
/**
@@ -47,7 +89,7 @@ final readonly class ConsoleTUI
{
try {
$this->initialize();
$this->mainLoop();
$this->runEventLoop();
} finally {
// Always cleanup, even on interrupt or error
$this->cleanup();
@@ -75,51 +117,109 @@ final readonly class ConsoleTUI
*/
private function setupSignalHandlers(): void
{
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGINT, [$this, 'handleShutdownSignal']);
pcntl_signal(SIGTERM, [$this, 'handleShutdownSignal']);
try {
$pcntlService = $this->container->get(PcntlService::class);
$pcntlService->registerSignal(Signal::SIGINT, function (Signal $signal) {
$this->handleShutdownSignal($signal);
});
$pcntlService->registerSignal(Signal::SIGTERM, function (Signal $signal) {
$this->handleShutdownSignal($signal);
});
} catch (\Throwable $e) {
// Fallback to direct pcntl_signal if PCNTL service not available
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGINT, [$this, 'handleShutdownSignalLegacy']);
pcntl_signal(SIGTERM, [$this, 'handleShutdownSignalLegacy']);
}
}
}
/**
* Handle shutdown signals (SIGINT, SIGTERM)
*/
public function handleShutdownSignal(int $signal): void
public function handleShutdownSignal(Signal $signal): void
{
$this->state->setRunning(false);
// Cleanup will be called in run() method's finally block or in cleanup()
}
/**
* Main event loop
* Legacy signal handler for when PCNTL service is not available
*/
private function mainLoop(): void
public function handleShutdownSignalLegacy(int $signal): void
{
$this->state->setRunning(false);
}
/**
* Run the event loop using EventLoop service
*/
private function runEventLoop(): void
{
$needsRender = true;
$errorCount = 0;
$lastRenderTime = 0.0;
while ($this->state->isRunning()) {
// Dispatch any pending signals (for graceful shutdown)
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
try {
// Dispatch signals
try {
$pcntlService = $this->container->get(PcntlService::class);
$pcntlService->dispatchSignals();
} catch (\Throwable $e) {
// Fallback to direct dispatch if PCNTL service not available
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
}
// Only render when needed
if ($needsRender) {
$this->renderCurrentView();
$needsRender = false;
}
// Read new events from parser and add to buffer
$this->eventLoop->readAndBufferEvents(fn() => $this->inputParser->readEvent());
// Handle input (non-blocking)
$event = $this->inputParser->readEvent();
if ($event === null) {
// No input available, sleep briefly to reduce CPU usage
usleep(50000); // 50ms
continue;
}
// Update animations
$this->animationManager->update(0.016); // ~60 FPS
// Process event and mark for re-render
$needsRender = $this->processEvent($event);
// Process events from buffer
$eventsProcessed = $this->eventLoop->processEvents(function (MouseEvent|KeyEvent $event) use (&$needsRender) {
$needsRender = $this->processEvent($event) || $needsRender;
});
if ($eventsProcessed > 0) {
$needsRender = true;
}
// Render if needed (with throttling)
if ($needsRender) {
$currentTime = microtime(true);
$timeSinceLastRender = $currentTime - $lastRenderTime;
if ($timeSinceLastRender >= 0.016) { // ~60 FPS max
$this->renderCurrentView();
$lastRenderTime = $currentTime;
$needsRender = false;
}
}
// Reset error count on successful iteration
$errorCount = 0;
// Sleep if no events processed to reduce CPU usage
if ($eventsProcessed === 0) {
usleep(5000); // 5ms
}
} catch (Throwable $e) {
$errorCount++;
// Log error
error_log("TUI Error: " . $e->getMessage());
// If too many errors, break loop
if ($errorCount >= 10) {
break;
}
// Brief pause before retry
usleep(10000); // 10ms
}
}
}
@@ -134,20 +234,11 @@ final readonly class ConsoleTUI
return true;
}
// Dispatch event to input handler
// Dispatch event to appropriate handler
if ($event instanceof MouseEvent) {
$needsRender = $this->inputHandler->handleMouseEvent($event, $this->state, $this->commandHistory);
return $needsRender; // Only re-render if handler indicates it's needed
return $this->mouseHandler->handle($event, $this->state, $this->commandHistory);
} elseif ($event instanceof KeyEvent) {
// Handle Ctrl+C
if ($event->ctrl && $event->key === 'C') {
$this->state->setRunning(false);
return false;
}
// Use new KeyEvent handler (supports Alt+Letter shortcuts)
$this->inputHandler->handleKeyEvent($event, $this->state, $this->commandHistory);
return true; // Key events always need re-render
return $this->keyboardHandler->handle($event, $this->state, $this->commandHistory);
}
return false;
@@ -172,7 +263,6 @@ final readonly class ConsoleTUI
private function flushRemainingInput(): void
{
// First, disable mouse tracking immediately to stop new events
// Send disable commands multiple times to ensure they're processed
for ($i = 0; $i < 3; $i++) {
$this->output->write(MouseControlCode::DISABLE_MOVE->format());
$this->output->write(MouseControlCode::DISABLE_SGR->format());
@@ -194,7 +284,7 @@ final readonly class ConsoleTUI
try {
// Aggressively read and discard ALL remaining input
$totalDiscarded = 0;
$maxAttempts = 200; // Read up to 200 characters
$maxAttempts = 200;
$attempt = 0;
while ($attempt < $maxAttempts && $totalDiscarded < 1000) {
@@ -202,20 +292,17 @@ final readonly class ConsoleTUI
$write = null;
$except = null;
// Very short timeout to quickly drain buffer
$result = stream_select($read, $write, $except, 0, 5000); // 5ms
if ($result === false || $result === 0) {
// No more input immediately available, try a few more times
if ($attempt > 10 && $totalDiscarded === 0) {
break; // No input after multiple attempts
break;
}
$attempt++;
usleep(5000); // 5ms
continue;
}
// Read and discard characters in chunks
$readCount = 0;
while (($char = fgetc(STDIN)) !== false && $readCount < 50) {
$totalDiscarded++;
@@ -228,10 +315,9 @@ final readonly class ConsoleTUI
continue;
}
$attempt = 0; // Reset attempt counter if we read something
$attempt = 0;
}
} finally {
// Restore blocking mode
stream_set_blocking(STDIN, $originalBlocking);
}
}
@@ -248,10 +334,6 @@ final readonly class ConsoleTUI
// Enable mouse reporting (SGR format)
$this->output->write(MouseControlCode::ENABLE_ALL->format());
$this->output->write(MouseControlCode::ENABLE_SGR->format());
// Note: Mouse move tracking (ENABLE_MOVE) is disabled by default
// because it generates too many events that can overflow the input buffer
// If hover effects are needed, consider enabling it conditionally
// $this->output->write(MouseControlCode::ENABLE_MOVE->format());
// Optional: Enable alternate screen buffer
$this->output->write(ScreenControlCode::ALTERNATE_BUFFER->format());
@@ -266,8 +348,7 @@ final readonly class ConsoleTUI
*/
private function restoreTerminal(): void
{
// Note: Mouse tracking is disabled in flushRemainingInput() before this
// But we disable it here too as a safety measure
// Disable mouse tracking
$this->output->write(MouseControlCode::DISABLE_MOVE->format());
$this->output->write(MouseControlCode::DISABLE_SGR->format());
$this->output->write(MouseControlCode::DISABLE_ALL->format());
@@ -294,11 +375,9 @@ final readonly class ConsoleTUI
*/
private function loadCommands(): void
{
// Use the new CommandGroupRegistry for organized commands
$categories = $this->groupRegistry->getOrganizedCommands();
$this->state->setCategories($categories);
// Load available workflows
$workflows = $this->groupRegistry->getWorkflows();
$this->state->setWorkflows($workflows);
}
@@ -311,8 +390,6 @@ final readonly class ConsoleTUI
$this->renderer->render($this->state, $this->commandHistory);
}
/**
* Handle form mode interaction
*/
@@ -324,71 +401,6 @@ final readonly class ConsoleTUI
}
}
/**
* Read a single key from input (including multi-byte escape sequences)
* Enhanced for PHPStorm terminal compatibility
*/
private function readKey(): string
{
$key = fgetc(STDIN);
if ($key === false) {
return '';
}
// Handle escape sequences (arrow keys, etc.)
if ($key === "\033") {
$sequence = $key;
// Use non-blocking read for better compatibility
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
stream_set_blocking(STDIN, false);
// Read the next character with small timeout
$next = fgetc(STDIN);
if ($next === false) {
// Wait a bit and try again (PHPStorm sometimes needs this)
usleep(10000); // 10ms
$next = fgetc(STDIN);
}
if ($next !== false) {
$sequence .= $next;
// If it's a bracket, read more
if ($next === '[') {
$third = fgetc(STDIN);
if ($third === false) {
usleep(10000); // 10ms
$third = fgetc(STDIN);
}
if ($third !== false) {
$sequence .= $third;
// Some sequences have more characters (like Page Up/Down)
if (in_array($third, ['5', '6', '3', '1', '2', '4'])) {
$fourth = fgetc(STDIN);
if ($fourth === false) {
usleep(5000); // 5ms
$fourth = fgetc(STDIN);
}
if ($fourth !== false) {
$sequence .= $fourth;
}
}
}
}
}
// Restore blocking mode
stream_set_blocking(STDIN, $originalBlocking);
return $sequence;
}
return $key;
}
/**
* Show welcome screen and wait for user input
*/
@@ -436,18 +448,14 @@ final readonly class ConsoleTUI
if ($enabled) {
if ($isPhpStorm) {
// PHPStorm-optimized settings
shell_exec('stty raw -echo min 1 time 0 2>/dev/null');
} else {
// Standard terminal settings
shell_exec('stty -icanon -echo 2>/dev/null');
}
} else {
if ($isPhpStorm) {
// PHPStorm-optimized restore
shell_exec('stty -raw echo 2>/dev/null');
} else {
// Standard terminal restore
shell_exec('stty icanon echo 2>/dev/null');
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use SplQueue;
/**
* Buffer for events and partial sequences.
* Provides thread-safe event queuing and partial sequence handling.
*/
final class EventBuffer
{
private SplQueue $eventQueue;
private string $partialSequence = '';
private int $maxQueueSize;
public function __construct(int $maxQueueSize = 1000)
{
$this->eventQueue = new SplQueue();
$this->maxQueueSize = $maxQueueSize;
}
/**
* Enqueue an event
*/
public function enqueue(MouseEvent|KeyEvent $event): void
{
if ($this->eventQueue->count() >= $this->maxQueueSize) {
// Drop oldest event if queue is full (backpressure)
$this->eventQueue->dequeue();
}
$this->eventQueue->enqueue($event);
}
/**
* Dequeue an event
*/
public function dequeue(): MouseEvent|KeyEvent|null
{
if ($this->eventQueue->isEmpty()) {
return null;
}
return $this->eventQueue->dequeue();
}
/**
* Get pending event count
*/
public function getPendingCount(): int
{
return $this->eventQueue->count();
}
/**
* Clear all events
*/
public function clear(): void
{
while (!$this->eventQueue->isEmpty()) {
$this->eventQueue->dequeue();
}
}
/**
* Store partial sequence for later completion
*/
public function storePartialSequence(string $sequence): void
{
$this->partialSequence = $sequence;
}
/**
* Get and clear stored partial sequence
*/
public function getPartialSequence(): string
{
$sequence = $this->partialSequence;
$this->partialSequence = '';
return $sequence;
}
/**
* Check if partial sequence exists
*/
public function hasPartialSequence(): bool
{
return $this->partialSequence !== '';
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components\EventLoop;
use App\Framework\Console\Animation\AnimationManager;
use App\Framework\Console\Components\EventBuffer;
use App\Framework\Console\Components\KeyEvent;
use App\Framework\Console\Components\MouseEvent;
use Throwable;
/**
* Stable event loop for TUI with error handling and event buffering.
* Provides a robust main loop with automatic recovery and rate limiting.
*/
final class EventLoop
{
private const int MAX_EVENTS_PER_CYCLE = 10;
private const int LOOP_SLEEP_MICROSECONDS = 5000; // 5ms
private const int MAX_ERROR_COUNT = 10;
public function __construct(
private readonly EventBuffer $eventBuffer,
private readonly EventLoopConfig $config,
private ?AnimationManager $animationManager = null
) {
}
/**
* Process events from buffer (with rate limiting)
* Returns number of events processed
*/
public function processEvents(callable $processEvent): int
{
$processed = 0;
$maxEvents = min(self::MAX_EVENTS_PER_CYCLE, $this->eventBuffer->getPendingCount());
for ($i = 0; $i < $maxEvents; $i++) {
$event = $this->eventBuffer->dequeue();
if ($event === null) {
break;
}
try {
$this->safeProcessEvent($processEvent, $event);
$processed++;
} catch (Throwable $e) {
// Log but continue processing
if ($this->config->errorHandler !== null) {
$this->config->errorHandler->__invoke($e);
}
}
}
return $processed;
}
/**
* Read events from input parser and add to buffer
*/
public function readAndBufferEvents(callable $readEvent): int
{
$readCount = 0;
$maxRead = 10; // Read up to 10 events per cycle
for ($i = 0; $i < $maxRead; $i++) {
$event = $readEvent();
if ($event === null) {
break;
}
$this->eventBuffer->enqueue($event);
$readCount++;
}
return $readCount;
}
/**
* Safely process a single event
*/
private function safeProcessEvent(callable $processEvent, MouseEvent|KeyEvent $event): void
{
try {
$processEvent($event);
} catch (Throwable $e) {
// Re-throw to be handled by caller
throw $e;
}
}
/**
* Register animation manager for automatic updates
*/
public function registerAnimationManager(AnimationManager $manager): void
{
$this->animationManager = $manager;
}
/**
* Update animations (called from loop)
*/
public function updateAnimations(float $deltaTime): void
{
if ($this->animationManager !== null) {
$this->animationManager->update($deltaTime);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components\EventLoop;
use Closure;
use Throwable;
/**
* Configuration for EventLoop
*/
final readonly class EventLoopConfig
{
/**
* @param Closure(Throwable): void|null $errorHandler
*/
public function __construct(
public bool $enableSignalDispatch = true,
public float $minRenderInterval = 0.016, // ~60 FPS max
public ?Closure $errorHandler = null
) {
}
}

View File

@@ -0,0 +1,485 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components\Handlers;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\Components\KeyEvent;
use App\Framework\Console\Components\MenuBar;
use App\Framework\Console\Components\TuiCommandExecutor;
use App\Framework\Console\Components\TuiState;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\TuiKeyCode;
use App\Framework\Console\TuiView;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Isolated keyboard event handler.
* Handles all keyboard input and navigation for different views.
*/
final readonly class KeyboardEventHandler
{
public function __construct(
private TuiCommandExecutor $commandExecutor,
private ?MenuBar $menuBar = null
) {
}
/**
* Handle keyboard event
* Returns true if re-render is needed
*/
public function handle(KeyEvent $event, TuiState $state, CommandHistory $history): bool
{
// Handle Ctrl+C
if ($event->ctrl && $event->key === 'C') {
$state->setRunning(false);
return false;
}
// Handle Alt+Letter shortcuts for menu
if ($event->alt && $this->menuBar !== null && strlen($event->key) === 1) {
$menuItem = $this->menuBar->getItemByHotkey($event->key);
if ($menuItem !== null) {
$state->setActiveMenu($menuItem);
$state->setStatus("Menü geöffnet: {$menuItem}");
return true;
}
}
// Convert to legacy key string format for compatibility
$keyString = $this->keyEventToString($event);
if ($keyString === '') {
return false;
}
// Handle global shortcuts first
$wasGlobalAction = $this->handleGlobalShortcuts($keyString, $event, $state);
if ($wasGlobalAction) {
return true;
}
// Handle dropdown navigation if dropdown is open
if ($state->getActiveDropdownMenu() !== null) {
$handled = $this->handleDropdownInput($keyString, $state);
if ($handled) {
return true;
}
}
// Close active menu when any non-menu action is performed
if ($state->getActiveMenu() !== null && $state->getActiveDropdownMenu() === null) {
$state->setActiveMenu(null);
}
// View-specific handling
return match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->handleCategoriesInput($keyString, $state),
TuiView::COMMANDS => $this->handleCommandsInput($keyString, $state),
TuiView::SEARCH => $this->handleSearchInput($keyString, $state),
TuiView::HISTORY => $this->handleHistoryInput($keyString, $state, $history),
TuiView::FORM => $this->handleFormInput($keyString, $state),
TuiView::DASHBOARD => $this->handleDashboardInput($keyString, $state),
TuiView::HELP => $this->handleHelpInput($keyString, $state),
};
}
/**
* Handle global shortcuts available in all views
*/
private function handleGlobalShortcuts(string $key, KeyEvent $event, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
// If dropdown is open, close it instead of quitting
if ($state->getActiveDropdownMenu() !== null) {
$state->setActiveDropdownMenu(null);
$state->setActiveMenu(null);
return true;
}
// Otherwise quit
$state->setRunning(false);
return true;
case 'q':
case 'Q':
$state->setRunning(false);
return true;
case 'd':
case 'D':
$state->setCurrentView(TuiView::DASHBOARD);
return true;
case 'h':
case 'H':
$state->setCurrentView(TuiView::HELP);
return true;
case '/':
case 's':
case 'S':
$state->setCurrentView(TuiView::SEARCH);
$state->resetSearchState();
return true;
case 'r':
case 'R':
$state->setCurrentView(TuiView::HISTORY);
$state->setSelectedHistoryItem(0);
return true;
case TuiKeyCode::F1->value:
$this->commandExecutor->showAllCommandsHelp();
return true;
case 'c':
case 'C':
if ($state->getCurrentView() !== TuiView::CATEGORIES) {
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
break;
}
return false;
}
/**
* Handle input in categories view
*/
private function handleCategoriesInput(string $key, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
return true;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
return true;
case TuiKeyCode::ENTER->value:
$state->setCurrentView(TuiView::COMMANDS);
$state->setSelectedCommand(0);
return true;
}
return false;
}
/**
* Handle input in commands view
*/
private function handleCommandsInput(string $key, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
return true;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
return true;
case TuiKeyCode::ENTER->value:
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->executeSelectedCommand($command);
}
return true;
case TuiKeyCode::SPACE->value:
$command = $state->getCurrentCommand();
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
return true;
case 'v':
case 'V':
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->validateSelectedCommand($command);
}
return true;
case 'h':
case 'H':
$command = $state->getCurrentCommand();
if ($command) {
$this->commandExecutor->showSelectedCommandHelp($command);
}
return true;
case TuiKeyCode::ARROW_LEFT->value:
case TuiKeyCode::BACKSPACE->value:
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
return false;
}
/**
* Handle input in search view
*/
private function handleSearchInput(string $key, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
$state->resetSearchState();
return true;
case TuiKeyCode::ENTER->value:
$command = $state->getCurrentSearchResult();
if ($command) {
$this->executeCommand($command);
}
return true;
case TuiKeyCode::SPACE->value:
$command = $state->getCurrentSearchResult();
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
return true;
case TuiKeyCode::ARROW_UP->value:
$state->navigateUp();
return true;
case TuiKeyCode::ARROW_DOWN->value:
$state->navigateDown();
return true;
case TuiKeyCode::BACKSPACE->value:
case "\x7f": // DEL key
$query = $state->getSearchQuery();
if (strlen($query) > 0) {
$newQuery = substr($query, 0, -1);
$state->setSearchQuery($newQuery);
$this->updateSearchResults($state);
}
return true;
default:
// Add character to search query
if (strlen($key) === 1 && ctype_print($key)) {
$newQuery = $state->getSearchQuery() . $key;
$state->setSearchQuery($newQuery);
$this->updateSearchResults($state);
}
return true;
}
}
/**
* Handle input in history view
*/
private function handleHistoryInput(string $key, TuiState $state, CommandHistory $history): bool
{
switch ($key) {
case TuiKeyCode::ARROW_UP->value:
$items = $this->getHistoryItems($state, $history);
$state->navigateHistoryItemUpWithCount(count($items));
return true;
case TuiKeyCode::ARROW_DOWN->value:
$items = $this->getHistoryItems($state, $history);
$state->navigateHistoryItemDownWithCount(count($items));
return true;
case TuiKeyCode::ARROW_LEFT->value:
$this->switchHistoryTab($state, -1);
return true;
case TuiKeyCode::ARROW_RIGHT->value:
$this->switchHistoryTab($state, 1);
return true;
case TuiKeyCode::ENTER->value:
$items = $this->getHistoryItems($state, $history);
if (!empty($items) && isset($items[$state->getSelectedHistoryItem()])) {
$command = $items[$state->getSelectedHistoryItem()]['command'];
$history->addToHistory($command);
$this->commandExecutor->executeCommand($command);
}
return true;
case TuiKeyCode::ESCAPE->value:
case TuiKeyCode::BACKSPACE->value:
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
return false;
}
/**
* Handle input in form view
*/
private function handleFormInput(string $key, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->resetFormState();
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
return false;
}
/**
* Handle input in dashboard view
*/
private function handleDashboardInput(string $key, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
return false;
}
/**
* Handle input in help view
*/
private function handleHelpInput(string $key, TuiState $state): bool
{
switch ($key) {
case TuiKeyCode::ESCAPE->value:
$state->setCurrentView(TuiView::CATEGORIES);
return true;
}
return false;
}
/**
* Handle dropdown menu input
*/
private function handleDropdownInput(string $key, TuiState $state): bool
{
// Simplified - actual implementation would handle dropdown navigation
return false;
}
/**
* Convert KeyEvent to legacy key string format
*/
private function keyEventToString(KeyEvent $event): string
{
if ($event->code !== '') {
return $event->code;
}
return match ($event->key) {
'ArrowUp' => TuiKeyCode::ARROW_UP->value,
'ArrowDown' => TuiKeyCode::ARROW_DOWN->value,
'ArrowRight' => TuiKeyCode::ARROW_RIGHT->value,
'ArrowLeft' => TuiKeyCode::ARROW_LEFT->value,
'Enter' => TuiKeyCode::ENTER->value,
'Escape' => TuiKeyCode::ESCAPE->value,
'Tab' => TuiKeyCode::TAB->value,
default => $event->key,
};
}
/**
* Execute command (handles DiscoveredAttribute objects)
*/
private function executeCommand(mixed $command): void
{
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->executeCommand($attribute->name);
}
} else {
$this->commandExecutor->executeCommand($command->name);
}
}
/**
* Update search results
*/
private function updateSearchResults(TuiState $state): void
{
$query = strtolower($state->getSearchQuery());
if (empty($query)) {
$state->setSearchResults([]);
return;
}
$results = [];
$categories = $state->getCategories();
foreach ($categories as $category) {
foreach ($category['commands'] as $command) {
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute === null) {
continue;
}
$commandName = strtolower($attribute->name);
$commandDescription = strtolower($attribute->description ?? '');
} else {
$commandName = strtolower($command->name ?? '');
$commandDescription = strtolower($command->description ?? '');
}
if (str_contains($commandName, $query) || str_contains($commandDescription, $query)) {
$results[] = $command;
}
}
}
$state->setSearchResults($results);
$state->setSelectedSearchResult(0);
}
/**
* Get history items based on current tab
*/
private function getHistoryItems(TuiState $state, CommandHistory $history): array
{
return match ($state->getHistoryTab()) {
HistoryTab::RECENT => $history->getRecentHistory(10),
HistoryTab::FREQUENT => $history->getFrequentCommands(10),
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
/**
* Switch history tab
*/
private function switchHistoryTab(TuiState $state, int $direction): void
{
$tabs = [HistoryTab::RECENT, HistoryTab::FREQUENT, HistoryTab::FAVORITES];
$currentIndex = array_search($state->getHistoryTab(), $tabs, true);
if ($currentIndex === false) {
return;
}
$newIndex = $currentIndex + $direction;
if ($newIndex < 0) {
$newIndex = count($tabs) - 1;
} elseif ($newIndex >= count($tabs)) {
$newIndex = 0;
}
$state->setHistoryTab($tabs[$newIndex]);
$state->setSelectedHistoryItem(0);
}
}

View File

@@ -0,0 +1,436 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components\Handlers;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\Components\MenuBar;
use App\Framework\Console\Components\MouseEvent;
use App\Framework\Console\Components\TuiCommandExecutor;
use App\Framework\Console\Components\TuiState;
use App\Framework\Console\TuiView;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
/**
* Isolated mouse event handler.
* Handles all mouse interactions (clicks, scroll, hover) with throttling.
*/
final class MouseEventHandler
{
private const float HOVER_UPDATE_INTERVAL = 0.05; // 50ms = 20 updates per second max
private float $lastHoverUpdate;
public function __construct(
private ?MenuBar $menuBar = null,
private ?TuiCommandExecutor $commandExecutor = null
) {
$this->lastHoverUpdate = 0.0;
}
/**
* Handle mouse event
* Returns true if re-render is needed
*/
public function handle(MouseEvent $event, TuiState $state, CommandHistory $history): bool
{
// Ignore mouse move events if hover throttling is active
if ($event->isMouseMove()) {
return $this->handleMouseMove($event, $state);
}
// Handle scroll events
if ($event->isScrollEvent()) {
return $this->handleScroll($event, $state);
}
// Handle click events
if ($event->pressed) {
return $this->handleClick($event, $state, $history);
}
return false;
}
/**
* Handle mouse move (hover) with throttling
*/
private function handleMouseMove(MouseEvent $event, TuiState $state): bool
{
$currentTime = microtime(true);
$timeSinceLastUpdate = $currentTime - $this->lastHoverUpdate;
// Throttle hover updates
if ($timeSinceLastUpdate < self::HOVER_UPDATE_INTERVAL) {
return false;
}
$this->lastHoverUpdate = $currentTime;
// Update hover state based on coordinates
$this->updateHoverState($event, $state);
return true;
}
/**
* Handle scroll events
*/
private function handleScroll(MouseEvent $event, TuiState $state): bool
{
$view = $state->getCurrentView();
if ($event->button === 64) {
// Scroll up
match ($view) {
TuiView::CATEGORIES, TuiView::COMMANDS, TuiView::SEARCH, TuiView::HISTORY => $state->navigateUp(),
default => null,
};
return true;
} elseif ($event->button === 65) {
// Scroll down
match ($view) {
TuiView::CATEGORIES, TuiView::COMMANDS, TuiView::SEARCH, TuiView::HISTORY => $state->navigateDown(),
default => null,
};
return true;
}
return false;
}
/**
* Handle click events
*/
private function handleClick(MouseEvent $event, TuiState $state, CommandHistory $history): bool
{
// Handle dropdown clicks first (if dropdown is open)
if ($state->getActiveDropdownMenu() !== null) {
$dropdownItemIndex = $this->handleDropdownClick($event, $state);
if ($dropdownItemIndex !== null) {
return true;
}
}
// Handle menu bar clicks
if ($this->menuBar !== null && $this->menuBar->isClickInMenuArea($event->x, $event->y)) {
$menuItem = $this->menuBar->getItemAtPosition($event->x);
if ($menuItem !== null && $event->button === 0) {
// Left click on menu item - open dropdown if it has submenus
if ($this->menuBar->hasSubmenu($menuItem)) {
$state->setActiveMenu($menuItem);
$state->setActiveDropdownMenu($menuItem);
$state->setSelectedDropdownItem(0);
$state->setStatus("Menü geöffnet: {$menuItem}");
} else {
// No submenu, just set active menu
$state->setActiveMenu($menuItem);
$state->setActiveDropdownMenu(null);
$state->setStatus("Menü: {$menuItem}");
}
}
return true;
}
// If dropdown is open and click is outside, close dropdown
if ($state->getActiveDropdownMenu() !== null && $event->button === 0) {
$state->setActiveDropdownMenu(null);
$state->setActiveMenu(null);
return true;
}
$view = $state->getCurrentView();
// Handle item clicks (left and right button)
if ($event->button === 0) {
// Left click - primary action
return $this->handleItemClick($event, $state, $history, false);
} elseif ($event->button === 2) {
// Right click - secondary action
return $this->handleItemClick($event, $state, $history, true);
}
return false;
}
/**
* Handle dropdown menu click
*/
private function handleDropdownClick(MouseEvent $event, TuiState $state): ?int
{
if ($this->menuBar === null) {
return null;
}
$activeMenu = $state->getActiveDropdownMenu();
if ($activeMenu === null) {
return null;
}
$submenuItems = $this->menuBar->getSubmenu($activeMenu);
if (empty($submenuItems)) {
return null;
}
// Calculate which dropdown item was clicked
// Dropdown starts at line 4, menu bar is at line 2-3
$clickedIndex = $event->y - 4;
if ($clickedIndex >= 0 && $clickedIndex < count($submenuItems)) {
if ($event->button === 0) {
// Left click - select item
$state->setSelectedDropdownItem($clickedIndex);
$item = $submenuItems[$clickedIndex];
if ($item !== '---') {
// Execute dropdown item action
$this->executeDropdownItem($activeMenu, $item, $state);
$state->setActiveDropdownMenu(null);
$state->setActiveMenu(null);
}
}
return $clickedIndex;
}
return null;
}
/**
* Execute a dropdown menu item action
*/
private function executeDropdownItem(string $menu, string $item, TuiState $state): void
{
$state->setStatus("Ausgeführt: {$menu} > {$item}");
// TODO: Implement actual actions for dropdown items
}
/**
* Handle item clicks (left and right button)
*/
private function handleItemClick(MouseEvent $event, TuiState $state, CommandHistory $history, bool $isRightClick): bool
{
$view = $state->getCurrentView();
// Calculate clicked index based on y coordinate
$clickedIndex = $event->y - 4;
return match ($view) {
TuiView::CATEGORIES => $this->handleCategoryClick($clickedIndex, $state, $isRightClick),
TuiView::COMMANDS => $this->handleCommandClick($clickedIndex, $state, $isRightClick),
TuiView::SEARCH => $this->handleSearchResultClick($clickedIndex, $state, $isRightClick),
TuiView::HISTORY => $this->handleHistoryItemClick($clickedIndex, $state, $history, $isRightClick),
default => false,
};
}
/**
* Handle category click
*/
private function handleCategoryClick(int $index, TuiState $state, bool $isRightClick): bool
{
$categories = $state->getCategories();
if ($index < 0 || $index >= count($categories)) {
return false;
}
$state->setSelectedCategory($index);
if ($isRightClick) {
// Right click - no action for categories
return false;
}
// Left click - select category
$state->setCurrentView(TuiView::COMMANDS);
$state->setSelectedCommand(0);
return true;
}
/**
* Handle command click
*/
private function handleCommandClick(int $index, TuiState $state, bool $isRightClick): bool
{
$category = $state->getCurrentCategory();
if (!$category) {
return false;
}
$commands = $category['commands'] ?? [];
if ($index < 0 || $index >= count($commands)) {
return false;
}
$state->setSelectedCommand($index);
$command = $commands[$index];
if ($isRightClick) {
// Right click - open parameter form
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
return true;
}
// Left click - execute command
if ($command && $this->commandExecutor !== null) {
$this->commandExecutor->executeSelectedCommand($command);
}
return true;
}
/**
* Handle search result click
*/
private function handleSearchResultClick(int $index, TuiState $state, bool $isRightClick): bool
{
$results = $state->getSearchResults();
if ($index < 0 || $index >= count($results)) {
return false;
}
$state->setSelectedSearchResult($index);
$command = $results[$index];
if ($isRightClick) {
// Right click - open parameter form
if ($command) {
$state->setSelectedCommandForForm($command);
$state->setCurrentView(TuiView::FORM);
}
return true;
}
// Left click - execute command
if ($command && $this->commandExecutor !== null) {
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
if ($attribute !== null) {
$this->commandExecutor->executeCommand($attribute->name);
}
} else {
$this->commandExecutor->executeCommand($command->name);
}
}
return true;
}
/**
* Handle history item click
*/
private function handleHistoryItemClick(int $index, TuiState $state, CommandHistory $history, bool $isRightClick): bool
{
$items = $this->getHistoryItems($state, $history);
if ($index < 0 || $index >= count($items)) {
return false;
}
$state->setSelectedHistoryItem($index);
if ($isRightClick) {
// Right click - toggle favorite
if (isset($items[$index])) {
$command = $items[$index]['command'];
$isFavorite = $history->toggleFavorite($command);
$status = $isFavorite ? 'Zu Favoriten hinzugefügt' : 'Aus Favoriten entfernt';
$state->setStatus("{$command}: {$status}");
}
return true;
}
// Left click - execute command
if (isset($items[$index])) {
$command = $items[$index]['command'];
$history->addToHistory($command);
if ($this->commandExecutor !== null) {
$this->commandExecutor->executeCommand($command);
}
}
return true;
}
/**
* Get history items based on current tab
*/
private function getHistoryItems(TuiState $state, CommandHistory $history): array
{
return match ($state->getHistoryTab()) {
\App\Framework\Console\HistoryTab::RECENT => $history->getRecentHistory(10),
\App\Framework\Console\HistoryTab::FREQUENT => $history->getFrequentCommands(10),
\App\Framework\Console\HistoryTab::FAVORITES => $history->getFavorites(),
};
}
/**
* Update hover state based on mouse coordinates
*/
private function updateHoverState(MouseEvent $event, TuiState $state): void
{
$view = $state->getCurrentView();
// Update hover state based on coordinates
// This is simplified - actual implementation would need to map coordinates to UI elements
match ($view) {
TuiView::CATEGORIES => $this->updateCategoryHover($event, $state),
TuiView::COMMANDS => $this->updateCommandHover($event, $state),
TuiView::SEARCH => $this->updateSearchHover($event, $state),
TuiView::HISTORY => $this->updateHistoryHover($event, $state),
default => null,
};
}
/**
* Update category hover state
*/
private function updateCategoryHover(MouseEvent $event, TuiState $state): void
{
$hoveredIndex = $event->y - 4;
if ($hoveredIndex >= 0 && $hoveredIndex < count($state->getCategories())) {
$state->setHoveredContentItem('category', $hoveredIndex);
}
}
/**
* Update command hover state
*/
private function updateCommandHover(MouseEvent $event, TuiState $state): void
{
$hoveredIndex = $event->y - 4;
$category = $state->getCurrentCategory();
if ($category && $hoveredIndex >= 0 && $hoveredIndex < count($category['commands'])) {
$state->setHoveredContentItem('command', $hoveredIndex);
}
}
/**
* Update search hover state
*/
private function updateSearchHover(MouseEvent $event, TuiState $state): void
{
$hoveredIndex = $event->y - 4;
$results = $state->getSearchResults();
if ($hoveredIndex >= 0 && $hoveredIndex < count($results)) {
$state->setHoveredContentItem('search', $hoveredIndex);
}
}
/**
* Update history hover state
*/
private function updateHistoryHover(MouseEvent $event, TuiState $state): void
{
$hoveredIndex = $event->y - 4;
// Similar to other views
$state->setHoveredContentItem('history', $hoveredIndex);
}
}

View File

@@ -4,11 +4,26 @@ declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\Components\Parsers\KeyboardEventParser;
use App\Framework\Console\Components\Parsers\MouseEventParser;
/**
* Parses ANSI escape sequences for mouse and keyboard events
* Refactored input parser - delegates to specialized parsers.
* Uses EventBuffer for better event handling and partial sequence management.
*/
final class InputParser
{
private MouseEventParser $mouseParser;
private KeyboardEventParser $keyboardParser;
private EventBuffer $eventBuffer;
public function __construct(?EventBuffer $eventBuffer = null)
{
$this->eventBuffer = $eventBuffer ?? new EventBuffer();
$this->mouseParser = new MouseEventParser($this->eventBuffer);
$this->keyboardParser = new KeyboardEventParser($this->eventBuffer);
}
/**
* Read and parse input from STDIN using stream_select()
*
@@ -16,6 +31,12 @@ final class InputParser
*/
public function readEvent(): MouseEvent|KeyEvent|null
{
// Check for partial sequence first
if ($this->eventBuffer->hasPartialSequence()) {
$partial = $this->eventBuffer->getPartialSequence();
return $this->continueParsing($partial);
}
// Use stream_select for non-blocking I/O
$read = [STDIN];
$write = null;
@@ -42,15 +63,8 @@ final class InputParser
return $this->parseEscapeSequence($firstChar);
}
// Check for Ctrl+C (ASCII 3)
if ($firstChar === "\003") {
stream_set_blocking(STDIN, $originalBlocking);
return new KeyEvent(key: 'C', ctrl: true);
}
// Regular character
stream_set_blocking(STDIN, $originalBlocking);
return new KeyEvent(key: $firstChar);
return $this->keyboardParser->parseRegularChar($firstChar);
} finally {
stream_set_blocking(STDIN, $originalBlocking);
}
@@ -66,7 +80,7 @@ final class InputParser
// Read next character with timeout
$next = $this->readCharWithTimeout();
if ($next === null) {
return new KeyEvent(key: "\033");
return new KeyEvent(key: "\033", code: "\033");
}
$sequence .= $next;
@@ -75,158 +89,30 @@ final class InputParser
if ($next === '[') {
$third = $this->readCharWithTimeout();
if ($third === '<') {
return $this->parseMouseEvent($sequence . $third);
return $this->mouseParser->parse($sequence . $third);
}
// Keyboard escape sequence like \e[A (arrow up)
// Keyboard escape sequence
if ($third !== null) {
$sequence .= $third;
return $this->parseKeyboardSequence($sequence);
return $this->keyboardParser->parse($sequence);
}
}
// Just escape key
return new KeyEvent(key: "\033");
return new KeyEvent(key: "\033", code: "\033");
}
/**
* Parse SGR mouse event: \e[<b;x;yM (press) or \e[<b;x;ym (release)
* Continue parsing a partial sequence
*/
private function parseMouseEvent(string $prefix): MouseEvent|null
private function continueParsing(string $partial): MouseEvent|KeyEvent|null
{
$buffer = '';
$timeout = 10000; // 10ms
$startTime = microtime(true) * 1000;
// Read until we get 'M' or 'm'
while (true) {
$char = fgetc(STDIN);
if ($char === false) {
// Check timeout
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeout) {
return null;
}
usleep(1000);
continue;
}
$buffer .= $char;
// Mouse event ends with 'M' (press) or 'm' (release)
if ($char === 'M' || $char === 'm') {
break;
}
// Safety: limit buffer size
if (strlen($buffer) > 20) {
return null;
}
if ($this->mouseParser->isMouseEventPrefix($partial)) {
return $this->mouseParser->parse($partial);
}
// Parse format: b;x;y where b is button code, x and y are coordinates
// Remove the final M/m
$data = substr($buffer, 0, -1);
$parts = explode(';', $data);
if (count($parts) < 3) {
// Invalid mouse event format, discard
return null;
}
$buttonCode = (int) $parts[0];
$x = (int) $parts[1];
$y = (int) $parts[2];
// Validate coordinates (basic sanity check to catch corrupted events)
if ($x < 1 || $y < 1 || $x > 1000 || $y > 1000) {
// Invalid coordinates, likely corrupted event - discard silently
return null;
}
// Decode button and modifiers
// Bit flags in button code:
// Bit 0-1: Button (0=left, 1=middle, 2=right, 3=release)
// Bit 2: Shift
// Bit 3: Meta/Alt
// Bit 4: Ctrl
// Bit 5: Mouse move (32 = 0x20)
// Bit 6-7: Scroll (64=scroll up, 65=scroll down)
$button = $buttonCode & 0x03;
$shift = ($buttonCode & 0x04) !== 0;
$alt = ($buttonCode & 0x08) !== 0;
$ctrl = ($buttonCode & 0x10) !== 0;
// Handle scroll events (button codes 64 and 65)
if ($buttonCode >= 64 && $buttonCode <= 65) {
$button = $buttonCode;
} elseif (($buttonCode & 0x20) !== 0) {
// Mouse move (button code 32 or bit 5 set)
// Store full button code to detect mouse move
$button = $buttonCode;
}
$pressed = $buffer[-1] === 'M';
return new MouseEvent(
x: $x,
y: $y,
button: $button,
pressed: $pressed,
shift: $shift,
ctrl: $ctrl,
alt: $alt
);
}
/**
* Parse keyboard escape sequence (arrow keys, function keys, etc.)
*/
private function parseKeyboardSequence(string $sequence): KeyEvent
{
// Map common escape sequences
$keyMap = [
"\033[A" => 'ArrowUp',
"\033[B" => 'ArrowDown',
"\033[C" => 'ArrowRight',
"\033[D" => 'ArrowLeft',
"\033[H" => 'Home',
"\033[F" => 'End',
"\033[5~" => 'PageUp',
"\033[6~" => 'PageDown',
"\033[3~" => 'Delete',
"\n" => 'Enter',
"\r" => 'Enter',
"\033" => 'Escape',
"\t" => 'Tab',
];
// Check if we need to read more characters
if (strlen($sequence) >= 3 && in_array($sequence[2], ['5', '6', '3'], true)) {
$fourth = $this->readCharWithTimeout();
if ($fourth !== null) {
$sequence .= $fourth;
}
}
// Check for Enter key
if ($sequence === "\n" || $sequence === "\r") {
return new KeyEvent(key: 'Enter', code: "\n");
}
// Check for Escape
if ($sequence === "\033") {
return new KeyEvent(key: 'Escape', code: "\033");
}
// Map to known key
if (isset($keyMap[$sequence])) {
return new KeyEvent(key: $keyMap[$sequence], code: $sequence);
}
// Unknown sequence, return as-is
return new KeyEvent(key: $sequence, code: $sequence);
return $this->keyboardParser->parse($partial);
}
/**
@@ -252,32 +138,10 @@ final class InputParser
}
/**
* Discard any remaining input characters from STDIN
* Get event buffer
*/
private function discardRemainingInput(): void
public function getEventBuffer(): EventBuffer
{
// Read and discard up to 50 characters to clear partial escape sequences
$maxDiscard = 50;
$discarded = 0;
while ($discarded < $maxDiscard) {
$read = [STDIN];
$write = null;
$except = null;
$result = stream_select($read, $write, $except, 0, 1000); // 1ms
if ($result === false || $result === 0) {
break; // No more input
}
$char = fgetc(STDIN);
if ($char === false) {
break;
}
$discarded++;
}
return $this->eventBuffer;
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components\Parsers;
use App\Framework\Console\Components\EventBuffer;
use App\Framework\Console\Components\KeyEvent;
/**
* Isolated keyboard event parser with comprehensive sequence support.
* Handles all keyboard escape sequences and regular keys.
*/
final readonly class KeyboardEventParser
{
private const int TIMEOUT_MS = 10;
/**
* Comprehensive key mapping for escape sequences
*/
private const array KEY_MAP = [
// Arrow keys
"\033[A" => 'ArrowUp',
"\033[B" => 'ArrowDown',
"\033[C" => 'ArrowRight',
"\033[D" => 'ArrowLeft',
// Navigation
"\033[H" => 'Home',
"\033[F" => 'End',
"\033[1~" => 'Home',
"\033[4~" => 'End',
// Page navigation
"\033[5~" => 'PageUp',
"\033[6~" => 'PageDown',
// Function keys
"\033OP" => 'F1',
"\033OQ" => 'F2',
"\033OR" => 'F3',
"\033OS" => 'F4',
"\033[15~" => 'F5',
"\033[17~" => 'F6',
"\033[18~" => 'F7',
"\033[19~" => 'F8',
"\033[20~" => 'F9',
"\033[21~" => 'F10',
"\033[23~" => 'F11',
"\033[24~" => 'F12',
// Special keys
"\033[3~" => 'Delete',
"\033[2~" => 'Insert',
"\033[Z" => 'ShiftTab',
// Control sequences
"\033[1;5A" => 'Ctrl+ArrowUp',
"\033[1;5B" => 'Ctrl+ArrowDown',
"\033[1;5C" => 'Ctrl+ArrowRight',
"\033[1;5D" => 'Ctrl+ArrowLeft',
];
public function __construct(
private EventBuffer $eventBuffer
) {
}
/**
* Parse keyboard event from escape sequence
*/
public function parse(string $sequence): KeyEvent
{
// Check for Enter key
if ($sequence === "\n" || $sequence === "\r") {
return new KeyEvent(key: 'Enter', code: "\n");
}
// Check for Escape
if ($sequence === "\033") {
return new KeyEvent(key: 'Escape', code: "\033");
}
// Try to read more characters for multi-character sequences
$fullSequence = $this->readFullSequence($sequence);
// Check if we have a known mapping
if (isset(self::KEY_MAP[$fullSequence])) {
return new KeyEvent(key: self::KEY_MAP[$fullSequence], code: $fullSequence);
}
// Check for modifier sequences (Ctrl+Key, Alt+Key, etc.)
$modifierEvent = $this->parseModifierSequence($fullSequence);
if ($modifierEvent !== null) {
return $modifierEvent;
}
// Unknown sequence, return as-is
return new KeyEvent(key: $fullSequence, code: $fullSequence);
}
/**
* Read full escape sequence (handles multi-byte sequences)
*/
private function readFullSequence(string $initialSequence): string
{
$sequence = $initialSequence;
$timeout = self::TIMEOUT_MS;
$startTime = microtime(true) * 1000;
// Read additional characters if needed
while (true) {
$char = fgetc(STDIN);
if ($char === false) {
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeout) {
break;
}
usleep(1000); // 1ms
continue;
}
$sequence .= $char;
// Check if sequence is complete
if ($this->isSequenceComplete($sequence)) {
break;
}
// Safety limit
if (strlen($sequence) > 20) {
break;
}
}
return $sequence;
}
/**
* Check if escape sequence is complete
*/
private function isSequenceComplete(string $sequence): bool
{
// Simple sequences end with a letter
if (strlen($sequence) >= 3 && preg_match('/^\033\[[A-Za-z]$/', $sequence)) {
return true;
}
// Function keys end with ~
if (preg_match('/^\033\[\d+~$/', $sequence)) {
return true;
}
// Modifier sequences end with letter after numbers
if (preg_match('/^\033\[\d+;\d+[A-Za-z]$/', $sequence)) {
return true;
}
return false;
}
/**
* Parse modifier sequences (Ctrl+, Alt+, etc.)
*/
private function parseModifierSequence(string $sequence): ?KeyEvent
{
// Pattern: \e[number;number;key
if (!preg_match('/^\033\[(\d+);(\d+)([A-Za-z])$/', $sequence, $matches)) {
return null;
}
$modifierCode = (int) $matches[1];
$keyCode = (int) $matches[2];
$key = $matches[3];
$ctrl = false;
$shift = false;
$alt = false;
$meta = false;
// Decode modifiers (standard xterm codes)
if ($modifierCode === 5) {
$ctrl = true;
} elseif ($modifierCode === 3) {
$alt = true;
} elseif ($modifierCode === 2) {
$shift = true;
}
return new KeyEvent(
key: $key,
code: $sequence,
shift: $shift,
ctrl: $ctrl,
alt: $alt,
meta: $meta
);
}
/**
* Parse regular character (non-escape sequence)
*/
public function parseRegularChar(string $char): KeyEvent
{
// Check for Ctrl+C (ASCII 3)
if ($char === "\003") {
return new KeyEvent(key: 'C', ctrl: true, code: "\003");
}
// Check for other control characters
$ord = ord($char);
if ($ord < 32 && $ord !== 9 && $ord !== 10 && $ord !== 13) {
// Control character
$ctrlKey = chr($ord + 64);
return new KeyEvent(key: $ctrlKey, ctrl: true, code: $char);
}
// Regular character
return new KeyEvent(key: $char, code: $char);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components\Parsers;
use App\Framework\Console\Components\EventBuffer;
use App\Framework\Console\Components\MouseEvent;
/**
* Isolated mouse event parser.
* Handles SGR mouse events (\e[<b;x;yM or \e[<b;x;ym)
*/
final readonly class MouseEventParser
{
private const int MAX_BUFFER_SIZE = 20;
private const int TIMEOUT_MS = 10;
private const int MAX_X = 1000;
private const int MAX_Y = 1000;
public function __construct(
private EventBuffer $eventBuffer
) {
}
/**
* Parse mouse event from sequence prefix \e[<
*/
public function parse(string $prefix): ?MouseEvent
{
$buffer = '';
$timeout = self::TIMEOUT_MS;
$startTime = microtime(true) * 1000;
// Read until we get 'M' or 'm'
while (true) {
$char = fgetc(STDIN);
if ($char === false) {
// Check timeout
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeout) {
// Timeout - store partial sequence for retry
if (strlen($prefix . $buffer) > 0) {
$this->eventBuffer->storePartialSequence($prefix . $buffer);
}
return null;
}
usleep(1000); // 1ms
continue;
}
$buffer .= $char;
// Mouse event ends with 'M' (press) or 'm' (release)
if ($char === 'M' || $char === 'm') {
break;
}
// Safety: limit buffer size
if (strlen($buffer) > self::MAX_BUFFER_SIZE) {
return null;
}
}
// Parse format: b;x;y where b is button code, x and y are coordinates
$data = substr($buffer, 0, -1);
$parts = explode(';', $data);
if (count($parts) < 3) {
// Invalid mouse event format
return null;
}
$buttonCode = (int) $parts[0];
$x = (int) $parts[1];
$y = (int) $parts[2];
// Validate coordinates
if ($x < 1 || $y < 1 || $x > self::MAX_X || $y > self::MAX_Y) {
// Invalid coordinates, likely corrupted
return null;
}
// Decode button and modifiers
$button = $buttonCode & 0x03;
$shift = ($buttonCode & 0x04) !== 0;
$alt = ($buttonCode & 0x08) !== 0;
$ctrl = ($buttonCode & 0x10) !== 0;
// Handle scroll events (button codes 64 and 65)
if ($buttonCode >= 64 && $buttonCode <= 65) {
$button = $buttonCode;
} elseif (($buttonCode & 0x20) !== 0) {
// Mouse move (button code 32 or bit 5 set)
$button = $buttonCode;
}
$pressed = $buffer[-1] === 'M';
return new MouseEvent(
x: $x,
y: $y,
button: $button,
pressed: $pressed,
shift: $shift,
ctrl: $ctrl,
alt: $alt
);
}
/**
* Check if sequence is a mouse event prefix
*/
public function isMouseEventPrefix(string $sequence): bool
{
return str_starts_with($sequence, "\033[<");
}
}

View File

@@ -4,210 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
/**
* Rendert eine Tabelle in der Konsole.
* @deprecated Use App\Framework\Display\Components\Console\Table instead
* This class is kept for backward compatibility only.
*/
final class Table
{
private array $headers = [];
private array $rows = [];
private array $columnWidths = [];
private int $padding = 1;
public function __construct(
private ?ConsoleStyle $headerStyle = null,
private ?ConsoleStyle $rowStyle = null,
private ?ConsoleStyle $borderStyle = null,
private readonly bool $showBorders = true
) {
$this->headerStyle ??= ConsoleStyle::create(color: ConsoleColor::BRIGHT_WHITE, format: ConsoleFormat::BOLD);
$this->rowStyle ??= ConsoleStyle::create();
$this->borderStyle ??= ConsoleStyle::create(color: ConsoleColor::GRAY);
}
/**
* Setzt die Spaltenüberschriften der Tabelle.
*/
public function setHeaders(array $headers): self
{
$this->headers = $headers;
$this->calculateColumnWidths();
return $this;
}
/**
* Fügt eine Zeile zur Tabelle hinzu.
*/
public function addRow(array $row): self
{
$this->rows[] = $row;
$this->calculateColumnWidths();
return $this;
}
/**
* Setzt alle Zeilen der Tabelle.
*/
public function setRows(array $rows): self
{
$this->rows = $rows;
$this->calculateColumnWidths();
return $this;
}
/**
* Setzt den Innenabstand der Zellen.
*/
public function setPadding(int $padding): self
{
$this->padding = max(0, $padding);
return $this;
}
/**
* Rendert die Tabelle und gibt den Text zurück.
*/
public function render(): string
{
if (empty($this->headers) && empty($this->rows)) {
return '';
}
$output = '';
if ($this->showBorders) {
$output .= $this->renderBorder('top') . "\n";
}
// Header
if (! empty($this->headers)) {
$output .= $this->renderRow($this->headers, $this->headerStyle) . "\n";
if ($this->showBorders) {
$output .= $this->renderBorder('middle') . "\n";
}
}
// Zeilen
foreach ($this->rows as $index => $row) {
$output .= $this->renderRow($row, $this->rowStyle) . "\n";
}
if ($this->showBorders) {
$output .= $this->renderBorder('bottom') . "\n";
}
return $output;
}
/**
* Rendert eine Zeile der Tabelle.
*/
private function renderRow(array $cells, ConsoleStyle $style): string
{
$output = '';
if ($this->showBorders) {
$output .= $this->borderStyle->apply('│');
}
foreach ($cells as $index => $cell) {
$width = $this->columnWidths[$index] ?? 10;
$cellText = (string)$cell;
// Linksbündige Ausrichtung mit exakter Breite
$paddedCell = str_pad($cellText, $width, ' ', STR_PAD_RIGHT);
// Padding hinzufügen
$padding = str_repeat(' ', $this->padding);
$finalCell = $padding . $paddedCell . $padding;
$output .= $style->apply($finalCell);
if ($this->showBorders) {
$output .= $this->borderStyle->apply('│');
}
}
return $output;
}
/**
* Rendert eine Trennlinie der Tabelle.
*/
private function renderBorder(string $type): string
{
$left = match($type) {
'top' => '┌',
'middle' => '├',
'row' => '├',
'bottom' => '└',
default => '│'
};
$right = match($type) {
'top' => '┐',
'middle' => '┤',
'row' => '┤',
'bottom' => '┘',
default => '│'
};
$horizontal = '─';
$junction = match($type) {
'top' => '┬',
'middle' => '┼',
'row' => '┼',
'bottom' => '┴',
default => '│'
};
$border = $left;
foreach ($this->columnWidths as $index => $width) {
$cellWidth = $width + ($this->padding * 2);
$border .= str_repeat($horizontal, $cellWidth);
if ($index < count($this->columnWidths) - 1) {
$border .= $junction;
}
}
$border .= $right;
return $this->borderStyle->apply($border);
}
/**
* Berechnet die Breite jeder Spalte basierend auf dem Inhalt.
*/
private function calculateColumnWidths(): void
{
$this->columnWidths = [];
// Header-Breiten
foreach ($this->headers as $index => $header) {
$this->columnWidths[$index] = mb_strlen((string)$header);
}
// Zeilen-Breiten
foreach ($this->rows as $row) {
foreach ($row as $index => $cell) {
$length = mb_strlen((string)$cell);
$this->columnWidths[$index] = max($this->columnWidths[$index] ?? 0, $length);
}
}
}
}
class_alias(
\App\Framework\Display\Components\Console\Table::class,
__NAMESPACE__ . '\\' . 'Table'
);

View File

@@ -4,122 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleStyle;
/**
* Rendert eine Textbox in der Konsole.
* @deprecated Use App\Framework\Display\Components\Console\TextBox instead
* This class is kept for backward compatibility only.
*/
final readonly class TextBox
{
public function __construct(
private string $content,
private int $width = 80,
private int $padding = 1,
private ?ConsoleStyle $borderStyle = null,
private ?ConsoleStyle $contentStyle = null,
private ?string $title = null
) {
}
public function render(): string
{
$borderStyle = $this->borderStyle ?? ConsoleStyle::create(color: ConsoleColor::GRAY);
$contentStyle = $this->contentStyle ?? ConsoleStyle::create();
$contentWidth = $this->width - ($this->padding * 2) - 2;
$lines = $this->wrapText($this->content, $contentWidth);
$output = '';
// Obere Linie
if ($this->title) {
$titleLength = mb_strlen($this->title);
$availableSpace = $this->width - $titleLength - 6; // Abzug für '[ ]' und Ränder
$leftPadding = max(0, (int)floor($availableSpace / 2));
$rightPadding = max(0, $availableSpace - $leftPadding);
$output .= $borderStyle->apply('┌' . str_repeat('─', $leftPadding) . '[ ' . $this->title . ' ]' . str_repeat('─', $rightPadding) . '┐') . "\n";
} else {
$output .= $borderStyle->apply('┌' . str_repeat('─', $this->width - 2) . '┐') . "\n";
}
// Padding oben
for ($i = 0; $i < $this->padding; $i++) {
$output .= $borderStyle->apply('│') . str_repeat(' ', $this->width - 2) . $borderStyle->apply('│') . "\n";
}
// Inhalt
foreach ($lines as $line) {
$lineLength = mb_strlen($line);
$spaces = $contentWidth - $lineLength;
$paddedLine = $line . str_repeat(' ', $spaces);
$output .= $borderStyle->apply('│') .
str_repeat(' ', $this->padding) .
$contentStyle->apply($paddedLine) .
str_repeat(' ', $this->padding) .
$borderStyle->apply('│') . "\n";
}
// Padding unten
for ($i = 0; $i < $this->padding; $i++) {
$output .= $borderStyle->apply('│') . str_repeat(' ', $this->width - 2) . $borderStyle->apply('│') . "\n";
}
// Untere Linie
$output .= $borderStyle->apply('└' . str_repeat('─', $this->width - 2) . '┘') . "\n";
return $output;
}
private function wrapText(string $text, int $width): array
{
$lines = explode("\n", $text);
$wrapped = [];
foreach ($lines as $line) {
if (mb_strlen($line) <= $width) {
$wrapped[] = $line;
} else {
$wrapped = array_merge($wrapped, $this->splitTextIntoLines($line, $width));
}
}
// Leere Zeile hinzufügen, falls keine Inhalte vorhanden
if (empty($wrapped)) {
$wrapped[] = '';
}
return $wrapped;
}
private function splitTextIntoLines(string $text, int $width): array
{
$lines = [];
$words = explode(' ', $text);
$currentLine = '';
foreach ($words as $word) {
$testLine = empty($currentLine) ? $word : $currentLine . ' ' . $word;
if (mb_strlen($testLine) <= $width) {
$currentLine = $testLine;
} else {
if (! empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $word;
} else {
// Wort ist länger als die Zeile - hart umbrechen
$lines[] = mb_substr($word, 0, $width);
$currentLine = mb_substr($word, $width);
}
}
}
if (! empty($currentLine)) {
$lines[] = $currentLine;
}
return $lines ?: [''];
}
}
class_alias(
\App\Framework\Display\Components\Console\TextBox::class,
__NAMESPACE__ . '\\' . 'TextBox'
);

View File

@@ -4,222 +4,11 @@ declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
/**
* TreeHelper zum Anzeigen hierarchischer Baumstrukturen in der Konsole.
* Ähnlich dem Symfony TreeHelper, aber angepasst an unser Styling-System.
* @deprecated Use App\Framework\Display\Components\Console\TreeHelper instead
* This class is kept for backward compatibility only.
*/
final class TreeHelper
{
private string $prefix = '';
private bool $isLastElement = true;
private ?ConsoleStyle $nodeStyle = null;
private ?ConsoleStyle $leafStyle = null;
private ?ConsoleStyle $lineStyle = null;
/**
* @var array<array{title: string, node: ?self, isLeaf: bool}>
*/
private array $nodes = [];
public function __construct(
private string $title = '',
#private readonly ConsoleOutput $output = new ConsoleOutput(),
) {
$this->nodeStyle = ConsoleStyle::create(color: ConsoleColor::BRIGHT_YELLOW, format: ConsoleFormat::BOLD);
$this->leafStyle = ConsoleStyle::create(color: ConsoleColor::WHITE);
$this->lineStyle = ConsoleStyle::create(color: ConsoleColor::GRAY);
}
/**
* Setzt den Stil für Knotentitel (Verzeichnisse/Kategorien).
*/
public function setNodeStyle(?ConsoleStyle $style): self
{
$this->nodeStyle = $style;
return $this;
}
/**
* Setzt den Stil für Blätter/Endpunkte.
*/
public function setLeafStyle(?ConsoleStyle $style): self
{
$this->leafStyle = $style;
return $this;
}
/**
* Setzt den Stil für Baumlinien.
*/
public function setLineStyle(?ConsoleStyle $style): self
{
$this->lineStyle = $style;
return $this;
}
/**
* Setzt den Haupttitel des Baums.
*/
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
/**
* Fügt einen Unterknoten (z.B. Unterverzeichnis) hinzu.
*/
public function addNode(string $title): self
{
$node = new self($title);
$node->nodeStyle = $this->nodeStyle;
$node->leafStyle = $this->leafStyle;
$node->lineStyle = $this->lineStyle;
$this->nodes[] = [
'title' => $title,
'node' => $node,
'isLeaf' => false,
];
return $node;
}
/**
* Fügt einen Endpunkt (z.B. Datei) hinzu.
*/
public function addLeaf(string $title): self
{
$this->nodes[] = [
'title' => $title,
'node' => null,
'isLeaf' => true,
];
return $this;
}
/**
* Zeigt die vollständige Baumstruktur an.
*/
public function display(ConsoleOutput $output): void
{
if (! empty($this->title)) {
$output->writeLine($this->title, $this->nodeStyle);
}
$this->displayTree($output);
}
/**
* Rendert die Baumstruktur und gibt den Text zurück.
*/
public function render(): string
{
$output = '';
if (! empty($this->title)) {
$output .= $this->nodeStyle->apply($this->title) . "\n";
}
$output .= $this->renderTree();
return $output;
}
/**
* Setzt den Präfix für die aktuelle Ebene.
* (Interne Methode für rekursives Rendern)
*/
private function setPrefix(string $prefix, bool $isLastElement): self
{
$this->prefix = $prefix;
$this->isLastElement = $isLastElement;
return $this;
}
/**
* Zeigt die Baumstruktur mit dem aktuellen Präfix an.
* (Interne Methode für rekursives Rendern)
*/
private function displayTree(ConsoleOutput $output): void
{
$count = count($this->nodes);
foreach ($this->nodes as $index => $item) {
$isLast = ($index === $count - 1);
$nodePrefix = $this->prefix . ($this->isLastElement ? ' ' : '│ ');
// Baumlinien und Verbindungen
$connector = $isLast ? '└── ' : '├── ';
$linePrefix = $this->prefix . $connector;
// Titel anzeigen
$style = $item['isLeaf'] ? $this->leafStyle : $this->nodeStyle;
$title = $linePrefix . $item['title'];
$output->writeLine(
$this->lineStyle->apply($linePrefix) .
$style->apply($item['title'])
);
// Unterelemente rekursiv anzeigen
if (! $item['isLeaf'] && $item['node'] !== null) {
$item['node']
->setPrefix($nodePrefix, $isLast)
->displayTree($output);
}
}
}
/**
* Rendert die Baumstruktur mit dem aktuellen Präfix und gibt den Text zurück.
* (Interne Methode für rekursives Rendern)
*/
private function renderTree(): string
{
$output = '';
$count = count($this->nodes);
foreach ($this->nodes as $index => $item) {
$isLast = ($index === $count - 1);
$nodePrefix = $this->prefix . ($this->isLastElement ? ' ' : '│ ');
// Baumlinien und Verbindungen
$connector = $isLast ? '└── ' : '├── ';
$linePrefix = $this->prefix . $connector;
// Titel formatieren
$style = $item['isLeaf'] ? $this->leafStyle : $this->nodeStyle;
$title = $item['title'];
$output .= $this->lineStyle->apply($linePrefix) .
$style->apply($title) . "\n";
// Unterelemente rekursiv rendern
if (! $item['isLeaf'] && $item['node'] !== null) {
$childOutput = $item['node']
->setPrefix($nodePrefix, $isLast)
->renderTree();
$output .= $childOutput;
}
}
return $output;
}
}
class_alias(
\App\Framework\Display\Components\Console\TreeHelper::class,
__NAMESPACE__ . '\\' . 'TreeHelper'
);

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\Animation\AnimationManager;
use App\Framework\Console\Animation\TuiAnimationRenderer;
use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
@@ -21,9 +23,11 @@ final class TuiRenderer
{
private MenuBar $menuBar;
private bool $menuBarInitialized = false;
private ?TuiAnimationRenderer $animationRenderer = null;
public function __construct(
private ConsoleOutputInterface $output
private ConsoleOutputInterface $output,
?AnimationManager $animationManager = null
) {
// Initialize menu bar with default items and submenus
$submenus = [
@@ -33,6 +37,11 @@ final class TuiRenderer
'Hilfe' => ['Hilfe anzeigen', 'Über'],
];
$this->menuBar = new MenuBar(['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe'], $submenus);
// Initialize animation renderer if animation manager is provided
if ($animationManager !== null) {
$this->animationRenderer = new TuiAnimationRenderer($animationManager);
}
}
/**
@@ -40,6 +49,11 @@ final class TuiRenderer
*/
public function render(TuiState $state, CommandHistory $history): void
{
// Update animations if animation renderer is available
if ($this->animationRenderer !== null) {
$this->animationRenderer->update(0.016); // ~60 FPS
}
// Get terminal size for layout
$terminalSize = TerminalSize::detect();

View File

@@ -8,8 +8,8 @@ use App\Framework\Attributes\Route;
use App\Framework\Core\AttributeMapper;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\ReflectionLegacy\WrappedReflectionClass;
use App\Framework\ReflectionLegacy\WrappedReflectionMethod;
final readonly class ConsoleCommandMapper implements AttributeMapper
{

View File

@@ -4,10 +4,15 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\Ansi\AnsiSequenceGenerator;
use App\Framework\Console\Terminal\TerminalCapabilities;
use App\Framework\Console\ValueObjects\ChoiceOptions;
use App\Framework\Console\ValueObjects\MenuOptions;
/**
* Refactored ConsoleInput - simplified and delegates to InteractivePrompter.
* Focuses on argument parsing only, interactive prompts are handled by InteractivePrompter.
*/
final class ConsoleInput implements ConsoleInputInterface
{
private array $arguments;
@@ -20,12 +25,21 @@ final class ConsoleInput implements ConsoleInputInterface
private ?ParsedArguments $parsedArguments = null;
private InteractivePrompter $prompter;
public function __construct(array $arguments, ?ConsoleOutputInterface $output = null, ?ArgumentParser $parser = null)
{
$this->parseArguments($arguments);
$this->output = $output ?? new ConsoleOutput();
$this->parser = $parser;
// Initialize prompter
$textWriter = new TextWriter(
new AnsiSequenceGenerator(),
new TerminalCapabilities()
);
$this->prompter = new InteractivePrompter($this->output, $textWriter);
// Use enhanced parser if available
if ($this->parser) {
$this->parsedArguments = $this->parser->parse($arguments);
@@ -82,7 +96,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function ask(string $question, string $default = ''): string
{
return $this->output->askQuestion($question, $default);
return $this->prompter->askQuestion($question, $default !== '' ? $default : null);
}
/**
@@ -90,15 +104,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function askPassword(string $question): string
{
echo $question . ": ";
// Verstecke Eingabe
system('stty -echo');
$password = trim(fgets(STDIN));
system('stty echo');
echo PHP_EOL;
return $password;
return $this->prompter->askPassword($question);
}
/**
@@ -106,7 +112,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function confirm(string $question, bool $default = false): bool
{
return $this->output->confirm($question, $default);
return $this->prompter->confirm($question, $default);
}
/**
@@ -117,9 +123,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function choice(string $question, array $choices, mixed $default = null): mixed
{
$menuOptions = MenuOptions::fromArray($choices);
return $this->choiceFromOptions($question, $menuOptions, $default);
return $this->prompter->choice($question, $choices, $default);
}
/**
@@ -127,18 +131,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function choiceFromOptions(string $question, MenuOptions $options, mixed $default = null): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($question);
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($option->label, null, $option->key);
}
}
return $menu->showSimple();
return $this->prompter->choiceFromOptions($question, $options, $default);
}
/**
@@ -149,9 +142,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function menu(string $title, array $items): mixed
{
$menuOptions = MenuOptions::fromArray($items);
return $this->menuFromOptions($title, $menuOptions);
return $this->prompter->menu($title, $items);
}
/**
@@ -159,18 +150,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function menuFromOptions(string $title, MenuOptions $options): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($title);
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($option->label, null, $option->key);
}
}
return $menu->showInteractive();
return $this->prompter->menuFromOptions($title, $options);
}
/**
@@ -181,10 +161,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function multiSelect(string $question, array $options): array
{
$choiceOptions = ChoiceOptions::fromArray($options);
$selected = $this->multiSelectFromOptions($question, $choiceOptions);
return $selected->getSelectedValues();
return $this->prompter->multiSelect($question, $options);
}
/**
@@ -192,28 +169,7 @@ final class ConsoleInput implements ConsoleInputInterface
*/
public function multiSelectFromOptions(string $question, ChoiceOptions $options): ChoiceOptions
{
$this->output->writeLine($question, ConsoleColor::BRIGHT_CYAN);
$this->output->writeLine("Mehrfachauswahl mit Komma getrennt (z.B. 1,3,5):");
$displayOptions = $options->toArray();
foreach ($displayOptions as $key => $option) {
$this->output->writeLine(" " . ($key + 1) . ": {$option->label}");
}
$this->output->write("Ihre Auswahl: ", ConsoleColor::BRIGHT_CYAN);
$input = trim(fgets(STDIN));
$selectedValues = [];
$indices = explode(',', $input);
foreach ($indices as $index) {
$index = (int)trim($index) - 1;
if (isset($displayOptions[$index])) {
$selectedValues[] = $displayOptions[$index]->value;
}
}
return $options->selectByValues($selectedValues);
return $this->prompter->multiSelectFromOptions($question, $options);
}
// ==========================================

View File

@@ -4,10 +4,22 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Ansi\AnsiSequenceGenerator;
use App\Framework\Console\Ansi\LinkFormatter;
use App\Framework\Console\Animation\Animation;
use App\Framework\Console\Animation\AnimationFactory;
use App\Framework\Console\Animation\AnimationManager;
use App\Framework\Console\Screen\Cursor;
use App\Framework\Console\Screen\Display;
use App\Framework\Console\Screen\ScreenManager;
use App\Framework\Console\Terminal\TerminalCapabilities;
use App\Framework\Console\Terminal\WindowTitleManager;
use App\Framework\Console\ValueObjects\TerminalStream;
/**
* Refactored ConsoleOutput - simplified and delegates to specialized classes.
* Uses TextWriter for text output, WindowTitleManager for titles, and InteractivePrompter for prompts.
*/
final readonly class ConsoleOutput implements ConsoleOutputInterface
{
public Cursor $cursor;
@@ -16,8 +28,30 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
public ScreenManager $screen;
private TextWriter $textWriter;
private WindowTitleManager $titleManager;
private InteractivePrompter $prompter;
private TerminalCapabilities $capabilities;
private AnsiSequenceGenerator $ansiGenerator;
private LinkFormatter $linkFormatter;
private AnimationManager $animationManager;
public function __construct()
{
$this->capabilities = new TerminalCapabilities();
$this->ansiGenerator = new AnsiSequenceGenerator();
$this->animationManager = new AnimationManager();
$this->textWriter = new TextWriter($this->ansiGenerator, $this->capabilities, $this->animationManager);
$this->titleManager = new WindowTitleManager($this->ansiGenerator, $this->capabilities);
$this->linkFormatter = new LinkFormatter($this->ansiGenerator, $this->capabilities);
$this->prompter = new InteractivePrompter($this, $this->textWriter);
$this->cursor = new Cursor($this);
$this->display = new Display($this);
$this->screen = new ScreenManager($this);
@@ -28,14 +62,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function write(string $message, ConsoleStyle|ConsoleColor|null $style = null): void
{
if ($style instanceof ConsoleColor) {
// Abwärtskompatibilität
echo $style->toAnsi() . $message . ConsoleColor::RESET->toAnsi();
} elseif ($style instanceof ConsoleStyle) {
echo $style->apply($message);
} else {
echo $message;
}
$this->textWriter->write($message, $style);
}
/**
@@ -43,7 +70,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeRaw(string $raw): void
{
echo $raw;
$this->textWriter->writeRaw($raw);
}
/**
@@ -51,13 +78,12 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeWindowTitle(string $title, int $mode = 0): void
{
// Only emit terminal escape sequences when output goes to a real terminal
// Skip for pipes, redirects, MCP mode, Claude Code execution, and any programmatic use
if (!$this->isTerminal()) {
return;
}
$this->writeRaw("\033]$mode;{$title}\007");
$oscMode = match($mode) {
1 => \App\Framework\Console\Terminal\OscMode::ICON,
2 => \App\Framework\Console\Terminal\OscMode::TITLE,
default => \App\Framework\Console\Terminal\OscMode::BOTH,
};
$this->titleManager->setTitle($title, $oscMode);
}
/**
@@ -65,7 +91,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeLine(string $message = '', ConsoleStyle|ConsoleColor|null $color = null): void
{
$this->write($message . PHP_EOL, $color);
$this->textWriter->writeLine($message, $color);
}
/**
@@ -73,7 +99,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeSuccess(string $message): void
{
$this->writeLine('✓ ' . $message, ConsoleStyle::success());
$this->textWriter->writeSuccess($message);
}
/**
@@ -81,7 +107,16 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeError(string $message): void
{
$this->writeLine('✗ ' . $message, ConsoleStyle::error());
$this->textWriter->writeError($message);
}
/**
* Schreibt eine Fehlerzeile zu stderr.
* Sollte für Fehlerausgaben verwendet werden, um sie von normaler Ausgabe zu trennen.
*/
public function writeErrorLine(string $message, ConsoleStyle|ConsoleColor|null $style = null): void
{
$this->textWriter->writeLineToStderr($message, $style);
}
/**
@@ -89,7 +124,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeWarning(string $message): void
{
$this->writeLine('⚠ ' . $message, ConsoleStyle::warning());
$this->textWriter->writeWarning($message);
}
/**
@@ -97,7 +132,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeInfo(string $message): void
{
$this->writeLine(' ' . $message, ConsoleStyle::info());
$this->textWriter->writeInfo($message);
}
/**
@@ -105,7 +140,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function newLine(int $count = 1): void
{
echo str_repeat(PHP_EOL, $count);
$this->textWriter->newLine($count);
}
/**
@@ -113,17 +148,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function askQuestion(string $question, ?string $default = null): string
{
$prompt = $question;
if ($default !== null) {
$prompt .= " [{$default}]";
}
$prompt .= ': ';
$this->write($prompt, ConsoleColor::BRIGHT_CYAN);
$answer = trim(fgets(STDIN));
return $answer === '' && $default !== null ? $default : $answer;
return $this->prompter->askQuestion($question, $default);
}
/**
@@ -131,14 +156,7 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function confirm(string $question, bool $default = false): bool
{
$defaultText = $default ? 'Y/n' : 'y/N';
$answer = $this->askQuestion("{$question} [{$defaultText}]");
if ($answer === '') {
return $default;
}
return in_array(strtolower($answer), ['y', 'yes', 'ja', '1', 'true']);
return $this->prompter->confirm($question, $default);
}
/**
@@ -146,6 +164,99 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function isTerminal(): bool
{
return function_exists('posix_isatty') && posix_isatty(STDOUT);
return $this->capabilities->isTerminal();
}
/**
* Get terminal capabilities
*/
public function getCapabilities(): TerminalCapabilities
{
return $this->capabilities;
}
/**
* Get ANSI sequence generator
*/
public function getAnsiGenerator(): AnsiSequenceGenerator
{
return $this->ansiGenerator;
}
/**
* Get link formatter
*/
public function getLinkFormatter(): LinkFormatter
{
return $this->linkFormatter;
}
/**
* Get window title manager
*/
public function getTitleManager(): WindowTitleManager
{
return $this->titleManager;
}
/**
* Animate text with a fade-in effect
*/
public function animateFadeIn(string $text, float $duration = 1.0): void
{
$animation = AnimationFactory::fadeIn($duration);
$this->textWriter->animateText($text, $animation);
}
/**
* Animate text with a fade-out effect
*/
public function animateFadeOut(string $text, float $duration = 1.0): void
{
$animation = AnimationFactory::fadeOut($duration);
$this->textWriter->animateText($text, $animation);
}
/**
* Animate text with typewriter effect
*/
public function animateTypewriter(string $text, float $charactersPerSecond = 10.0): void
{
$animation = AnimationFactory::typewriter($text, $charactersPerSecond);
$this->textWriter->animateText($text, $animation);
}
/**
* Animate text with marquee effect
*/
public function animateMarquee(string $text, int $width = 80, float $speed = 1.0): void
{
$animation = AnimationFactory::marquee($text, $width, $speed);
$this->textWriter->animateText($text, $animation);
}
/**
* Animate text with custom animation
*/
public function animateText(string $text, Animation $animation): void
{
$this->textWriter->animateText($text, $animation);
}
/**
* Update all active animations
* Should be called periodically if animations are active
*/
public function updateAnimations(float $deltaTime = 0.016): void
{
$this->textWriter->updateAnimations($deltaTime);
}
/**
* Get animation manager
*/
public function getAnimationManager(): AnimationManager
{
return $this->animationManager;
}
}

View File

@@ -4,12 +4,18 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Ansi\RgbColor;
use App\Framework\Console\Ansi\TruecolorStyle;
use App\Framework\Console\Terminal\TerminalCapabilities;
final readonly class ConsoleStyle
{
public function __construct(
public ?ConsoleColor $color = null,
public ?ConsoleFormat $format = null,
public ?ConsoleColor $background = null,
public ?RgbColor $truecolorForeground = null,
public ?RgbColor $truecolorBackground = null,
) {
}
@@ -24,6 +30,23 @@ final readonly class ConsoleStyle
return new self($color, $format, $backgroundColor);
}
/**
* Create style with truecolor support
*/
public static function createTruecolor(
?RgbColor $foreground = null,
?RgbColor $background = null,
?ConsoleFormat $format = null
): self {
return new self(
color: null,
format: $format,
background: null,
truecolorForeground: $foreground,
truecolorBackground: $background
);
}
public function toAnsi(): string
{
$codes = [];
@@ -60,6 +83,26 @@ final readonly class ConsoleStyle
return $ansi . $text . ConsoleColor::RESET->toAnsi();
}
/**
* Apply style with automatic truecolor support detection
* Falls back to standard colors if truecolor not supported
*/
public function applyWithTruecolor(string $text, TerminalCapabilities $capabilities): string
{
// If truecolor is specified, use it
if ($this->truecolorForeground !== null || $this->truecolorBackground !== null) {
$truecolorStyle = new TruecolorStyle(
$this->truecolorForeground,
$this->truecolorBackground,
$this->format
);
return $truecolorStyle->apply($text, new \App\Framework\Console\Ansi\AnsiSequenceGenerator(), $capabilities);
}
// Otherwise use standard style
return $this->apply($text);
}
// Vordefinierte Styles für häufige Anwendungen
public static function success(): self
{

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Console\ErrorRecovery;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Exceptions\ConsoleException;
use App\Framework\Exception\Core\AuthErrorCode;
use App\Framework\Exception\Core\ConsoleErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
@@ -42,6 +43,12 @@ final readonly class ConsoleErrorHandler
'trace' => $error->getTraceAsString(),
]);
if ($error instanceof ConsoleException) {
$this->recoveryService->handleCommandExecutionError($command, $error, $output);
return ExitCode::GENERAL_ERROR;
}
if ($error instanceof FrameworkException) {
return $this->handleFrameworkException($command, $error, $output);
}
@@ -189,6 +196,10 @@ final readonly class ConsoleErrorHandler
private function determineExitCode(\Throwable $error): ExitCode
{
if ($error instanceof ConsoleException) {
return ExitCode::GENERAL_ERROR;
}
if ($error instanceof FrameworkException) {
return match ($error->getErrorCode()) {
ConsoleErrorCode::COMMAND_NOT_FOUND => ExitCode::COMMAND_NOT_FOUND,

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Examples;
use App\Framework\Console\Animation\AnimationFactory;
use App\Framework\Console\Animation\AnimationManager;
use App\Framework\Console\Animation\EasingFunction;
use App\Framework\Console\Animation\Types\SlideDirection;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
/**
* Demo command for animation system
*/
final readonly class AnimationDemoCommand
{
#[ConsoleCommand(name: 'demo:animation', description: 'Demonstrate animation system features')]
public function demoAnimation(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🎬 Animation System Demo', \App\Framework\Console\ConsoleColor::BRIGHT_CYAN);
$output->writeLine(str_repeat('═', 60), \App\Framework\Console\ConsoleColor::GRAY);
$output->writeLine('');
// Create animation manager
$animationManager = $output instanceof ConsoleOutput ? $output->getAnimationManager() : new AnimationManager();
// Demo 1: Fade animations
$output->writeLine('1. Fade Animations:', \App\Framework\Console\ConsoleColor::BRIGHT_YELLOW);
$fadeIn = AnimationFactory::fadeIn(1.0);
$fadeIn->onComplete(function () use ($output) {
$output->writeLine('✓ Fade-in complete');
});
$animationManager->add($fadeIn);
$fadeIn->start();
// Simulate animation progress
$this->simulateAnimation($animationManager, 1.0);
$output->writeLine('');
// Demo 2: Typewriter animation
$output->writeLine('2. Typewriter Animation:', \App\Framework\Console\ConsoleColor::BRIGHT_YELLOW);
$typewriter = AnimationFactory::typewriter('Hello, this is a typewriter animation!', 15.0);
$animationManager->add($typewriter);
$typewriter->start();
$this->simulateTypewriter($animationManager, $typewriter, $output);
$output->writeLine('');
// Demo 3: Marquee animation
$output->writeLine('3. Marquee Animation:', \App\Framework\Console\ConsoleColor::BRIGHT_YELLOW);
$marquee = AnimationFactory::marquee('This is a scrolling marquee text that moves continuously', 50, 2.0);
$animationManager->add($marquee);
$marquee->start();
$this->simulateMarquee($animationManager, $marquee, $output, 3.0);
$output->writeLine('');
// Demo 4: Pulse animation
$output->writeLine('4. Pulse Animation:', \App\Framework\Console\ConsoleColor::BRIGHT_YELLOW);
$pulse = AnimationFactory::pulse(1.0, 1.0, 1.3, 2.0);
$pulse->start();
$animationManager->add($pulse);
$this->simulateAnimation($animationManager, 2.0);
$output->writeLine('');
// Demo 5: Slide animation
$output->writeLine('5. Slide Animation:', \App\Framework\Console\ConsoleColor::BRIGHT_YELLOW);
$slide = AnimationFactory::slideInFromLeft(20, 1.0, EasingFunction::EASE_OUT);
$slide->start();
$animationManager->add($slide);
$this->simulateAnimation($animationManager, 1.0);
$output->writeLine('');
$output->writeLine('✅ All animation demos completed!', \App\Framework\Console\ConsoleColor::BRIGHT_GREEN);
return ExitCode::SUCCESS;
}
/**
* Simulate animation updates
*/
private function simulateAnimation(AnimationManager $manager, float $duration): void
{
$startTime = microtime(true);
$lastUpdate = $startTime;
while (microtime(true) - $startTime < $duration) {
$currentTime = microtime(true);
$deltaTime = $currentTime - $lastUpdate;
$lastUpdate = $currentTime;
$manager->update($deltaTime);
usleep(16000); // ~60 FPS
}
}
/**
* Simulate typewriter animation with output
*/
private function simulateTypewriter(AnimationManager $manager, $typewriter, ConsoleOutput $output): void
{
$startTime = microtime(true);
$lastUpdate = $startTime;
$lastText = '';
while (!$typewriter->isComplete()) {
$currentTime = microtime(true);
$deltaTime = $currentTime - $lastUpdate;
$lastUpdate = $currentTime;
$manager->update($deltaTime);
$currentText = $typewriter->getText();
if ($currentText !== $lastText) {
$output->write("\r" . str_repeat(' ', 60) . "\r"); // Clear line
$output->write($currentText . '_'); // Cursor
$lastText = $currentText;
}
usleep(16000); // ~60 FPS
}
$output->writeLine(''); // New line after completion
}
/**
* Simulate marquee animation with output
*/
private function simulateMarquee(AnimationManager $manager, $marquee, ConsoleOutput $output, float $duration): void
{
$startTime = microtime(true);
$lastUpdate = $startTime;
$lastText = '';
while (microtime(true) - $startTime < $duration) {
$currentTime = microtime(true);
$deltaTime = $currentTime - $lastUpdate;
$lastUpdate = $currentTime;
$manager->update($deltaTime);
$currentText = $marquee->getDisplayText();
if ($currentText !== $lastText) {
$output->write("\r" . $currentText);
$lastText = $currentText;
}
usleep(16000); // ~60 FPS
}
$output->writeLine(''); // New line after completion
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Exceptions;
final class CommandExecutionException extends ConsoleException
{
public function __construct(string $commandName, \Throwable $previous)
{
parent::__construct("Ausführung des Kommandos '{$commandName}' fehlgeschlagen: {$previous->getMessage()}", 0, $previous);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console.Exceptions;
final class CommandMetadataNotFoundException extends ConsoleException
{
public function __construct(string $commandName)
{
parent::__construct("Metadaten für Kommando '{$commandName}' wurden nicht gefunden.");
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Exceptions;
final class ConsoleInitializationException extends ConsoleException
{
public function __construct(string $message, \Throwable $previous)
{
parent::__construct($message, 0, $previous);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Exceptions;
final class DuplicateCommandException extends ConsoleException
{
public function __construct(string $commandName)
{
parent::__construct("Kommando '{$commandName}' ist bereits registriert.");
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Exceptions;
final class InvalidCommandConfigurationException extends ConsoleException
{
public function __construct(string $commandName, string $className, string $methodName)
{
parent::__construct("Ungültige Kommando-Konfiguration für '{$commandName}' ({$className}::{$methodName}).");
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Console\Components\InteractiveMenu;
use App\Framework\Console\ValueObjects\ChoiceOptions;
use App\Framework\Console\ValueObjects\MenuOptions;
/**
* Interactive prompts extracted from ConsoleOutput and ConsoleInput.
* Handles all interactive user input (ask, confirm, choice, menu, etc.)
*/
final readonly class InteractivePrompter
{
public function __construct(
private ConsoleOutputInterface $output,
private TextWriter $textWriter
) {
}
/**
* Ask a question and wait for user input
*/
public function askQuestion(string $question, ?string $default = null): string
{
$prompt = $question;
if ($default !== null) {
$prompt .= " [{$default}]";
}
$prompt .= ': ';
$this->textWriter->write($prompt, ConsoleColor::BRIGHT_CYAN);
$answer = trim(fgets(STDIN) ?: '');
return $answer === '' && $default !== null ? $default : $answer;
}
/**
* Ask for password (hidden input)
*/
public function askPassword(string $question): string
{
$this->textWriter->write($question . ": ", ConsoleColor::BRIGHT_CYAN);
// Hide input
if (function_exists('system')) {
system('stty -echo');
}
$password = trim(fgets(STDIN) ?: '');
if (function_exists('system')) {
system('stty echo');
}
$this->textWriter->newLine();
return $password;
}
/**
* Ask for confirmation (yes/no)
*/
public function confirm(string $question, bool $default = false): bool
{
$defaultText = $default ? 'Y/n' : 'y/N';
$answer = $this->askQuestion("{$question} [{$defaultText}]");
if ($answer === '') {
return $default;
}
return in_array(strtolower($answer), ['y', 'yes', 'ja', '1', 'true'], true);
}
/**
* Show simple choice menu
*/
public function choice(string $question, array $choices, mixed $default = null): mixed
{
$menuOptions = MenuOptions::fromArray($choices);
return $this->choiceFromOptions($question, $menuOptions, $default);
}
/**
* Present choices using MenuOptions Value Object
*/
public function choiceFromOptions(string $question, MenuOptions $options, mixed $default = null): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($question);
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($option->label, null, $option->key);
}
}
return $menu->showSimple();
}
/**
* Show interactive menu with arrow key navigation
*/
public function menu(string $title, array $items): mixed
{
$menuOptions = MenuOptions::fromArray($items);
return $this->menuFromOptions($title, $menuOptions);
}
/**
* Show interactive menu using MenuOptions Value Object
*/
public function menuFromOptions(string $title, MenuOptions $options): mixed
{
$menu = new InteractiveMenu($this->output);
$menu->setTitle($title);
foreach ($options as $option) {
if ($option->isSeparator()) {
$menu->addSeparator();
} else {
$menu->addItem($option->label, null, $option->key);
}
}
return $menu->showInteractive();
}
/**
* Multi-select from options
*/
public function multiSelect(string $question, array $options): array
{
$choiceOptions = ChoiceOptions::fromArray($options);
$selected = $this->multiSelectFromOptions($question, $choiceOptions);
return $selected->getSelectedValues();
}
/**
* Multi-select using ChoiceOptions Value Object
*/
public function multiSelectFromOptions(string $question, ChoiceOptions $options): ChoiceOptions
{
$this->textWriter->writeLine($question, ConsoleColor::BRIGHT_CYAN);
$this->textWriter->writeLine("Mehrfachauswahl mit Komma getrennt (z.B. 1,3,5):");
$displayOptions = $options->toArray();
foreach ($displayOptions as $key => $option) {
$this->textWriter->writeLine(" " . ($key + 1) . ": {$option->label}");
}
$this->textWriter->write("Ihre Auswahl: ", ConsoleColor::BRIGHT_CYAN);
$input = trim(fgets(STDIN) ?: '');
$selectedValues = [];
$indices = explode(',', $input);
foreach ($indices as $index) {
$index = (int)trim($index) - 1;
if (isset($displayOptions[$index])) {
$selectedValues[] = $displayOptions[$index]->value;
}
}
return $options->selectByValues($selectedValues);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Adapters;
use App\Framework\Display\Components\DisplayComponentInterface;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
use App\Framework\Display\ValueObjects\OutputFormat;
/**
* Adapter for using Display Components in Console Layouts
*/
final readonly class DisplayComponentAdapter
{
public function __construct(
private ?ConsoleTheme $theme = null
) {
}
/**
* Convert Display Component to layout content string
*/
public function adapt(DisplayComponentInterface $component): string
{
// Only adapt console components
if ($component->getOutputFormat() !== OutputFormat::CONSOLE) {
throw new \InvalidArgumentException(
'Display Component must have CONSOLE output format to be used in layouts'
);
}
// Render the component
return $component->render();
}
/**
* Wrap Display Component in a Section
*/
public function wrapInSection(
DisplayComponentInterface $component,
string $sectionTitle,
\App\Framework\Console\Layout\ValueObjects\SectionStyle $style = \App\Framework\Console\Layout\ValueObjects\SectionStyle::DEFAULT
): \App\Framework\Console\Layout\Section {
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$section = new \App\Framework\Console\Layout\Section($sectionTitle, $style, $theme);
$section->addContent($this->adapt($component));
return $section;
}
/**
* Wrap Display Component in a Panel
*/
public function wrapInPanel(
DisplayComponentInterface $component,
string $panelTitle,
\App\Framework\Console\Layout\ValueObjects\PanelSize $size = \App\Framework\Console\Layout\ValueObjects\PanelSize::MEDIUM
): \App\Framework\Console\Layout\Panel {
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$panel = \App\Framework\Console\Layout\Panel::create($panelTitle, $size, $theme);
$panel->setContent($this->adapt($component));
return $panel;
}
/**
* Create adapter with theme
*/
public static function create(?ConsoleTheme $theme = null): self
{
return new self($theme);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Adapters;
use App\Framework\Console\ConsoleColor;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Maps Display Module themes to Console Layout themes
*/
final readonly class ThemeMapper
{
/**
* Map Display ConsoleTheme to Layout ConsoleTheme (passthrough for now)
*/
public static function mapTheme(ConsoleTheme $displayTheme): ConsoleTheme
{
// Display ConsoleTheme is already compatible with Layout ConsoleTheme
// This mapper exists for future extensibility
return $displayTheme;
}
/**
* Get default theme for layouts
*/
public static function getDefaultTheme(): ConsoleTheme
{
return \App\Framework\Display\Themes\DefaultThemes::getConsoleTheme('default');
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Components;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Footer component for page layouts
*/
final readonly class Footer
{
public function __construct(
private string $content,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Render the footer
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? \App\Framework\Display\Themes\DefaultThemes::getConsoleTheme('default');
$output = [];
if ($showBorders) {
// Top border
$output[] = '╔' . str_repeat('═', $width - 2) . '╗';
// Content
$footerStyle = ConsoleStyle::create(color: $theme->metadataColor);
$contentPadded = str_pad($this->content, $width - 4);
$output[] = '║ ' . $footerStyle->apply($contentPadded) . ' ║';
// Bottom border
$output[] = '╚' . str_repeat('═', $width - 2) . '╝';
} else {
$footerStyle = ConsoleStyle::create(color: $theme->metadataColor);
$output[] = $footerStyle->apply($this->content);
}
return implode("\n", $output);
}
/**
* Create footer
*/
public static function create(string $content, ?ConsoleTheme $theme = null): self
{
return new self($content, $theme);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Components;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Header component for page layouts
*/
final readonly class Header
{
public function __construct(
private string $title,
private ?string $subtitle = null,
private ?ConsoleTheme $theme = null,
private array $meta = [],
) {
}
/**
* Render the header
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? \App\Framework\Display\Themes\DefaultThemes::getConsoleTheme('default');
$output = [];
if ($showBorders) {
// Top border
$output[] = '╔' . str_repeat('═', $width - 2) . '╗';
// Title
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$titlePadded = str_pad($this->title, $width - 4);
$output[] = '║ ' . $titleStyle->apply($titlePadded) . ' ║';
// Subtitle (if provided)
if ($this->subtitle !== null) {
$subtitleStyle = ConsoleStyle::create(color: $theme->metadataColor);
$subtitlePadded = str_pad($this->subtitle, $width - 4);
$output[] = '║ ' . $subtitleStyle->apply($subtitlePadded) . ' ║';
}
// Meta information (if provided)
if (!empty($this->meta)) {
$metaStyle = ConsoleStyle::create(color: $theme->metadataColor);
$metaText = implode(' | ', $this->meta);
$metaPadded = str_pad($metaText, $width - 4);
$output[] = '║ ' . $metaStyle->apply($metaPadded) . ' ║';
}
// Bottom border
$output[] = '╚' . str_repeat('═', $width - 2) . '╝';
} else {
// No borders - just title and subtitle
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$output[] = $titleStyle->apply($this->title);
if ($this->subtitle !== null) {
$subtitleStyle = ConsoleStyle::create(color: $theme->metadataColor);
$output[] = $subtitleStyle->apply($this->subtitle);
}
if (!empty($this->meta)) {
$metaStyle = ConsoleStyle::create(color: $theme->metadataColor);
$output[] = $metaStyle->apply(implode(' | ', $this->meta));
}
}
return implode("\n", $output);
}
/**
* Create header with title
*/
public static function create(string $title, ?ConsoleTheme $theme = null): self
{
return new self($title, null, $theme);
}
/**
* Create header with title and subtitle
*/
public static function withSubtitle(string $title, string $subtitle, ?ConsoleTheme $theme = null): self
{
return new self($title, $subtitle, $theme);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Progress section component for layouts
*/
final readonly class ProgressSection
{
public function __construct(
private string $label,
private float $progress, // 0.0 to 1.0
private ?string $status = null,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Render progress section
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$output = [];
$barWidth = $width - 20; // Reserve space for percentage and label
$filled = (int)round($barWidth * $this->progress);
$empty = $barWidth - $filled;
$bar = str_repeat('█', $filled) . str_repeat('░', $empty);
$percentage = (int)round($this->progress * 100);
$labelStyle = ConsoleStyle::create(color: $theme->valueColor);
$barStyle = ConsoleStyle::create(color: $theme->numberColor);
$percentageStyle = ConsoleStyle::create(color: $theme->infoColor);
$progressLine = sprintf(
'%s [%s] %d%%',
$labelStyle->apply(str_pad($this->label, 15)),
$barStyle->apply($bar),
$percentage
);
if ($this->status !== null) {
$statusStyle = ConsoleStyle::create(color: $theme->metadataColor);
$progressLine .= ' ' . $statusStyle->apply($this->status);
}
if ($showBorders) {
$output[] = '┌' . str_repeat('─', $width - 2) . '┐';
$output[] = '│ ' . str_pad($progressLine, $width - 4) . ' │';
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
} else {
$output[] = $progressLine;
}
return implode("\n", $output);
}
/**
* Create progress section
*/
public static function create(string $label, float $progress, ?string $status = null, ?ConsoleTheme $theme = null): self
{
return new self($label, $progress, $status, $theme);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Components;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Sidebar component for page layouts
*/
final readonly class Sidebar
{
public function __construct(
private array $items,
private ?string $title = null,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Render the sidebar
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? \App\Framework\Display\Themes\DefaultThemes::getConsoleTheme('default');
$output = [];
if ($showBorders) {
// Top border
if ($this->title !== null) {
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$titlePadded = str_pad($this->title, $width - 4);
$output[] = '┌' . str_repeat('─', $width - 2) . '┐';
$output[] = '│ ' . $titleStyle->apply($titlePadded) . ' │';
$output[] = '├' . str_repeat('─', $width - 2) . '┤';
} else {
$output[] = '┌' . str_repeat('─', $width - 2) . '┐';
}
// Items
$itemStyle = ConsoleStyle::create(color: $theme->valueColor);
foreach ($this->items as $item) {
$itemPadded = str_pad($item, $width - 4);
$output[] = '│ ' . $itemStyle->apply($itemPadded) . ' │';
}
// Bottom border
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
} else {
if ($this->title !== null) {
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$output[] = $titleStyle->apply($this->title);
}
$itemStyle = ConsoleStyle::create(color: $theme->valueColor);
foreach ($this->items as $item) {
$output[] = $itemStyle->apply(' ' . $item);
}
}
return implode("\n", $output);
}
/**
* Create sidebar
*/
public static function create(array $items, ?string $title = null, ?ConsoleTheme $theme = null): self
{
return new self($items, $title, $theme);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Status indicator component
*/
final readonly class StatusIndicator
{
public function __construct(
private string $label,
private StatusType $type,
private ?string $message = null,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Render status indicator
*/
public function render(int $width = 0, bool $showBorders = false): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$icon = $this->getIcon();
$color = $this->getColor($theme);
$iconStyle = ConsoleStyle::create(color: $color, format: ConsoleFormat::BOLD);
$labelStyle = ConsoleStyle::create(color: $theme->valueColor);
$output = $iconStyle->apply($icon) . ' ' . $labelStyle->apply($this->label);
if ($this->message !== null) {
$messageStyle = ConsoleStyle::create(color: $theme->metadataColor);
$output .= ' ' . $messageStyle->apply('(' . $this->message . ')');
}
return $output;
}
private function getIcon(): string
{
return match ($this->type) {
StatusType::SUCCESS => '✓',
StatusType::ERROR => '✗',
StatusType::WARNING => '⚠',
StatusType::INFO => '',
StatusType::PENDING => '…',
StatusType::LOADING => '⟳',
};
}
private function getColor(ConsoleTheme $theme): ConsoleColor
{
return match ($this->type) {
StatusType::SUCCESS => ConsoleColor::GREEN,
StatusType::ERROR => $theme->errorColor,
StatusType::WARNING => $theme->warningColor,
StatusType::INFO => $theme->infoColor,
StatusType::PENDING => ConsoleColor::GRAY,
StatusType::LOADING => ConsoleColor::CYAN,
};
}
/**
* Create success indicator
*/
public static function success(string $label, ?string $message = null, ?ConsoleTheme $theme = null): self
{
return new self($label, StatusType::SUCCESS, $message, $theme);
}
/**
* Create error indicator
*/
public static function error(string $label, ?string $message = null, ?ConsoleTheme $theme = null): self
{
return new self($label, StatusType::ERROR, $message, $theme);
}
/**
* Create warning indicator
*/
public static function warning(string $label, ?string $message = null, ?ConsoleTheme $theme = null): self
{
return new self($label, StatusType::WARNING, $message, $theme);
}
/**
* Create info indicator
*/
public static function info(string $label, ?string $message = null, ?ConsoleTheme $theme = null): self
{
return new self($label, StatusType::INFO, $message, $theme);
}
/**
* Create status indicator
*/
public static function create(string $label, StatusType $type, ?string $message = null, ?ConsoleTheme $theme = null): self
{
return new self($label, $type, $message, $theme);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Layout\ValueObjects\GridConfig;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
use App\Framework\Console\Layout\ValueObjects\PanelSize;
/**
* Multi-panel grid layout for dashboard-style outputs
*/
final class DashboardLayout extends LayoutContainer
{
private array $panels = [];
private ?GridConfig $gridConfig = null;
public function __construct(
?TerminalSize $customTerminalSize = null,
?LayoutOptions $options = null,
?GridConfig $gridConfig = null
) {
parent::__construct($customTerminalSize, $options);
$this->gridConfig = $gridConfig ?? ($this->isResponsive()
? GridConfig::fromBreakpoint($this->terminalSize->getBreakpoint())
: new GridConfig(columns: 2));
}
/**
* Add a panel
*/
public function addPanel(Panel $panel): self
{
$this->panels[] = $panel;
return $this;
}
/**
* Add panel with content callback
*/
public function addPanelWithContent(string $title, PanelSize $size, callable $contentCallback): self
{
$panel = Panel::create($title, $size, $this->theme);
$content = $contentCallback($panel);
$panel->setContent(is_string($content) ? $content : '');
$this->panels[] = $panel;
return $this;
}
/**
* Render the dashboard layout
*/
public function render(): string
{
$width = $this->getEffectiveWidth();
$columns = $this->gridConfig->columns;
$gap = $this->gridConfig->gap;
if (empty($this->panels)) {
return '';
}
// Group panels by rows
$rows = [];
$currentRow = [];
$currentRowSpan = 0;
foreach ($this->panels as $panel) {
$panelSpan = $panel->getSize()->getColumnSpan($columns);
$totalSpan = $currentRowSpan + $panelSpan;
if ($totalSpan > $columns || empty($currentRow)) {
if (!empty($currentRow)) {
$rows[] = $currentRow;
}
$currentRow = [$panel];
$currentRowSpan = $panelSpan;
} else {
$currentRow[] = $panel;
$currentRowSpan = $totalSpan;
}
}
if (!empty($currentRow)) {
$rows[] = $currentRow;
}
// Render rows
$output = [];
foreach ($rows as $row) {
$rowOutput = $this->renderRow($row, $width, $columns, $gap);
$output[] = $rowOutput;
$output[] = ''; // Space between rows
}
return implode("\n", $output);
}
private function renderRow(array $panels, int $totalWidth, int $columns, int $gap): string
{
$columnWidth = $this->gridConfig->getColumnWidth($totalWidth, 1);
$panelOutputs = [];
foreach ($panels as $panel) {
$panelSpan = $panel->getSize()->getColumnSpan($columns);
$panelWidth = $this->gridConfig->getColumnWidth($totalWidth, $panelSpan);
$panelOutputs[] = $panel->render($panelWidth, $this->options->showBorders);
}
// Combine panels horizontally
return $this->combinePanelsHorizontally($panelOutputs, $gap);
}
private function combinePanelsHorizontally(array $panelOutputs, int $gap): string
{
if (count($panelOutputs) === 1) {
return $panelOutputs[0];
}
// Split each panel into lines
$panelLines = [];
$maxLines = 0;
foreach ($panelOutputs as $panelOutput) {
$lines = explode("\n", $panelOutput);
$panelLines[] = $lines;
$maxLines = max($maxLines, count($lines));
}
// Combine lines
$combined = [];
for ($i = 0; $i < $maxLines; $i++) {
$lineParts = [];
foreach ($panelLines as $lines) {
$lineParts[] = $lines[$i] ?? str_repeat(' ', mb_strlen($lines[0] ?? ''));
}
$combined[] = implode(str_repeat(' ', $gap), $lineParts);
}
return implode("\n", $combined);
}
/**
* Create dashboard layout
*/
public static function create(?TerminalSize $terminalSize = null, ?LayoutOptions $options = null, ?GridConfig $gridConfig = null): self
{
return new self($terminalSize, $options, $gridConfig);
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Examples;
use App\Framework\Console\Layout\DashboardLayout;
use App\Framework\Console\Layout\LayoutFactory;
use App\Framework\Console\Layout\PageLayout;
use App\Framework\Console\Layout\Section;
use App\Framework\Console\Layout\SectionLayout;
use App\Framework\Console\Layout\SimpleLayout;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
use App\Framework\Console\Layout\ValueObjects\PanelSize;
use App\Framework\Console\Layout\ValueObjects\SectionStyle;
use App\Framework\Display\Components\ComponentFactory;
use App\Framework\Display\ValueObjects\DisplayOptions;
use App\Framework\Display\ValueObjects\OutputFormat;
/**
* Examples demonstrating Console Layout usage
*/
final readonly class LayoutExamples
{
/**
* Example 1: PageLayout with Sections
*/
public static function pageLayoutExample(): string
{
$layout = LayoutFactory::create()->createPageLayout()
->withHeader('System Status', 'v1.0.0', ['Last updated: ' . date('Y-m-d H:i:s')])
->withSidebar(['Commands', 'Health', 'Metrics', 'Settings'])
->addSection(
Section::create('System Health')
->addContent('CPU: 45%')
->addContent('Memory: 2.1GB / 8GB')
->addContent('Disk: 120GB / 500GB')
)
->addSection(
Section::info('Database Status')
->addContent('Connected: Yes')
->addContent('Queries: 1,234')
->addContent('Response Time: 12ms')
)
->withFooter('System running normally');
return $layout->render();
}
/**
* Example 2: SectionLayout with multiple sections
*/
public static function sectionLayoutExample(): string
{
$layout = LayoutFactory::create()->createSectionLayout()
->addSection(
Section::success('Completed Tasks')
->addContent('Task 1: Done')
->addContent('Task 2: Done')
->addContent('Task 3: Done')
)
->addSection(
Section::warning('Pending Tasks')
->addContent('Task 4: In Progress')
->addContent('Task 5: Waiting')
)
->addSection(
Section::error('Failed Tasks')
->addContent('Task 6: Error - Connection timeout')
);
return $layout->render();
}
/**
* Example 3: DashboardLayout with Display Components
*/
public static function dashboardLayoutExample(): string
{
$displayOptions = DisplayOptions::default();
$layout = LayoutFactory::create()->createDashboardLayout()
->addPanelWithContent('Stats', PanelSize::MEDIUM, function ($panel) use ($displayOptions) {
$stats = ComponentFactory::createStats(OutputFormat::CONSOLE, $displayOptions, 150, 'Users');
return $stats->render();
})
->addPanelWithContent('Recent Activity', PanelSize::LARGE, function ($panel) use ($displayOptions) {
$table = ComponentFactory::createTable(OutputFormat::CONSOLE, $displayOptions);
$table->setHeaders(['Time', 'Action', 'User']);
$table->setRows([
['10:30', 'Login', 'admin'],
['10:25', 'Update', 'user1'],
['10:20', 'Create', 'user2'],
]);
return $table->render();
})
->addPanelWithContent('Status', PanelSize::SMALL, function ($panel) {
return '✓ All systems operational';
});
return $layout->render();
}
/**
* Example 4: SimpleLayout
*/
public static function simpleLayoutExample(): string
{
$layout = LayoutFactory::create()->createSimpleLayout()
->addContent('Welcome to the Console Layout System!')
->addContent('')
->addContent('This is a simple container layout.')
->addContent('Perfect for basic use cases.');
return $layout->render();
}
/**
* Example 5: Layout without borders
*/
public static function noBordersExample(): string
{
$options = LayoutOptions::withoutBorders();
$layout = LayoutFactory::create()->createSectionLayout($options)
->addSection(
Section::create('Clean Layout')
->addContent('This layout has no borders')
->addContent('Great for minimalist designs')
);
return $layout->render();
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Helpers;
use App\Framework\Console\Layout\Adapters\DisplayComponentAdapter;
use App\Framework\Console\Layout\Panel;
use App\Framework\Console\Layout\Section;
use App\Framework\Console\Layout\ValueObjects\PanelSize;
use App\Framework\Console\Layout\ValueObjects\SectionStyle;
use App\Framework\Display\Components\DisplayComponentInterface;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Helper methods for easy integration of Display Components in Layouts
*/
final readonly class DisplayComponentHelper
{
public function __construct(
private ?ConsoleTheme $theme = null
) {
}
/**
* Create a Section from a Display Component
*/
public function createSection(
DisplayComponentInterface $component,
string $title,
SectionStyle $style = SectionStyle::DEFAULT
): Section {
$adapter = DisplayComponentAdapter::create($this->theme);
return $adapter->wrapInSection($component, $title, $style);
}
/**
* Create a Panel from a Display Component
*/
public function createPanel(
DisplayComponentInterface $component,
string $title,
PanelSize $size = PanelSize::MEDIUM
): Panel {
$adapter = DisplayComponentAdapter::create($this->theme);
return $adapter->wrapInPanel($component, $title, $size);
}
/**
* Render a Display Component directly (for use in layouts)
*/
public function render(DisplayComponentInterface $component): string
{
$adapter = DisplayComponentAdapter::create($this->theme);
return $adapter->adapt($component);
}
/**
* Create helper with theme
*/
public static function create(?ConsoleTheme $theme = null): self
{
return new self($theme);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Interactive;
use App\Framework\Console\Layout\Section;
use App\Framework\Console\Layout\ValueObjects\SectionStyle;
/**
* Collapsible section wrapper
*
* Note: This is a convenience wrapper. Sections can be made collapsible directly
* by setting the collapsible parameter in the Section constructor.
*/
final class CollapsibleSection
{
public function __construct(
private Section $section,
private bool $collapsed = false
) {
}
/**
* Set collapsed state
*/
public function setCollapsed(bool $collapsed): self
{
$this->collapsed = $collapsed;
$this->section->setCollapsed($collapsed);
return $this;
}
/**
* Toggle collapsed state
*/
public function toggle(): self
{
$this->collapsed = !$this->collapsed;
$this->section->setCollapsed($this->collapsed);
return $this;
}
/**
* Render the collapsible section
*/
public function render(int $width, bool $showBorders = true): string
{
$this->section->setCollapsed($this->collapsed);
return $this->section->render($width, $showBorders);
}
/**
* Get underlying section
*/
public function getSection(): Section
{
return $this->section;
}
/**
* Check if section is collapsed
*/
public function isCollapsed(): bool
{
return $this->collapsed;
}
/**
* Create collapsible section
*/
public static function create(string $title, \App\Framework\Console\Layout\ValueObjects\SectionStyle $style = \App\Framework\Console\Layout\ValueObjects\SectionStyle::DEFAULT, bool $collapsed = false): self
{
$section = new Section($title, $style, null, true); // collapsible = true
$section->setCollapsed($collapsed);
return new self($section, $collapsed);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Interactive;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Tab navigation component
*/
final readonly class Tabs
{
private string $activeTab;
/**
* @param array<string, string> $tabs Tab name => content
*/
public function __construct(
private array $tabs,
string $activeTab = '',
private ?ConsoleTheme $theme = null,
) {
if (empty($this->activeTab) && !empty($this->tabs)) {
$this->activeTab = array_key_first($this->tabs);
}
}
/**
* Render tabs
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$output = [];
// Tab headers
$tabHeaders = [];
$tabWidth = (int)($width / count($this->tabs));
foreach ($this->tabs as $tabName => $content) {
$isActive = $tabName === $this->activeTab;
$style = $isActive
? ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD)
: ConsoleStyle::create(color: $theme->valueColor);
$tabText = str_pad($tabName, $tabWidth - 2);
$tabHeaders[] = $style->apply($isActive ? '▶ ' . $tabText : ' ' . $tabText);
}
if ($showBorders) {
$output[] = '┌' . implode('', array_map(fn($h) => str_repeat('─', $tabWidth - 2), $tabHeaders)) . '┐';
$output[] = '│' . implode('│', $tabHeaders) . '│';
$output[] = '├' . str_repeat('─', $width - 2) . '┤';
} else {
$output[] = implode(' | ', $tabHeaders);
}
// Active tab content
if (isset($this->tabs[$this->activeTab])) {
$content = $this->tabs[$this->activeTab];
$contentLines = explode("\n", $content);
foreach ($contentLines as $line) {
if ($showBorders) {
$output[] = '│ ' . str_pad($line, $width - 4) . ' │';
} else {
$output[] = $line;
}
}
}
if ($showBorders) {
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
}
return implode("\n", $output);
}
/**
* Set active tab
*/
public function setActiveTab(string $tabName): Tabs
{
if (!isset($this->tabs[$tabName])) {
throw new \InvalidArgumentException("Tab '{$tabName}' does not exist");
}
return new Tabs($this->tabs, $tabName, $this->theme);
}
/**
* Create tabs
*/
public static function create(array $tabs, string $activeTab = '', ?ConsoleTheme $theme = null): self
{
return new self($tabs, $activeTab, $theme);
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Base container for all console layouts
*/
abstract class LayoutContainer implements LayoutInterface
{
protected TerminalSize $terminalSize;
protected LayoutOptions $options;
protected ConsoleTheme $theme;
public function __construct(
protected ?TerminalSize $customTerminalSize = null,
?LayoutOptions $options = null
) {
$this->terminalSize = $this->customTerminalSize ?? TerminalSize::detect();
$this->options = $options ?? LayoutOptions::default();
$this->theme = $this->options->theme ?? DefaultThemes::getConsoleTheme('default');
}
public function getTerminalSize(): TerminalSize
{
return $this->terminalSize;
}
public function getOptions(): LayoutOptions
{
return $this->options;
}
public function getTheme(): ConsoleTheme
{
return $this->theme;
}
/**
* Get effective width for layout (considering padding and margins)
*/
protected function getEffectiveWidth(): int
{
$width = $this->options->width > 0
? $this->options->width
: $this->terminalSize->getUsableWidth();
// Subtract margins and padding
$totalMargin = ($this->options->margin * 2);
$totalPadding = ($this->options->padding * 2);
return max(40, $width - $totalMargin - $totalPadding);
}
/**
* Get effective height for layout
*/
protected function getEffectiveHeight(): int
{
return $this->terminalSize->getUsableHeight();
}
/**
* Check if layout should be responsive
*/
protected function isResponsive(): bool
{
return $this->options->responsive;
}
/**
* Get border characters based on border style
*/
protected function getBorderChars(): array
{
return match ($this->options->borderStyle) {
'double' => [
'top-left' => '╔',
'top-right' => '╗',
'bottom-left' => '╚',
'bottom-right' => '╝',
'horizontal' => '═',
'vertical' => '║',
'cross' => '╬',
'top-cross' => '╦',
'bottom-cross' => '╩',
'left-cross' => '╠',
'right-cross' => '╣',
],
'rounded' => [
'top-left' => '╭',
'top-right' => '╮',
'bottom-left' => '╰',
'bottom-right' => '╯',
'horizontal' => '─',
'vertical' => '│',
'cross' => '┼',
'top-cross' => '┬',
'bottom-cross' => '┴',
'left-cross' => '├',
'right-cross' => '┤',
],
'none' => [
'top-left' => '',
'top-right' => '',
'bottom-left' => '',
'bottom-right' => '',
'horizontal' => '',
'vertical' => '',
'cross' => '',
'top-cross' => '',
'bottom-cross' => '',
'left-cross' => '',
'right-cross' => '',
],
default => [ // 'single'
'top-left' => '┌',
'top-right' => '┐',
'bottom-left' => '└',
'bottom-right' => '┘',
'horizontal' => '─',
'vertical' => '│',
'cross' => '┼',
'top-cross' => '┬',
'bottom-cross' => '┴',
'left-cross' => '├',
'right-cross' => '┤',
],
};
}
/**
* Render a horizontal border line
*/
protected function renderHorizontalBorder(string $left = '', string $right = '', ?string $middle = null): string
{
if (!$this->options->showBorders) {
return '';
}
$chars = $this->getBorderChars();
$width = $this->getEffectiveWidth();
$horizontal = str_repeat($chars['horizontal'], $width);
$leftChar = $left !== '' ? $left : $chars['horizontal'];
$rightChar = $right !== '' ? $right : $chars['horizontal'];
if ($middle !== null) {
$horizontal = $leftChar . $middle . $rightChar;
} else {
$horizontal = $leftChar . $horizontal . $rightChar;
}
return $horizontal;
}
/**
* Wrap content with padding
*/
protected function wrapWithPadding(string $content): string
{
if ($this->options->padding === 0) {
return $content;
}
$padding = str_repeat(' ', $this->options->padding);
$lines = explode("\n", $content);
$wrapped = [];
foreach ($lines as $line) {
$wrapped[] = $padding . $line;
}
return implode("\n", $wrapped);
}
/**
* Wrap content with borders
*/
protected function wrapWithBorders(string $content): string
{
if (!$this->options->showBorders) {
return $content;
}
$chars = $this->getBorderChars();
$width = $this->getEffectiveWidth();
$lines = explode("\n", $content);
$bordered = [];
// Top border
$bordered[] = $chars['top-left'] . str_repeat($chars['horizontal'], $width) . $chars['top-right'];
// Content lines with vertical borders
foreach ($lines as $line) {
$paddedLine = str_pad($line, $width);
$bordered[] = $chars['vertical'] . $paddedLine . $chars['vertical'];
}
// Bottom border
$bordered[] = $chars['bottom-left'] . str_repeat($chars['horizontal'], $width) . $chars['bottom-right'];
return implode("\n", $bordered);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Layout\ValueObjects\GridConfig;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Factory for creating console layouts
*/
final readonly class LayoutFactory
{
public function __construct(
private ?ConsoleTheme $theme = null,
private ?TerminalSize $terminalSize = null
) {
}
/**
* Create a page layout
*/
public function createPageLayout(?LayoutOptions $options = null): PageLayout
{
$options = $this->mergeOptions($options);
return PageLayout::create($this->terminalSize, $options);
}
/**
* Create a section layout
*/
public function createSectionLayout(?LayoutOptions $options = null): SectionLayout
{
$options = $this->mergeOptions($options);
return SectionLayout::create($this->terminalSize, $options);
}
/**
* Create a dashboard layout
*/
public function createDashboardLayout(?LayoutOptions $options = null, ?GridConfig $gridConfig = null): DashboardLayout
{
$options = $this->mergeOptions($options);
return DashboardLayout::create($this->terminalSize, $options, $gridConfig);
}
/**
* Create a simple layout
*/
public function createSimpleLayout(?LayoutOptions $options = null): SimpleLayout
{
$options = $this->mergeOptions($options);
return SimpleLayout::create($this->terminalSize, $options);
}
/**
* Merge options with default theme
*/
private function mergeOptions(?LayoutOptions $options): LayoutOptions
{
if ($options === null) {
return LayoutOptions::withTheme($this->theme ?? DefaultThemes::getConsoleTheme('default'));
}
if ($options->theme === null && $this->theme !== null) {
return LayoutOptions::withTheme($this->theme);
}
return $options;
}
/**
* Create factory with theme
*/
public static function withTheme(ConsoleTheme $theme): self
{
return new self($theme);
}
/**
* Create factory with terminal size
*/
public static function withTerminalSize(TerminalSize $terminalSize): self
{
return new self(null, $terminalSize);
}
/**
* Create default factory
*/
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
/**
* Interface for all console layouts
*/
interface LayoutInterface
{
/**
* Render the layout and return the output string
*/
public function render(): string;
/**
* Get the terminal size for this layout
*/
public function getTerminalSize(): TerminalSize;
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Rendering engine for all layout types
*/
final readonly class LayoutRenderer
{
public function __construct(
private ConsoleTheme $theme
) {
}
/**
* Render a box with borders
*/
public function renderBox(string $content, int $width, bool $showBorders = true, string $title = ''): string
{
$lines = explode("\n", $content);
$output = [];
if ($showBorders) {
// Top border
if ($title !== '') {
$titleWidth = mb_strlen($title);
$availableSpace = $width - $titleWidth - 4; // Space for '[ ' and ' ]'
$leftPadding = max(0, (int)floor($availableSpace / 2));
$rightPadding = max(0, $availableSpace - $leftPadding);
$topBorder = '┌' . str_repeat('─', $leftPadding) . '[ ' . $title . ' ]' . str_repeat('─', $rightPadding) . '┐';
$output[] = $topBorder;
} else {
$output[] = '┌' . str_repeat('─', $width - 2) . '┐';
}
// Content lines
foreach ($lines as $line) {
$paddedLine = str_pad($line, $width - 4);
$output[] = '│ ' . $paddedLine . ' │';
}
// Bottom border
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
} else {
$output = $lines;
}
return implode("\n", $output);
}
/**
* Render a horizontal divider
*/
public function renderDivider(int $width, ?string $label = null, bool $double = false): string
{
$char = $double ? '═' : '─';
if ($label !== null) {
$labelLength = mb_strlen($label);
$availableSpace = $width - $labelLength - 4; // Space for ' ' on both sides
$leftWidth = max(0, (int)floor($availableSpace / 2));
$rightWidth = max(0, $availableSpace - $leftWidth);
return str_repeat($char, $leftWidth) . ' ' . $label . ' ' . str_repeat($char, $rightWidth);
}
return str_repeat($char, $width);
}
/**
* Render a header
*/
public function renderHeader(string $title, ?string $subtitle = null, int $width = 80): string
{
$output = [];
$titleStyle = ConsoleStyle::create(color: $this->theme->classNameColor, format: ConsoleFormat::BOLD);
// Top border
$output[] = '╔' . str_repeat('═', $width - 2) . '╗';
// Title
$titlePadded = str_pad($title, $width - 4);
$output[] = '║ ' . $titleStyle->apply($titlePadded) . ' ║';
// Subtitle (if provided)
if ($subtitle !== null) {
$subtitleStyle = ConsoleStyle::create(color: $this->theme->metadataColor);
$subtitlePadded = str_pad($subtitle, $width - 4);
$output[] = '║ ' . $subtitleStyle->apply($subtitlePadded) . ' ║';
}
// Bottom border
$output[] = '╚' . str_repeat('═', $width - 2) . '╝';
return implode("\n", $output);
}
/**
* Render a footer
*/
public function renderFooter(string $content, int $width = 80): string
{
$output = [];
$footerStyle = ConsoleStyle::create(color: $this->theme->metadataColor);
// Top border
$output[] = '╔' . str_repeat('═', $width - 2) . '╗';
// Content
$contentPadded = str_pad($content, $width - 4);
$output[] = '║ ' . $footerStyle->apply($contentPadded) . ' ║';
// Bottom border
$output[] = '╚' . str_repeat('═', $width - 2) . '╝';
return implode("\n", $output);
}
/**
* Apply ANSI formatting to text
*/
public function applyStyle(string $text, ConsoleColor $color, ?ConsoleFormat $format = null): string
{
$style = ConsoleStyle::create(color: $color, format: $format);
return $style->apply($text);
}
/**
* Wrap text to fit width
*/
public function wrapText(string $text, int $width): array
{
$lines = explode("\n", $text);
$wrapped = [];
foreach ($lines as $line) {
if (mb_strlen($line) <= $width) {
$wrapped[] = $line;
} else {
$wrapped = array_merge($wrapped, $this->splitLine($line, $width));
}
}
return $wrapped;
}
private function splitLine(string $line, int $width): array
{
$words = explode(' ', $line);
$currentLine = '';
$result = [];
foreach ($words as $word) {
$testLine = empty($currentLine) ? $word : $currentLine . ' ' . $word;
if (mb_strlen($testLine) <= $width) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$result[] = $currentLine;
$currentLine = $word;
} else {
$result[] = mb_substr($word, 0, $width);
$currentLine = mb_substr($word, $width);
}
}
}
if (!empty($currentLine)) {
$result[] = $currentLine;
}
return $result ?: [''];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Navigation;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Breadcrumbs navigation component
*/
final readonly class Breadcrumbs
{
public function __construct(
private array $items,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Render breadcrumbs
*/
public function render(int $width, bool $showBorders = false): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$separator = ' > ';
$separatorStyle = ConsoleStyle::create(color: $theme->separatorColor);
$itemStyle = ConsoleStyle::create(color: $theme->valueColor);
$lastItemStyle = ConsoleStyle::create(color: $theme->classNameColor);
$breadcrumbParts = [];
$itemCount = count($this->items);
foreach ($this->items as $index => $item) {
$isLast = ($index === $itemCount - 1);
$style = $isLast ? $lastItemStyle : $itemStyle;
$breadcrumbParts[] = $style->apply($item);
if (!$isLast) {
$breadcrumbParts[] = $separatorStyle->apply($separator);
}
}
$breadcrumb = implode('', $breadcrumbParts);
if ($showBorders) {
// Calculate text length without ANSI codes (approximation)
$textLength = mb_strlen(preg_replace('/\033\[[0-9;]*m/', '', $breadcrumb));
return '┌─ ' . $breadcrumb . str_repeat('─', max(0, $width - $textLength - 5)) . '┐';
}
return $breadcrumb;
}
/**
* Create breadcrumbs
*/
public static function create(array $items, ?ConsoleTheme $theme = null): self
{
return new self($items, $theme);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\Navigation;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Navigation menu component
*/
final readonly class NavigationMenu
{
public function __construct(
private array $items,
private ?string $title = null,
private ?int $activeIndex = null,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Render navigation menu
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$output = [];
if ($showBorders) {
// Top border
if ($this->title !== null) {
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$titlePadded = str_pad($this->title, $width - 4);
$output[] = '┌' . str_repeat('─', $width - 2) . '┐';
$output[] = '│ ' . $titleStyle->apply($titlePadded) . ' │';
$output[] = '├' . str_repeat('─', $width - 2) . '┤';
} else {
$output[] = '┌' . str_repeat('─', $width - 2) . '┐';
}
// Menu items
foreach ($this->items as $index => $item) {
$isActive = $this->activeIndex === $index;
$itemStyle = $isActive
? ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD)
: ConsoleStyle::create(color: $theme->valueColor);
$prefix = $isActive ? '▶ ' : ' ';
$itemPadded = str_pad($prefix . $item, $width - 4);
$output[] = '│ ' . $itemStyle->apply($itemPadded) . ' │';
}
// Bottom border
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
} else {
if ($this->title !== null) {
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$output[] = $titleStyle->apply($this->title);
}
foreach ($this->items as $index => $item) {
$isActive = $this->activeIndex === $index;
$itemStyle = $isActive
? ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD)
: ConsoleStyle::create(color: $theme->valueColor);
$prefix = $isActive ? '▶ ' : ' ';
$output[] = $itemStyle->apply($prefix . $item);
}
}
return implode("\n", $output);
}
/**
* Create navigation menu
*/
public static function create(array $items, ?string $title = null, ?int $activeIndex = null, ?ConsoleTheme $theme = null): self
{
return new self($items, $title, $activeIndex, $theme);
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Layout\Components\Footer;
use App\Framework\Console\Layout\Components\Header;
use App\Framework\Console\Layout\Components\Sidebar;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
/**
* Page layout with header, main content, footer, and optional sidebar
*/
final class PageLayout extends LayoutContainer
{
private ?Header $header = null;
private ?Footer $footer = null;
private ?Sidebar $sidebar = null;
private array $sections = [];
private array $mainContent = [];
public function __construct(
?TerminalSize $customTerminalSize = null,
?LayoutOptions $options = null
) {
parent::__construct($customTerminalSize, $options);
}
/**
* Set header
*/
public function withHeader(string $title, ?string $subtitle = null, array $meta = []): self
{
$this->header = new Header($title, $subtitle, $this->theme, $meta);
return $this;
}
/**
* Set footer
*/
public function withFooter(string $content): self
{
$this->footer = new Footer($content, $this->theme);
return $this;
}
/**
* Set sidebar
*/
public function withSidebar(array $items, ?string $title = null): self
{
$this->sidebar = new Sidebar($items, $title, $this->theme);
return $this;
}
/**
* Add section to main content
*/
public function addSection(Section $section): self
{
$this->sections[] = $section;
return $this;
}
/**
* Add content line to main area
*/
public function addContent(string $content): self
{
$this->mainContent[] = $content;
return $this;
}
/**
* Render the page layout
*/
public function render(): string
{
$width = $this->getEffectiveWidth();
$output = [];
$hasSidebar = $this->sidebar !== null && $this->shouldShowSidebar();
// Header
if ($this->header !== null) {
$output[] = $this->header->render($width, $this->options->showBorders);
$output[] = '';
}
// Main content area
$mainContent = $this->renderMainContent($width, $hasSidebar);
if ($hasSidebar) {
// Split layout: sidebar + main content
$sidebarWidth = min(25, (int)($width * 0.25));
$mainWidth = $width - $sidebarWidth - 2; // Space between sidebar and main
$sidebarLines = explode("\n", $this->sidebar->render($sidebarWidth, $this->options->showBorders));
$mainLines = explode("\n", $mainContent);
$maxLines = max(count($sidebarLines), count($mainLines));
for ($i = 0; $i < $maxLines; $i++) {
$sidebarLine = $sidebarLines[$i] ?? str_repeat(' ', $sidebarWidth);
$mainLine = $mainLines[$i] ?? '';
$output[] = $sidebarLine . ' ' . $mainLine;
}
} else {
// Full width main content
$output[] = $mainContent;
}
// Footer
if ($this->footer !== null) {
$output[] = '';
$output[] = $this->footer->render($width, $this->options->showBorders);
}
return implode("\n", $output);
}
private function renderMainContent(int $width, bool $hasSidebar): string
{
$content = [];
// Render sections
foreach ($this->sections as $section) {
$content[] = $section->render($width, $this->options->showBorders);
$content[] = '';
}
// Render additional content
foreach ($this->mainContent as $line) {
$content[] = $line;
}
return implode("\n", $content);
}
private function shouldShowSidebar(): bool
{
if (!$this->isResponsive()) {
return true;
}
// Hide sidebar on small terminals
$breakpoint = $this->terminalSize->getBreakpoint();
return $breakpoint !== TerminalBreakpoint::EXTRA_SMALL && $breakpoint !== TerminalBreakpoint::SMALL;
}
/**
* Create page layout
*/
public static function create(?TerminalSize $terminalSize = null, ?LayoutOptions $options = null): self
{
return new self($terminalSize, $options);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\Layout\ValueObjects\PanelSize;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Panel component for dashboard layouts
*/
final class Panel
{
private string $content = '';
public function __construct(
private string $title,
private PanelSize $size = PanelSize::MEDIUM,
private ?ConsoleTheme $theme = null,
) {
}
/**
* Set panel content
*/
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
/**
* Render the panel
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$output = [];
if ($showBorders) {
// Top border with title
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$titleWidth = mb_strlen($this->title);
$availableSpace = $width - $titleWidth - 4; // Space for '[ ' and ' ]'
$leftPadding = max(0, (int)floor($availableSpace / 2));
$rightPadding = max(0, $availableSpace - $leftPadding);
$topBorder = '┌' . str_repeat('─', $leftPadding) . '[ ' . $titleStyle->apply($this->title) . ' ]' . str_repeat('─', $rightPadding) . '┐';
$output[] = $topBorder;
// Content
$contentLines = explode("\n", $this->content);
foreach ($contentLines as $line) {
$paddedLine = str_pad($line, $width - 4);
$output[] = '│ ' . $paddedLine . ' │';
}
// Bottom border
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
} else {
// No borders
$titleStyle = ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD);
$output[] = $titleStyle->apply($this->title);
$output[] = $this->content;
}
return implode("\n", $output);
}
/**
* Get panel size
*/
public function getSize(): PanelSize
{
return $this->size;
}
/**
* Create panel
*/
public static function create(string $title, PanelSize $size = PanelSize::MEDIUM, ?ConsoleTheme $theme = null): self
{
return new self($title, $size, $theme);
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Output\ConsoleOutput;
use App\Framework\Console\ConsoleOutput;
final readonly class ResponsiveOutput
{
@@ -173,6 +173,44 @@ final readonly class ResponsiveOutput
return substr($text, 0, $maxLength - 3) . '...';
}
/**
* Create a page layout
*/
public function createPageLayout(?\App\Framework\Console\Layout\ValueObjects\LayoutOptions $options = null): \App\Framework\Console\Layout\PageLayout
{
$factory = \App\Framework\Console\Layout\LayoutFactory::withTerminalSize($this->terminalSize);
return $factory->createPageLayout($options);
}
/**
* Create a section layout
*/
public function createSectionLayout(?\App\Framework\Console\Layout\ValueObjects\LayoutOptions $options = null): \App\Framework\Console\Layout\SectionLayout
{
$factory = \App\Framework\Console\Layout\LayoutFactory::withTerminalSize($this->terminalSize);
return $factory->createSectionLayout($options);
}
/**
* Create a dashboard layout
*/
public function createDashboardLayout(
?\App\Framework\Console\Layout\ValueObjects\LayoutOptions $options = null,
?\App\Framework\Console\Layout\ValueObjects\GridConfig $gridConfig = null
): \App\Framework\Console\Layout\DashboardLayout {
$factory = \App\Framework\Console\Layout\LayoutFactory::withTerminalSize($this->terminalSize);
return $factory->createDashboardLayout($options, $gridConfig);
}
/**
* Create a simple layout
*/
public function createSimpleLayout(?\App\Framework\Console\Layout\ValueObjects\LayoutOptions $options = null): \App\Framework\Console\Layout\SimpleLayout
{
$factory = \App\Framework\Console\Layout\LayoutFactory::withTerminalSize($this->terminalSize);
return $factory->createSimpleLayout($options);
}
public static function create(ConsoleOutput $output, ?TerminalSize $terminalSize = null): self
{
return new self(

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleFormat;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\Layout\ValueObjects\SectionStyle;
use App\Framework\Display\Themes\ConsoleTheme;
use App\Framework\Display\Themes\DefaultThemes;
/**
* Reusable content section for layouts
*/
final class Section
{
private array $contentLines = [];
private ?string $footer = null;
private bool $collapsed = false;
public function __construct(
private string $title,
private SectionStyle $style = SectionStyle::DEFAULT,
private ?ConsoleTheme $theme = null,
private bool $collapsible = false,
) {
}
/**
* Check if section is collapsible
*/
public function isCollapsible(): bool
{
return $this->collapsible;
}
/**
* Add content line(s)
*/
public function addContent(string $content): self
{
$lines = explode("\n", $content);
$this->contentLines = array_merge($this->contentLines, $lines);
return $this;
}
/**
* Set footer text
*/
public function withFooter(string $footer): self
{
$this->footer = $footer;
return $this;
}
/**
* Set collapsed state (only works if collapsible)
*/
public function setCollapsed(bool $collapsed): self
{
$this->collapsed = $collapsed;
return $this;
}
/**
* Render the section
*/
public function render(int $width, bool $showBorders = true): string
{
$theme = $this->theme ?? DefaultThemes::getConsoleTheme('default');
$output = [];
// Title with style
$titleStyle = $this->getTitleStyle($theme);
$titleLine = $this->formatTitle($this->title, $width, $titleStyle, $showBorders);
$output[] = $titleLine;
// Content (if not collapsed)
if (!$this->collapsed && !empty($this->contentLines)) {
$contentWidth = $showBorders ? $width - 4 : $width; // Account for borders
foreach ($this->contentLines as $line) {
$wrapped = $this->wrapLine($line, $contentWidth);
foreach ($wrapped as $wrappedLine) {
if ($showBorders) {
$output[] = '│ ' . str_pad($wrappedLine, $contentWidth) . ' │';
} else {
$output[] = $wrappedLine;
}
}
}
} elseif ($this->collapsed) {
$collapsedText = '... (collapsed)';
$contentWidth = $showBorders ? $width - 4 : $width;
if ($showBorders) {
$output[] = '│ ' . str_pad($collapsedText, $contentWidth) . ' │';
} else {
$output[] = $collapsedText;
}
}
// Footer (if provided)
if ($this->footer !== null && !$this->collapsed) {
$footerStyle = ConsoleStyle::create(color: $theme->metadataColor);
$footerLine = $this->formatFooter($this->footer, $width, $footerStyle, $showBorders);
$output[] = $footerLine;
}
// Bottom border
if ($showBorders && !empty($this->contentLines)) {
$output[] = '└' . str_repeat('─', $width - 2) . '┘';
}
return implode("\n", $output);
}
private function getTitleStyle(ConsoleTheme $theme): ConsoleStyle
{
return match ($this->style) {
SectionStyle::INFO => ConsoleStyle::create(color: $theme->infoColor, format: ConsoleFormat::BOLD),
SectionStyle::SUCCESS => ConsoleStyle::create(color: ConsoleColor::GREEN, format: ConsoleFormat::BOLD),
SectionStyle::WARNING => ConsoleStyle::create(color: $theme->warningColor, format: ConsoleFormat::BOLD),
SectionStyle::ERROR => ConsoleStyle::create(color: $theme->errorColor, format: ConsoleFormat::BOLD),
SectionStyle::PRIMARY => ConsoleStyle::create(color: $theme->classNameColor, format: ConsoleFormat::BOLD),
default => ConsoleStyle::create(color: $theme->valueColor, format: ConsoleFormat::BOLD),
};
}
private function formatTitle(string $title, int $width, ConsoleStyle $style, bool $showBorders): string
{
$titleText = $this->collapsible ? ($this->collapsed ? '▶ ' : '▼ ') . $title : $title;
$styledTitle = $style->apply($titleText);
if ($showBorders) {
$padding = str_repeat('─', max(0, $width - mb_strlen($titleText) - 4));
return '┌─ ' . $styledTitle . ' ' . $padding . '┐';
} else {
return $styledTitle;
}
}
private function formatFooter(string $footer, int $width, ConsoleStyle $style, bool $showBorders): string
{
$styledFooter = $style->apply($footer);
$footerWidth = $showBorders ? $width - 4 : $width;
if ($showBorders) {
return '├─ ' . str_pad($styledFooter, $footerWidth) . ' ┤';
} else {
return $styledFooter;
}
}
private function wrapLine(string $line, int $width): array
{
if (mb_strlen($line) <= $width) {
return [$line];
}
$lines = [];
$words = explode(' ', $line);
$currentLine = '';
foreach ($words as $word) {
$testLine = empty($currentLine) ? $word : $currentLine . ' ' . $word;
if (mb_strlen($testLine) <= $width) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
$currentLine = $word;
} else {
$lines[] = mb_substr($word, 0, $width);
$currentLine = mb_substr($word, $width);
}
}
}
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
return $lines;
}
/**
* Create section with default style
*/
public static function create(string $title, ?ConsoleTheme $theme = null): self
{
return new self($title, SectionStyle::DEFAULT, $theme);
}
/**
* Create section with info style
*/
public static function info(string $title, ?ConsoleTheme $theme = null): self
{
return new self($title, SectionStyle::INFO, $theme);
}
/**
* Create section with success style
*/
public static function success(string $title, ?ConsoleTheme $theme = null): self
{
return new self($title, SectionStyle::SUCCESS, $theme);
}
/**
* Create section with warning style
*/
public static function warning(string $title, ?ConsoleTheme $theme = null): self
{
return new self($title, SectionStyle::WARNING, $theme);
}
/**
* Create section with error style
*/
public static function error(string $title, ?ConsoleTheme $theme = null): self
{
return new self($title, SectionStyle::ERROR, $theme);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
/**
* Container for multiple sections
*/
final class SectionLayout extends LayoutContainer
{
private array $sections = [];
public function __construct(
?TerminalSize $customTerminalSize = null,
?LayoutOptions $options = null
) {
parent::__construct($customTerminalSize, $options);
}
/**
* Add a section
*/
public function addSection(Section $section): self
{
$this->sections[] = $section;
return $this;
}
/**
* Add multiple sections
*/
public function addSections(array $sections): self
{
foreach ($sections as $section) {
if ($section instanceof Section) {
$this->sections[] = $section;
}
}
return $this;
}
/**
* Render the section layout
*/
public function render(): string
{
$width = $this->getEffectiveWidth();
$output = [];
foreach ($this->sections as $index => $section) {
$output[] = $section->render($width, $this->options->showBorders);
// Add separator between sections (except last one)
if ($index < count($this->sections) - 1) {
$output[] = '';
}
}
return implode("\n", $output);
}
/**
* Create section layout
*/
public static function create(?TerminalSize $terminalSize = null, ?LayoutOptions $options = null): self
{
return new self($terminalSize, $options);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout;
use App\Framework\Console\Layout\ValueObjects\LayoutOptions;
/**
* Simple container layout for basic use cases
*/
final class SimpleLayout extends LayoutContainer
{
private array $content = [];
public function __construct(
?TerminalSize $customTerminalSize = null,
?LayoutOptions $options = null
) {
parent::__construct($customTerminalSize, $options);
}
/**
* Add content line
*/
public function addContent(string $content): self
{
$this->content[] = $content;
return $this;
}
/**
* Add multiple content lines
*/
public function addContentLines(array $lines): self
{
$this->content = array_merge($this->content, $lines);
return $this;
}
/**
* Render the simple layout
*/
public function render(): string
{
$width = $this->getEffectiveWidth();
$content = implode("\n", $this->content);
if ($this->options->showBorders) {
return $this->wrapWithBorders($this->wrapWithPadding($content));
}
return $this->wrapWithPadding($content);
}
/**
* Create simple layout
*/
public static function create(?TerminalSize $terminalSize = null, ?LayoutOptions $options = null): self
{
return new self($terminalSize, $options);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\ValueObjects;
/**
* Grid configuration for dashboard layouts
*/
final readonly class GridConfig
{
public function __construct(
public int $columns = 2,
public int $gap = 2,
public bool $responsive = true,
) {
}
/**
* Calculate responsive columns based on terminal breakpoint
*/
public static function fromBreakpoint(\App\Framework\Console\Layout\TerminalBreakpoint $breakpoint): self
{
$columns = match ($breakpoint) {
\App\Framework\Console\Layout\TerminalBreakpoint::EXTRA_SMALL => 1,
\App\Framework\Console\Layout\TerminalBreakpoint::SMALL => 1,
\App\Framework\Console\Layout\TerminalBreakpoint::MEDIUM => 2,
\App\Framework\Console\Layout\TerminalBreakpoint::LARGE => 3,
\App\Framework\Console\Layout\TerminalBreakpoint::EXTRA_LARGE => 4,
};
return new self(columns: $columns);
}
/**
* Calculate column width
*/
public function getColumnWidth(int $totalWidth, int $columnSpan = 1): int
{
$totalGap = ($this->columns - 1) * $this->gap;
$availableWidth = $totalWidth - $totalGap;
$baseColumnWidth = (int)($availableWidth / $this->columns);
return ($baseColumnWidth * $columnSpan) + (($columnSpan - 1) * $this->gap);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\ValueObjects;
use App\Framework\Display\Themes\ConsoleTheme;
/**
* Options for console layouts
*/
final readonly class LayoutOptions
{
public function __construct(
public int $width = 0, // 0 = auto-detect
public int $padding = 2,
public bool $showBorders = true,
public ?ConsoleTheme $theme = null,
public bool $responsive = true,
public int $margin = 0,
public string $borderStyle = 'single', // 'single', 'double', 'rounded', 'none'
) {
}
/**
* Create default layout options
*/
public static function default(): self
{
return new self();
}
/**
* Create with custom width
*/
public static function withWidth(int $width): self
{
return new self(width: $width);
}
/**
* Create without borders
*/
public static function withoutBorders(): self
{
return new self(showBorders: false);
}
/**
* Create with custom theme
*/
public static function withTheme(ConsoleTheme $theme): self
{
return new self(theme: $theme);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\ValueObjects;
/**
* Panel size options for dashboard layouts
*/
enum PanelSize: string
{
case SMALL = 'small'; // 1/4 width
case MEDIUM = 'medium'; // 1/2 width
case LARGE = 'large'; // 3/4 width
case FULL = 'full'; // 1/1 width
/**
* Get width fraction (0.0 to 1.0)
*/
public function getWidthFraction(): float
{
return match ($this) {
self::SMALL => 0.25,
self::MEDIUM => 0.5,
self::LARGE => 0.75,
self::FULL => 1.0,
};
}
/**
* Get column span (for grid layouts)
*/
public function getColumnSpan(int $totalColumns): int
{
return match ($this) {
self::SMALL => max(1, (int)floor($totalColumns * 0.25)),
self::MEDIUM => max(1, (int)floor($totalColumns * 0.5)),
self::LARGE => max(1, (int)floor($totalColumns * 0.75)),
self::FULL => $totalColumns,
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Layout\ValueObjects;
/**
* Style options for sections
*/
enum SectionStyle: string
{
case DEFAULT = 'default';
case INFO = 'info';
case SUCCESS = 'success';
case WARNING = 'warning';
case ERROR = 'error';
case PRIMARY = 'primary';
}

View File

@@ -18,18 +18,31 @@ final readonly class Display
/**
* Löscht den gesamten Bildschirm und setzt den Cursor an den Anfang.
* Uses explicit \e[2J sequence for screen clearing.
*/
public function clear(): self
{
if ($this->output->isTerminal()) {
// Bildschirm löschen und Cursor an den Anfang setzen
$this->output->writeRaw(ScreenControlCode::CLEAR_ALL->format());
$this->clearScreen();
$this->output->writeRaw(CursorControlCode::POSITION->format(1, 1));
}
return $this;
}
/**
* Clear screen using explicit \e[2J sequence via ScreenControlCode enum
*/
public function clearScreen(): self
{
if ($this->output->isTerminal()) {
$this->output->writeRaw(ScreenControlCode::CLEAR_ALL->format());
}
return $this;
}
/**
* Löscht die aktuelle Zeile.
*/

View File

@@ -15,17 +15,7 @@ final class SecurityException extends FrameworkException
public readonly ConsoleUser $user,
?\Throwable $previous = null
) {
parent::__construct($message, 403, $previous);
$this->errorCode = ErrorCode::AUTH_INSUFFICIENT_PERMISSIONS;
$this->withData([
'user_id' => $user->id,
'user_name' => $user->name,
'user_permissions' => array_map(fn (Permission $p) => $p->value, $user->permissions),
'required_permissions' => is_array($requiredPermissions)
? array_map(fn (Permission $p) => $p->value, $requiredPermissions)
: [$requiredPermissions->value],
]);
parent::__construct($message, (int) ErrorCode::AUTH_INSUFFICIENT_PERMISSIONS->value, $previous);
}
public static function accessDenied(Permission $permission, ConsoleUser $user): self

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Terminal;
/**
* OSC (Operating System Command) modes for window title management.
* Mode 0 = both icon and title, Mode 1 = icon, Mode 2 = title
*/
enum OscMode: int
{
case BOTH = 0; // Both icon and title
case ICON = 1; // Icon only
case TITLE = 2; // Title only
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Terminal;
/**
* Detects PhpStorm/JetBrains terminal environment
*
* Provides utilities for PhpStorm-specific terminal features like
* clickable file links and project root detection.
*/
final readonly class PhpStormDetector
{
/**
* Check if running in PhpStorm/JetBrains terminal
*/
public static function isPhpStorm(): bool
{
// Check TERMINAL_EMULATOR environment variable
$terminalEmulator = getenv('TERMINAL_EMULATOR');
if ($terminalEmulator !== false && $terminalEmulator === 'JetBrains-JediTerm') {
return true;
}
// Check IDE_PROJECT_ROOTS (set by PhpStorm/IntelliJ)
$ideProjectRoots = getenv('IDE_PROJECT_ROOTS');
if ($ideProjectRoots !== false && $ideProjectRoots !== '') {
return true;
}
// Check TERM for jetbrains-related values
$term = getenv('TERM');
if ($term !== false && str_contains(strtolower($term), 'jetbrains')) {
return true;
}
return false;
}
/**
* Get project root paths from IDE_PROJECT_ROOTS
*
* @return array<string> Array of project root paths, or empty array if not available
*/
public static function getProjectRoots(): array
{
$ideProjectRoots = getenv('IDE_PROJECT_ROOTS');
if ($ideProjectRoots === false || $ideProjectRoots === '') {
return [];
}
// IDE_PROJECT_ROOTS is typically a colon-separated list (Unix) or semicolon-separated (Windows)
$separator = str_contains($ideProjectRoots, ';') ? ';' : ':';
$roots = explode($separator, $ideProjectRoots);
// Filter out empty values and normalize paths
return array_filter(
array_map('trim', $roots),
fn(string $root) => $root !== '' && is_dir($root)
);
}
/**
* Convert absolute file path to relative path based on project roots
*
* If PhpStorm is detected and project roots are available, returns the shortest
* relative path. Otherwise returns the absolute path.
*
* @param string $filePath Absolute file path
* @return string Relative path if project root found, otherwise absolute path
*/
public static function getRelativePath(string $filePath): string
{
if (!self::isPhpStorm()) {
return $filePath;
}
$projectRoots = self::getProjectRoots();
if (empty($projectRoots)) {
return $filePath;
}
$filePath = realpath($filePath);
if ($filePath === false) {
return $filePath;
}
$shortestPath = $filePath;
$shortestLength = strlen($filePath);
foreach ($projectRoots as $root) {
$root = realpath($root);
if ($root === false) {
continue;
}
// Check if file is within this project root
if (str_starts_with($filePath, $root . DIRECTORY_SEPARATOR) || $filePath === $root) {
$relative = substr($filePath, strlen($root) + 1);
if (strlen($relative) < $shortestLength) {
$shortestPath = $relative;
$shortestLength = strlen($relative);
}
}
}
return $shortestPath;
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Terminal;
use App\Framework\Console\TerminalDetector;
use App\Framework\Console\ValueObjects\TerminalStream;
/**
* Extended terminal capability detection.
* Detects Truecolor support, link support, and UTF-8 support.
*
* Values are cached after first detection for performance.
*/
final class TerminalCapabilities
{
private ?bool $truecolorSupport = null;
private ?bool $linkSupport = null;
private ?bool $utf8Support = null;
private ?bool $phpStorm = null;
public function __construct(
private readonly ?TerminalStream $stream = null
) {
}
/**
* Check if terminal supports truecolor (24-bit RGB colors)
* Checks COLORTERM environment variable for "24bit" or "truecolor"
*/
public function supportsTruecolor(): bool
{
if ($this->truecolorSupport !== null) {
return $this->truecolorSupport;
}
if (!TerminalDetector::isTerminal($this->stream)) {
$this->truecolorSupport = false;
return false;
}
$colorterm = getenv('COLORTERM');
if ($colorterm === false) {
$this->truecolorSupport = false;
return false;
}
$colorterm = strtolower($colorterm);
$this->truecolorSupport = in_array($colorterm, ['24bit', 'truecolor'], true);
return $this->truecolorSupport;
}
/**
* Check if terminal supports clickable links (OSC 8)
* Most modern terminals support this, but we check for known non-supporting terminals
*/
public function supportsLinks(): bool
{
if ($this->linkSupport !== null) {
return $this->linkSupport;
}
if (!TerminalDetector::isTerminal($this->stream)) {
$this->linkSupport = false;
return false;
}
// Check for known terminals that don't support links
$term = getenv('TERM');
if ($term !== false) {
$term = strtolower($term);
// Some older terminals don't support OSC 8
$unsupported = ['dumb', 'linux', 'vt100', 'vt102', 'vt220'];
if (in_array($term, $unsupported, true)) {
$this->linkSupport = false;
return false;
}
}
// Check for specific terminal emulators that don't support links
$terminalEmulator = getenv('TERMINAL_EMULATOR');
if ($terminalEmulator !== false) {
// Some emulators might not support links
// For now, assume most modern terminals support it
}
// Default to true for modern terminals (most support OSC 8)
$this->linkSupport = true;
return true;
}
/**
* Check if terminal supports UTF-8
* Checks locale settings and terminal type
*/
public function supportsUtf8(): bool
{
if ($this->utf8Support !== null) {
return $this->utf8Support;
}
if (!TerminalDetector::isTerminal($this->stream)) {
$this->utf8Support = false;
return false;
}
// Check locale
$locale = setlocale(LC_ALL, '0');
if ($locale !== false && stripos($locale, 'utf-8') !== false) {
$this->utf8Support = true;
return true;
}
// Check LANG environment variable
$lang = getenv('LANG');
if ($lang !== false && stripos($lang, 'utf-8') !== false) {
$this->utf8Support = true;
return true;
}
// Check LC_ALL, LC_CTYPE
$lcAll = getenv('LC_ALL');
$lcCtype = getenv('LC_CTYPE');
if (
($lcAll !== false && stripos($lcAll, 'utf-8') !== false) ||
($lcCtype !== false && stripos($lcCtype, 'utf-8') !== false)
) {
$this->utf8Support = true;
return true;
}
// Check if terminal type indicates UTF-8 support
$term = getenv('TERM');
if ($term !== false) {
// Most modern terminal types support UTF-8
$term = strtolower($term);
$utf8Terms = ['xterm', 'xterm-256color', 'screen', 'tmux', 'rxvt', 'konsole', 'alacritty', 'kitty'];
foreach ($utf8Terms as $utf8Term) {
if (str_contains($term, $utf8Term)) {
$this->utf8Support = true;
return true;
}
}
}
// Default: assume UTF-8 is supported for modern terminals
$this->utf8Support = true;
return true;
}
/**
* Check if terminal supports colors (basic check)
*/
public function supportsColors(): bool
{
return TerminalDetector::supportsColors($this->stream);
}
/**
* Check if stream is a terminal
*/
public function isTerminal(): bool
{
return TerminalDetector::isTerminal($this->stream);
}
/**
* Check if running in PhpStorm/JetBrains terminal (cached)
*/
public function isPhpStorm(): bool
{
if ($this->phpStorm === null) {
$this->phpStorm = \App\Framework\Console\Terminal\PhpStormDetector::isPhpStorm();
}
return $this->phpStorm;
}
/**
* Get all detected capabilities as array
*/
public function getAllCapabilities(): array
{
return [
'terminal' => $this->isTerminal(),
'colors' => $this->supportsColors(),
'truecolor' => $this->supportsTruecolor(),
'links' => $this->supportsLinks(),
'utf8' => $this->supportsUtf8(),
];
}
}

Some files were not shown because too many files have changed in this diff Show More