fix(console): comprehensive TUI rendering fixes

- Fix Enter key detection: handle multiple Enter key formats (\n, \r, \r\n)
- Reduce flickering: lower render frequency from 60 FPS to 30 FPS
- Fix menu bar visibility: re-render menu bar after content to prevent overwriting
- Fix content positioning: explicit line positioning for categories and commands
- Fix line shifting: clear lines before writing, control newlines manually
- Limit visible items: prevent overflow with maxVisibleCategories/Commands
- Improve CPU usage: increase sleep interval when no events processed

This fixes:
- Enter key not working for selection
- Strong flickering of the application
- Menu bar not visible or being overwritten
- Top half of selection list not displayed
- Lines being shifted/misaligned
This commit is contained in:
2025-11-10 11:06:07 +01:00
parent 6bc78f5540
commit 8f3c15ddbb
106 changed files with 9082 additions and 4483 deletions

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\FormBuilder;
@@ -13,13 +12,13 @@ use App\Framework\View\Table\Table;
/**
* Admin Page Renderer
*
* Provides consistent rendering for admin pages
* Provides consistent rendering for admin pages.
* Layout data (navigation, breadcrumbs) is automatically added by RouteResponder.
*/
final readonly class AdminPageRenderer
{
public function __construct(
private AdminLayoutProcessor $layoutProcessor
) {
public function __construct()
{
}
public function renderIndex(
@@ -35,12 +34,10 @@ final readonly class AdminPageRenderer
'actions' => $actions,
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'admin-index',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
data: $data
);
}
@@ -57,12 +54,10 @@ final readonly class AdminPageRenderer
'form' => $form->build(),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'admin-form',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
data: $data
);
}
@@ -77,12 +72,10 @@ final readonly class AdminPageRenderer
...$data,
];
$finalData = $this->layoutProcessor->processLayoutFromArray($pageData);
return new ViewResult(
template: 'admin-show',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
data: $pageData
);
}
}

View File

@@ -16,7 +16,7 @@ final readonly class CommandCategorizer
*
* @var array<string, string>
*/
private const CATEGORY_INFO = [
private const array CATEGORY_INFO = [
'db' => 'Database operations (migrations, health checks)',
'errors' => 'Error management and analytics',
'backup' => 'Backup and restore operations',

View File

@@ -187,12 +187,13 @@ final readonly class ConsoleTUI
$needsRender = true;
}
// Render if needed (with throttling)
// Render if needed (with throttling to reduce flickering)
if ($needsRender) {
$currentTime = microtime(true);
$timeSinceLastRender = $currentTime - $lastRenderTime;
$minRenderInterval = 0.033; // ~30 FPS instead of 60 to reduce flickering
if ($timeSinceLastRender >= 0.016) { // ~60 FPS max
if ($timeSinceLastRender >= $minRenderInterval) {
$this->renderCurrentView();
$lastRenderTime = $currentTime;
$needsRender = false;
@@ -204,7 +205,7 @@ final readonly class ConsoleTUI
// Sleep if no events processed to reduce CPU usage
if ($eventsProcessed === 0) {
usleep(5000); // 5ms
usleep(10000); // 10ms - increased to reduce CPU usage
}
} catch (Throwable $e) {
$errorCount++;

View File

@@ -377,6 +377,11 @@ final readonly class KeyboardEventHandler
*/
private function keyEventToString(KeyEvent $event): string
{
// Check for Enter key explicitly (multiple formats)
if ($event->key === 'Enter' || $event->key === "\n" || $event->key === "\r" || $event->key === "\r\n") {
return TuiKeyCode::ENTER->value;
}
if ($event->code !== '') {
return $event->code;
}

View File

@@ -86,6 +86,10 @@ final class TuiRenderer
TuiView::HELP => $this->renderHelp($state),
};
// Re-render menu bar AFTER content to ensure it's always visible
// This prevents content from overwriting the menu bar
$this->renderMenuBar($state, $terminalSize->width);
// Render status line at bottom
$this->renderStatusLine($state, $terminalSize->width);
@@ -114,17 +118,20 @@ final class TuiRenderer
}
/**
* Clear only the content area (from line 4 downwards), preserving the menu bar (lines 1-3)
* Clear only the content area (from line 4 downwards), preserving the menu bar (lines 2-3)
* Line 1: empty spacing (can be cleared)
* Lines 2-3: menu bar (MUST be preserved)
* Line 4+: content area (will be cleared)
*/
private function clearContentArea(): void
{
$terminalSize = TerminalSize::detect();
// Clear line 1 (spacing line) explicitly
// Clear line 1 (spacing line) explicitly - this is safe to clear
$this->output->write(CursorControlCode::POSITION->format(1, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
// Position cursor at line 4 (start of content area)
// Position cursor at line 4 (start of content area, after menu bar at 2-3)
$this->output->write(CursorControlCode::POSITION->format(4, 1));
// Clear everything from cursor downwards (preserves lines 2-3: menu bar)
@@ -151,25 +158,40 @@ final class TuiRenderer
*/
private function renderCategories(TuiState $state): void
{
// Ensure we're at the correct starting line (4) for content
// Start at line 4 (after menu bar at lines 2-3)
$currentLine = 4;
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->writeLine('📂 Select Category:', ConsoleColor::BRIGHT_YELLOW);
// Clear and write title - ensure we're at the right position
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$this->output->write('📂 Select Category:', ConsoleColor::BRIGHT_YELLOW);
// Don't use writeLine here - we control the line manually
$this->output->write("\n");
$currentLine++;
$categories = $state->getCategories();
$maxVisibleCategories = min(count($categories), 20); // Limit visible items to prevent overflow
foreach ($categories as $index => $category) {
// Only render visible categories to prevent overflow
if ($index >= $maxVisibleCategories) {
break;
}
$isSelected = $index === $state->getSelectedCategory();
$isHovered = $state->isContentItemHovered('category', $index);
// Position cursor at correct line before rendering
// Position cursor at correct line before rendering - ensure we're at column 1
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->renderCategoryItem($category, $isSelected, $isHovered);
$currentLine++;
}
$this->output->newLine();
// Render navigation bar after categories
// Position cursor explicitly before rendering navigation
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->writeLine(''); // Empty line before navigation
$this->renderNavigationBar([
"↑/↓: Navigate",
"Enter: Select",
@@ -195,8 +217,9 @@ final class TuiRenderer
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
// Clear the entire line first to remove any leftover characters
// Use CLEAR_LINE and reset to column 1
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$this->output->write("\r"); // Reset to beginning of line
// Build the text without any tabs or extra spaces
$text = "{$prefix}{$icon} {$name} ({$count} commands)";
@@ -208,8 +231,8 @@ final class TuiRenderer
$this->output->write($text);
}
// Move to next line explicitly
$this->output->write("\n");
// Don't write newline here - the caller controls line positioning
// The cursor will be positioned by the caller for the next item
}
/**
@@ -222,17 +245,57 @@ final class TuiRenderer
return;
}
// Start at line 4 (after menu bar at lines 2-3)
$currentLine = 4;
// Clear and write title
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$icon = $category['icon'] ?? '📁';
$this->output->writeLine("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW);
$this->output->writeLine('');
$this->output->write("📂 {$icon} {$category['name']} Commands:", ConsoleColor::BRIGHT_YELLOW);
$this->output->write("\n");
$currentLine++;
// Empty line
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\n");
$currentLine++;
foreach ($category['commands'] as $index => $command) {
$commands = $category['commands'];
$maxVisibleCommands = min(count($commands), 15); // Limit visible items
foreach ($commands as $index => $command) {
if ($index >= $maxVisibleCommands) {
break;
}
$isSelected = $index === $state->getSelectedCommand();
$isHovered = $state->isContentItemHovered('command', $index);
// Position cursor at correct line before rendering
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->renderCommandItem($command, $isSelected, $isHovered);
$currentLine++; // Command name line
// Check if description exists and increment line counter
$hasDescription = false;
if ($command instanceof DiscoveredAttribute) {
$attribute = $command->createAttributeInstance();
$hasDescription = $attribute !== null && !empty($attribute->description ?? '');
} else {
$hasDescription = !empty($command->description ?? '');
}
if ($hasDescription) {
$currentLine++; // Description line
}
}
$this->output->newLine();
// Render navigation bar after commands
$this->output->write(CursorControlCode::POSITION->format($currentLine, 1));
$this->output->writeLine('');
$this->renderNavigationBar([
"↑/↓: Navigate",
"Enter: Execute",
@@ -269,11 +332,21 @@ final class TuiRenderer
$commandDescription = $command->description ?? '';
}
$this->output->writeLine("{$prefix}{$commandName}", $color);
// Clear line first
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
// Write command name
$this->output->write("{$prefix}{$commandName}", $color);
$this->output->write("\n");
if (! empty($commandDescription)) {
// Clear next line and write description
$descColor = ConsoleColor::GRAY;
$this->output->writeLine(" {$commandDescription}", $descColor);
$this->output->write(ScreenControlCode::CLEAR_LINE->format());
$this->output->write("\r");
$this->output->write(" {$commandDescription}", $descColor);
$this->output->write("\n");
}
}

View File

@@ -21,7 +21,9 @@ use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Pcntl\PcntlService;
use App\Framework\Pcntl\ValueObjects\Signal;
use JetBrains\PhpStorm\NoReturn;
use Throwable;
final class ConsoleApplication
@@ -46,7 +48,7 @@ final class ConsoleApplication
// Setup signal handlers für graceful shutdown
$this->signalHandler = new ConsoleSignalHandler(
$this->container,
$container->get(PcntlService::class),
function (Signal $signal) {
$this->handleShutdown($signal);
}
@@ -87,6 +89,7 @@ final class ConsoleApplication
$this->errorHandler = new ConsoleErrorHandler($recoveryService, $logger);
}
#[NoReturn]
public function handleShutdown(Signal $signal): void
{
$this->shutdownRequested = true;

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Console;
use App\Framework\DI\Container;
use App\Framework\Pcntl\PcntlService;
use App\Framework\Pcntl\ValueObjects\Signal;
use Closure;
/**
* Kapselt Signal-Handler-Setup für ConsoleApplication.
@@ -16,13 +17,10 @@ use App\Framework\Pcntl\ValueObjects\Signal;
*/
final readonly class ConsoleSignalHandler
{
private ?PcntlService $pcntlService = null;
public function __construct(
private Container $container,
private callable $shutdownCallback
) {
}
private PcntlService $pcntlService,
private Closure $shutdownCallback
) {}
/**
* Setup shutdown handlers für SIGTERM, SIGINT, SIGHUP.
@@ -32,8 +30,6 @@ final readonly class ConsoleSignalHandler
public function setupShutdownHandlers(): void
{
try {
$this->pcntlService = $this->container->get(PcntlService::class);
$this->pcntlService->registerSignal(Signal::SIGTERM, function (Signal $signal) {
($this->shutdownCallback)($signal);
});
@@ -58,9 +54,7 @@ final readonly class ConsoleSignalHandler
*/
public function dispatchSignals(): void
{
if ($this->pcntlService !== null) {
$this->pcntlService->dispatchSignals();
}
$this->pcntlService->dispatchSignals();
}
/**

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Console;
use App\Framework\Core\ValueObjects\Email;
use App\Framework\Core\ValueObjects\EmailAddress;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\UrlFactory;
@@ -105,9 +105,9 @@ final readonly class ParsedArguments
/**
* Get Email value object
*/
public function getEmail(string $name): Email
public function getEmail(string $name): EmailAddress
{
return new Email($this->getString($name));
return new EmailAddress($this->getString($name));
}
/**

View File

@@ -191,7 +191,7 @@ class ProgressBar
*/
private function getRemaining(float $percent): string
{
if ($percent === 0) {
if ($percent === 0.0 || $percent < 0.0001) {
return '--';
}

View File

@@ -114,7 +114,7 @@ class Spinner
$frame = $this->frames[$this->currentFrame];
$this->output->write("\r\033[2K{$frame} {$this->message}");
$this->updateCount = $expectedUpdates;
$this->updateCount = (int) $expectedUpdates;
}
return $this;

View File

@@ -23,6 +23,7 @@ use App\Framework\Router\Result\WebSocketResult;
use App\Framework\View\RenderContext;
use App\Framework\View\Template;
use App\Framework\View\TemplateRenderer;
use App\Application\Admin\Service\AdminLayoutProcessor;
final readonly class RouteResponder
{
@@ -36,15 +37,50 @@ final readonly class RouteResponder
public function getContext(ViewResult $result): RenderContext
{
$data = $result->model ? get_object_vars($result->model) + $result->data : $result->data;
// Automatically enrich with admin layout data for admin routes
if ($this->isAdminRoute($this->request->path)) {
$data = $this->enrichWithAdminLayout($data);
}
return new RenderContext(
template: $result->model ? $this->resolveTemplate($result->model) : $result->template,
metaData: $result->metaData,
data: $result->model ? get_object_vars($result->model) + $result->data : $result->data,
data: $data,
layout: '',
slots: $result->slots,
isPartial: $this->isSpaRequest(),
);
}
/**
* Check if the current path is an admin route
*/
private function isAdminRoute(string $path): bool
{
return str_starts_with($path, '/admin/') || $path === '/admin';
}
/**
* Enrich data with admin layout information (navigation, breadcrumbs, etc.)
*/
private function enrichWithAdminLayout(array $data): array
{
try {
// Get AdminLayoutProcessor from container
if (!$this->container->has(AdminLayoutProcessor::class)) {
return $data;
}
$layoutProcessor = $this->container->get(AdminLayoutProcessor::class);
return $layoutProcessor->processLayoutFromArray($data);
} catch (\Throwable $e) {
// Log error but don't break the request
error_log("RouteResponder: Failed to enrich admin layout: " . $e->getMessage());
return $data;
}
}
public function respond(Response|ActionResult $result): Response
{

View File

@@ -38,14 +38,14 @@ final readonly class StorageInitializer
// Register MinIoClient (if needed for S3 driver)
$container->singleton(MinIoClient::class, function (Container $container) use ($env) {
return new MinIoClient(
endpoint: $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
accessKey: $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
secretKey: $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
region: $env->getString('MINIO_REGION', 'us-east-1'),
usePathStyle: $env->getBool('MINIO_USE_PATH_STYLE', true),
endpoint : $env->getString('MINIO_ENDPOINT', 'http://minio:9000'),
accessKey : $env->getString('MINIO_ACCESS_KEY', 'minioadmin'),
secretKey : $env->getString('MINIO_SECRET_KEY', 'minioadmin'),
randomGenerator: $container->get(RandomGenerator::class),
hmacService: $container->get(HmacService::class),
httpClient: $container->get(CurlHttpClient::class)
hmacService : $container->get(HmacService::class),
httpClient : $container->get(CurlHttpClient::class),
region : $env->getString('MINIO_REGION', 'us-east-1'),
usePathStyle : $env->getBool('MINIO_USE_PATH_STYLE', true)
);
});