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
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:
45
src/Framework/Admin/Attributes/AdminPage.php
Normal file
45
src/Framework/Admin/Attributes/AdminPage.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
|
||||
43
src/Framework/Admin/Attributes/AdminSection.php
Normal file
43
src/Framework/Admin/Attributes/AdminSection.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
84
src/Framework/Console/Animation/Animation.php
Normal file
84
src/Framework/Console/Animation/Animation.php
Normal 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;
|
||||
}
|
||||
|
||||
212
src/Framework/Console/Animation/AnimationBuilder.php
Normal file
212
src/Framework/Console/Animation/AnimationBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
132
src/Framework/Console/Animation/AnimationFactory.php
Normal file
132
src/Framework/Console/Animation/AnimationFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
27
src/Framework/Console/Animation/AnimationFrame.php
Normal file
27
src/Framework/Console/Animation/AnimationFrame.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
124
src/Framework/Console/Animation/AnimationManager.php
Normal file
124
src/Framework/Console/Animation/AnimationManager.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
17
src/Framework/Console/Animation/AnimationState.php
Normal file
17
src/Framework/Console/Animation/AnimationState.php
Normal 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';
|
||||
}
|
||||
|
||||
233
src/Framework/Console/Animation/CompositeAnimation.php
Normal file
233
src/Framework/Console/Animation/CompositeAnimation.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
src/Framework/Console/Animation/EasingFunction.php
Normal file
78
src/Framework/Console/Animation/EasingFunction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
174
src/Framework/Console/Animation/KeyframeAnimation.php
Normal file
174
src/Framework/Console/Animation/KeyframeAnimation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
31
src/Framework/Console/Animation/TextAnimation.php
Normal file
31
src/Framework/Console/Animation/TextAnimation.php
Normal 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;
|
||||
}
|
||||
|
||||
183
src/Framework/Console/Animation/TuiAnimationRenderer.php
Normal file
183
src/Framework/Console/Animation/TuiAnimationRenderer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
207
src/Framework/Console/Animation/Types/BaseAnimation.php
Normal file
207
src/Framework/Console/Animation/Types/BaseAnimation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
src/Framework/Console/Animation/Types/FadeAnimation.php
Normal file
72
src/Framework/Console/Animation/Types/FadeAnimation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
103
src/Framework/Console/Animation/Types/MarqueeAnimation.php
Normal file
103
src/Framework/Console/Animation/Types/MarqueeAnimation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
77
src/Framework/Console/Animation/Types/PulseAnimation.php
Normal file
77
src/Framework/Console/Animation/Types/PulseAnimation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
87
src/Framework/Console/Animation/Types/SlideAnimation.php
Normal file
87
src/Framework/Console/Animation/Types/SlideAnimation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
129
src/Framework/Console/Animation/Types/SpinnerAnimation.php
Normal file
129
src/Framework/Console/Animation/Types/SpinnerAnimation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
130
src/Framework/Console/Ansi/AnsiSequenceGenerator.php
Normal file
130
src/Framework/Console/Ansi/AnsiSequenceGenerator.php
Normal 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";
|
||||
}
|
||||
}
|
||||
|
||||
113
src/Framework/Console/Ansi/LinkFormatter.php
Normal file
113
src/Framework/Console/Ansi/LinkFormatter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
186
src/Framework/Console/Ansi/RgbColor.php
Normal file
186
src/Framework/Console/Ansi/RgbColor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
170
src/Framework/Console/Commands/CodeQualityScanCommand.php
Normal file
170
src/Framework/Console/Commands/CodeQualityScanCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/Framework/Console/Commands/DatabaseBrowserCommand.php
Normal file
242
src/Framework/Console/Commands/DatabaseBrowserCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
96
src/Framework/Console/Components/EventBuffer.php
Normal file
96
src/Framework/Console/Components/EventBuffer.php
Normal 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 !== '';
|
||||
}
|
||||
}
|
||||
|
||||
110
src/Framework/Console/Components/EventLoop/EventLoop.php
Normal file
110
src/Framework/Console/Components/EventLoop/EventLoop.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
436
src/Framework/Console/Components/Handlers/MouseEventHandler.php
Normal file
436
src/Framework/Console/Components/Handlers/MouseEventHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
src/Framework/Console/Components/Parsers/KeyboardEventParser.php
Normal file
222
src/Framework/Console/Components/Parsers/KeyboardEventParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
119
src/Framework/Console/Components/Parsers/MouseEventParser.php
Normal file
119
src/Framework/Console/Components/Parsers/MouseEventParser.php
Normal 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[<");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
156
src/Framework/Console/Examples/AnimationDemoCommand.php
Normal file
156
src/Framework/Console/Examples/AnimationDemoCommand.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}).");
|
||||
}
|
||||
}
|
||||
|
||||
175
src/Framework/Console/InteractivePrompter.php
Normal file
175
src/Framework/Console/InteractivePrompter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/Framework/Console/Layout/Adapters/ThemeMapper.php
Normal file
33
src/Framework/Console/Layout/Adapters/ThemeMapper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
56
src/Framework/Console/Layout/Components/Footer.php
Normal file
56
src/Framework/Console/Layout/Components/Footer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
93
src/Framework/Console/Layout/Components/Header.php
Normal file
93
src/Framework/Console/Layout/Components/Header.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
75
src/Framework/Console/Layout/Components/ProgressSection.php
Normal file
75
src/Framework/Console/Layout/Components/ProgressSection.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
75
src/Framework/Console/Layout/Components/Sidebar.php
Normal file
75
src/Framework/Console/Layout/Components/Sidebar.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Framework/Console/Layout/Components/StatusIndicator.php
Normal file
112
src/Framework/Console/Layout/Components/StatusIndicator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
152
src/Framework/Console/Layout/DashboardLayout.php
Normal file
152
src/Framework/Console/Layout/DashboardLayout.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
134
src/Framework/Console/Layout/Examples/LayoutExamples.php
Normal file
134
src/Framework/Console/Layout/Examples/LayoutExamples.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
102
src/Framework/Console/Layout/Interactive/Tabs.php
Normal file
102
src/Framework/Console/Layout/Interactive/Tabs.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
211
src/Framework/Console/Layout/LayoutContainer.php
Normal file
211
src/Framework/Console/Layout/LayoutContainer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
99
src/Framework/Console/Layout/LayoutFactory.php
Normal file
99
src/Framework/Console/Layout/LayoutFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
22
src/Framework/Console/Layout/LayoutInterface.php
Normal file
22
src/Framework/Console/Layout/LayoutInterface.php
Normal 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;
|
||||
}
|
||||
|
||||
184
src/Framework/Console/Layout/LayoutRenderer.php
Normal file
184
src/Framework/Console/Layout/LayoutRenderer.php
Normal 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 ?: [''];
|
||||
}
|
||||
}
|
||||
|
||||
65
src/Framework/Console/Layout/Navigation/Breadcrumbs.php
Normal file
65
src/Framework/Console/Layout/Navigation/Breadcrumbs.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
87
src/Framework/Console/Layout/Navigation/NavigationMenu.php
Normal file
87
src/Framework/Console/Layout/Navigation/NavigationMenu.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
159
src/Framework/Console/Layout/PageLayout.php
Normal file
159
src/Framework/Console/Layout/PageLayout.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
90
src/Framework/Console/Layout/Panel.php
Normal file
90
src/Framework/Console/Layout/Panel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
231
src/Framework/Console/Layout/Section.php
Normal file
231
src/Framework/Console/Layout/Section.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
73
src/Framework/Console/Layout/SectionLayout.php
Normal file
73
src/Framework/Console/Layout/SectionLayout.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
64
src/Framework/Console/Layout/SimpleLayout.php
Normal file
64
src/Framework/Console/Layout/SimpleLayout.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Framework/Console/Layout/ValueObjects/GridConfig.php
Normal file
47
src/Framework/Console/Layout/ValueObjects/GridConfig.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
57
src/Framework/Console/Layout/ValueObjects/LayoutOptions.php
Normal file
57
src/Framework/Console/Layout/ValueObjects/LayoutOptions.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
43
src/Framework/Console/Layout/ValueObjects/PanelSize.php
Normal file
43
src/Framework/Console/Layout/ValueObjects/PanelSize.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
19
src/Framework/Console/Layout/ValueObjects/SectionStyle.php
Normal file
19
src/Framework/Console/Layout/ValueObjects/SectionStyle.php
Normal 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';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
17
src/Framework/Console/Terminal/OscMode.php
Normal file
17
src/Framework/Console/Terminal/OscMode.php
Normal 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
|
||||
}
|
||||
|
||||
111
src/Framework/Console/Terminal/PhpStormDetector.php
Normal file
111
src/Framework/Console/Terminal/PhpStormDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
195
src/Framework/Console/Terminal/TerminalCapabilities.php
Normal file
195
src/Framework/Console/Terminal/TerminalCapabilities.php
Normal 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
Reference in New Issue
Block a user