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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,7 +191,7 @@ class ProgressBar
|
||||
*/
|
||||
private function getRemaining(float $percent): string
|
||||
{
|
||||
if ($percent === 0) {
|
||||
if ($percent === 0.0 || $percent < 0.0001) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user