fix(console): improve InteractiveMenu rendering with layout-aware system

- Add LayoutAreas and LayoutArea value objects for coordinated screen rendering
- Add ScreenRendererInterface for testable screen operations
- Extend ScreenManager with clearContentArea() for selective clearing
- Refactor InteractiveMenu to support LayoutAreas via setLayoutAreas()
- Add prepareScreen() method that handles both standalone and layout-aware modes
- Fix cursor positioning to prevent menu bar overwriting
- Add comprehensive tests for layout areas and rendering behavior

This fixes rendering issues where InteractiveMenu would overwrite the menu bar
and cause misalignment of menu items when used within TUI layouts.
This commit is contained in:
2025-11-10 02:00:41 +01:00
parent 43dd602509
commit 74d50a29cc
7 changed files with 552 additions and 7 deletions

View File

@@ -7,6 +7,8 @@ namespace App\Framework\Console\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\LayoutAreas;
/**
* Klasse für interaktive Menüs in der Konsole.
@@ -15,7 +17,7 @@ use App\Framework\Console\ConsoleOutputInterface;
*/
final class InteractiveMenu
{
private ConsoleOutput $output;
private ConsoleOutputInterface $output;
private array $menuItems = [];
@@ -25,11 +27,23 @@ final class InteractiveMenu
private bool $showNumbers = true;
private ?LayoutAreas $layoutAreas = null;
public function __construct(ConsoleOutputInterface $output)
{
$this->output = $output;
}
/**
* Set layout areas for coordinated rendering (e.g., within TUI with menu bar).
* If null, menu operates in standalone mode (clears entire screen).
*/
public function setLayoutAreas(?LayoutAreas $layoutAreas): self
{
$this->layoutAreas = $layoutAreas;
return $this;
}
/**
* Setzt den Titel des Menüs.
*/
@@ -88,8 +102,7 @@ final class InteractiveMenu
*/
public function showSimple(): mixed
{
// Verwende den ScreenManager anstelle des direkten clearScreen()
$this->output->screen->newMenu();
$this->prepareScreen();
if ($this->title) {
$this->output->writeLine($this->title, ConsoleColor::BRIGHT_CYAN);
@@ -175,8 +188,7 @@ final class InteractiveMenu
*/
private function renderMenu(): void
{
// Verwende den ScreenManager anstelle des direkten clearScreen()
$this->output->screen->newMenu();
$this->prepareScreen();
if ($this->title) {
$this->output->writeLine($this->title, ConsoleColor::BRIGHT_CYAN);
@@ -272,6 +284,23 @@ final class InteractiveMenu
}
}
// Die clearScreen-Methode wurde entfernt, da sie durch den ScreenManager ersetzt wurde.
// Verwende stattdessen $this->output->screen()->newScreen() oder $this->output->display()->clear().
/**
* Prepares the screen for rendering based on layout context.
* If layoutAreas is set, only clears content area and positions cursor correctly.
* Otherwise, clears entire screen (standalone mode).
*/
private function prepareScreen(): void
{
if ($this->layoutAreas === null) {
// Standalone mode: clear entire screen
$this->output->screen->newMenu();
} else {
// Layout-aware mode: only clear content area
$contentArea = $this->layoutAreas->contentArea();
$this->output->screen->clearContentArea($contentArea->startLine);
// Position cursor at content area start
$this->output->writeRaw(CursorControlCode::POSITION->format($contentArea->startLine, 1));
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Screen;
/**
* Represents a single layout area with its boundaries.
*/
final readonly class LayoutArea
{
public function __construct(
public int $startLine,
public int $endLine,
public int $height
) {
if ($startLine < 1 || $endLine < 1) {
throw new \InvalidArgumentException('Line numbers must be >= 1');
}
if ($startLine > $endLine) {
throw new \InvalidArgumentException('Start line must be <= end line');
}
if ($height < 1) {
throw new \InvalidArgumentException('Height must be >= 1');
}
}
/**
* Check if a line number is within this area
*/
public function containsLine(int $line): bool
{
return $line >= $this->startLine && $line <= $this->endLine;
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Screen;
use App\Framework\Console\Layout\TerminalSize;
/**
* Defines layout areas with their start lines for coordinated screen rendering.
* Used to prevent components from overwriting each other (e.g., menu bar vs content).
*/
final readonly class LayoutAreas
{
/**
* @param int $menuBarStartLine Start line for menu bar (1-based)
* @param int $menuBarEndLine End line for menu bar (1-based, inclusive)
* @param int $contentStartLine Start line for content area (1-based)
* @param int $statusStartLine Start line for status area (1-based, 0 = no status area)
* @param TerminalSize $terminalSize Terminal size for calculations
*/
public function __construct(
public int $menuBarStartLine,
public int $menuBarEndLine,
public int $contentStartLine,
public int $statusStartLine,
public TerminalSize $terminalSize
) {
if ($menuBarStartLine < 1 || $contentStartLine < 1) {
throw new \InvalidArgumentException('Line numbers must be >= 1');
}
// If menu bar exists (endLine > 0), validate it
if ($menuBarEndLine > 0) {
if ($menuBarStartLine > $menuBarEndLine) {
throw new \InvalidArgumentException('Menu bar start line must be <= end line');
}
if ($contentStartLine <= $menuBarEndLine) {
throw new \InvalidArgumentException('Content area must start after menu bar');
}
}
if ($statusStartLine > 0 && $statusStartLine <= $contentStartLine) {
throw new \InvalidArgumentException('Status area must start after content area');
}
}
/**
* Get menu bar area definition (null if no menu bar)
*/
public function menuBarArea(): ?LayoutArea
{
if ($this->menuBarEndLine === 0) {
return null;
}
return new LayoutArea(
startLine: $this->menuBarStartLine,
endLine: $this->menuBarEndLine,
height: $this->menuBarEndLine - $this->menuBarStartLine + 1
);
}
/**
* Get content area definition
*/
public function contentArea(): LayoutArea
{
$endLine = $this->statusStartLine > 0
? $this->statusStartLine - 1
: $this->terminalSize->height;
return new LayoutArea(
startLine: $this->contentStartLine,
endLine: $endLine,
height: $endLine - $this->contentStartLine + 1
);
}
/**
* Get status area definition (if available)
*/
public function statusArea(): ?LayoutArea
{
if ($this->statusStartLine === 0) {
return null;
}
return new LayoutArea(
startLine: $this->statusStartLine,
endLine: $this->terminalSize->height,
height: $this->terminalSize->height - $this->statusStartLine + 1
);
}
/**
* Create layout areas for TUI mode (MenuBar: lines 1-3, Content: from line 4)
*/
public static function forTui(?TerminalSize $terminalSize = null): self
{
$terminalSize = $terminalSize ?? TerminalSize::detect();
return new self(
menuBarStartLine: 1,
menuBarEndLine: 3,
contentStartLine: 4,
statusStartLine: 0, // No status area by default
terminalSize: $terminalSize
);
}
/**
* Create layout areas for standalone mode (no menu bar, content from line 1)
*/
public static function standalone(?TerminalSize $terminalSize = null): self
{
$terminalSize = $terminalSize ?? TerminalSize::detect();
return new self(
menuBarStartLine: 1,
menuBarEndLine: 0, // No menu bar
contentStartLine: 1,
statusStartLine: 0,
terminalSize: $terminalSize
);
}
/**
* Create custom layout areas
*/
public static function create(
int $menuBarStartLine,
int $menuBarEndLine,
int $contentStartLine,
int $statusStartLine = 0,
?TerminalSize $terminalSize = null
): self {
return new self(
menuBarStartLine: $menuBarStartLine,
menuBarEndLine: $menuBarEndLine,
contentStartLine: $contentStartLine,
statusStartLine: $statusStartLine,
terminalSize: $terminalSize ?? TerminalSize::detect()
);
}
}

View File

@@ -114,6 +114,28 @@ final class ScreenManager
return $this;
}
/**
* Clear only the content area starting from a specific line, preserving content above.
* Useful when rendering within a layout (e.g., preserving menu bar).
*/
public function clearContentArea(int $startLine): self
{
if (!$this->output->isTerminal()) {
return $this;
}
// Position cursor at the start line
$this->output->writeRaw(CursorControlCode::POSITION->format($startLine, 1));
// Clear everything from cursor downwards
$this->output->writeRaw(ScreenControlCode::CLEAR_BELOW->format());
// Position cursor back at start line for content rendering
$this->output->writeRaw(CursorControlCode::POSITION->format($startLine, 1));
return $this;
}
/**
* Entscheidet, ob der Bildschirm gelöscht werden soll.
*/

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Screen;
/**
* Interface for screen rendering operations.
* Allows mocking in tests for verification of screen operations.
*/
interface ScreenRendererInterface
{
/**
* Clear the entire screen
*/
public function clear(): void;
/**
* Clear content area starting from a specific line
*/
public function clearContentArea(int $startLine): void;
/**
* Position cursor at specific line and column (1-based)
*/
public function position(int $line, int $column = 1): void;
/**
* Write raw output (ANSI codes, etc.)
*/
public function writeRaw(string $data): void;
}