chore: update console components, logging, router and add subdomain support
This commit is contained in:
@@ -45,9 +45,13 @@ final readonly class ConsoleTUI
|
||||
*/
|
||||
public function run(): ExitCode
|
||||
{
|
||||
$this->initialize();
|
||||
$this->mainLoop();
|
||||
$this->cleanup();
|
||||
try {
|
||||
$this->initialize();
|
||||
$this->mainLoop();
|
||||
} finally {
|
||||
// Always cleanup, even on interrupt or error
|
||||
$this->cleanup();
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
@@ -57,12 +61,35 @@ final readonly class ConsoleTUI
|
||||
*/
|
||||
private function initialize(): void
|
||||
{
|
||||
// Setup signal handlers for clean shutdown
|
||||
$this->setupSignalHandlers();
|
||||
|
||||
$this->setupTerminal();
|
||||
$this->showWelcomeScreen();
|
||||
$this->loadCommands();
|
||||
$this->state->setRunning(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup signal handlers for graceful shutdown
|
||||
*/
|
||||
private function setupSignalHandlers(): void
|
||||
{
|
||||
if (function_exists('pcntl_signal')) {
|
||||
pcntl_signal(SIGINT, [$this, 'handleShutdownSignal']);
|
||||
pcntl_signal(SIGTERM, [$this, 'handleShutdownSignal']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle shutdown signals (SIGINT, SIGTERM)
|
||||
*/
|
||||
public function handleShutdownSignal(int $signal): void
|
||||
{
|
||||
$this->state->setRunning(false);
|
||||
// Cleanup will be called in run() method's finally block or in cleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Main event loop
|
||||
*/
|
||||
@@ -71,6 +98,11 @@ final readonly class ConsoleTUI
|
||||
$needsRender = true;
|
||||
|
||||
while ($this->state->isRunning()) {
|
||||
// Dispatch any pending signals (for graceful shutdown)
|
||||
if (function_exists('pcntl_signal_dispatch')) {
|
||||
pcntl_signal_dispatch();
|
||||
}
|
||||
|
||||
// Only render when needed
|
||||
if ($needsRender) {
|
||||
$this->renderCurrentView();
|
||||
@@ -104,8 +136,8 @@ final readonly class ConsoleTUI
|
||||
|
||||
// Dispatch event to input handler
|
||||
if ($event instanceof MouseEvent) {
|
||||
$this->inputHandler->handleMouseEvent($event, $this->state);
|
||||
return true; // Mouse events always need re-render
|
||||
$needsRender = $this->inputHandler->handleMouseEvent($event, $this->state, $this->commandHistory);
|
||||
return $needsRender; // Only re-render if handler indicates it's needed
|
||||
} elseif ($event instanceof KeyEvent) {
|
||||
// Handle Ctrl+C
|
||||
if ($event->ctrl && $event->key === 'C') {
|
||||
@@ -126,11 +158,84 @@ final readonly class ConsoleTUI
|
||||
*/
|
||||
private function cleanup(): void
|
||||
{
|
||||
// Flush any remaining mouse events from input buffer before restoring terminal
|
||||
$this->flushRemainingInput();
|
||||
|
||||
$this->restoreTerminal();
|
||||
$this->output->writeLine('');
|
||||
$this->output->writeLine('👋 Console session ended. Goodbye!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any remaining input events from STDIN to prevent them from appearing in terminal
|
||||
*/
|
||||
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());
|
||||
$this->output->write(MouseControlCode::DISABLE_ALL->format());
|
||||
}
|
||||
|
||||
// Flush output to ensure commands are sent immediately
|
||||
if (function_exists('fflush')) {
|
||||
fflush(STDOUT);
|
||||
}
|
||||
|
||||
// Delay to let terminal process disable commands
|
||||
usleep(100000); // 100ms
|
||||
|
||||
// Set non-blocking mode
|
||||
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
|
||||
stream_set_blocking(STDIN, false);
|
||||
|
||||
try {
|
||||
// Aggressively read and discard ALL remaining input
|
||||
$totalDiscarded = 0;
|
||||
$maxAttempts = 200; // Read up to 200 characters
|
||||
$attempt = 0;
|
||||
|
||||
while ($attempt < $maxAttempts && $totalDiscarded < 1000) {
|
||||
$read = [STDIN];
|
||||
$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
|
||||
}
|
||||
$attempt++;
|
||||
usleep(5000); // 5ms
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read and discard characters in chunks
|
||||
$readCount = 0;
|
||||
while (($char = fgetc(STDIN)) !== false && $readCount < 50) {
|
||||
$totalDiscarded++;
|
||||
$readCount++;
|
||||
}
|
||||
|
||||
if ($readCount === 0) {
|
||||
$attempt++;
|
||||
usleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
$attempt = 0; // Reset attempt counter if we read something
|
||||
}
|
||||
} finally {
|
||||
// Restore blocking mode
|
||||
stream_set_blocking(STDIN, $originalBlocking);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup terminal for interactive mode
|
||||
*/
|
||||
@@ -143,6 +248,10 @@ 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());
|
||||
@@ -157,7 +266,9 @@ final readonly class ConsoleTUI
|
||||
*/
|
||||
private function restoreTerminal(): void
|
||||
{
|
||||
// Disable mouse reporting
|
||||
// Note: Mouse tracking is disabled in flushRemainingInput() before this
|
||||
// But we disable it here too as a safety measure
|
||||
$this->output->write(MouseControlCode::DISABLE_MOVE->format());
|
||||
$this->output->write(MouseControlCode::DISABLE_SGR->format());
|
||||
$this->output->write(MouseControlCode::DISABLE_ALL->format());
|
||||
|
||||
|
||||
@@ -130,12 +130,19 @@ final class InputParser
|
||||
$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:
|
||||
@@ -143,7 +150,8 @@ final class InputParser
|
||||
// Bit 2: Shift
|
||||
// Bit 3: Meta/Alt
|
||||
// Bit 4: Ctrl
|
||||
// Bit 5-6: Polarity (64=scroll up, 65=scroll down)
|
||||
// Bit 5: Mouse move (32 = 0x20)
|
||||
// Bit 6-7: Scroll (64=scroll up, 65=scroll down)
|
||||
|
||||
$button = $buttonCode & 0x03;
|
||||
$shift = ($buttonCode & 0x04) !== 0;
|
||||
@@ -153,6 +161,10 @@ final class InputParser
|
||||
// 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';
|
||||
@@ -238,5 +250,34 @@ final class InputParser
|
||||
usleep(1000); // 1ms
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any remaining input characters from STDIN
|
||||
*/
|
||||
private function discardRemainingInput(): void
|
||||
{
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,34 +10,107 @@ use App\Framework\Console\ConsoleOutputInterface;
|
||||
/**
|
||||
* Menu bar with clickable entries
|
||||
*/
|
||||
final readonly class MenuBar
|
||||
final class MenuBar
|
||||
{
|
||||
/**
|
||||
* @var array<int> Stores the start X position of each menu item (index => startX)
|
||||
*/
|
||||
private array $itemPositions = [];
|
||||
|
||||
/**
|
||||
* @var array<int> Stores the end X position of each menu item (index => endX)
|
||||
*/
|
||||
private array $itemEndPositions = [];
|
||||
|
||||
/**
|
||||
* @var ?string The active menu item used during last render (to know prefix length)
|
||||
*/
|
||||
private ?string $lastActiveMenu = null;
|
||||
|
||||
/**
|
||||
* @var array<string, array<string>> Submenu items for each menu item (menuItem => [subItem1, subItem2, ...])
|
||||
*/
|
||||
private array $submenus = [];
|
||||
|
||||
/**
|
||||
* @param array<string> $items Menu items (e.g. ['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe'])
|
||||
* @param array<string, array<string>> $submenus Optional submenu items for each menu item
|
||||
*/
|
||||
public function __construct(
|
||||
private array $items
|
||||
private array $items,
|
||||
array $submenus = []
|
||||
) {
|
||||
$this->submenus = $submenus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set submenu items for a menu item
|
||||
*/
|
||||
public function setSubmenu(string $menuItem, array $submenuItems): void
|
||||
{
|
||||
$this->submenus[$menuItem] = $submenuItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get submenu items for a menu item
|
||||
*/
|
||||
public function getSubmenu(string $menuItem): array
|
||||
{
|
||||
return $this->submenus[$menuItem] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a menu item has submenu items
|
||||
*/
|
||||
public function hasSubmenu(string $menuItem): bool
|
||||
{
|
||||
return !empty($this->submenus[$menuItem] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the menu bar at the top of the screen
|
||||
* Tracks exact X positions of each menu item for accurate click detection
|
||||
*/
|
||||
public function render(ConsoleOutputInterface $output, ?string $activeMenu = null, int $screenWidth = 80): void
|
||||
public function render(ConsoleOutputInterface $output, ?string $activeMenu = null, int $screenWidth = 80, ?string $hoveredMenuItem = null): void
|
||||
{
|
||||
$menuLine = '';
|
||||
$separator = ' | ';
|
||||
|
||||
// Reset positions arrays
|
||||
$this->itemPositions = [];
|
||||
$this->itemEndPositions = [];
|
||||
$this->lastActiveMenu = $activeMenu;
|
||||
|
||||
// Track current position in the menu line (1-based, as ANSI coordinates are 1-based)
|
||||
$currentX = 1;
|
||||
|
||||
foreach ($this->items as $index => $item) {
|
||||
// Add separator before each item except the first
|
||||
if ($index > 0) {
|
||||
$menuLine .= $separator;
|
||||
$currentX += mb_strlen($separator);
|
||||
}
|
||||
|
||||
// Store the start position of this item (before prefix)
|
||||
$this->itemPositions[$index] = $currentX;
|
||||
|
||||
$isActive = $item === $activeMenu;
|
||||
$color = $isActive ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
|
||||
$isHovered = $item === $hoveredMenuItem;
|
||||
|
||||
// Determine color: active > hovered > normal
|
||||
$color = $isActive ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
|
||||
$prefix = $isActive ? '▶ ' : ' ';
|
||||
|
||||
$menuLine .= "{$prefix}{$item}";
|
||||
// Build the item string with prefix
|
||||
$itemString = "{$prefix}{$item}";
|
||||
$menuLine .= $itemString;
|
||||
|
||||
// Store the end position of this item (exclusive, so x < endX means inside item)
|
||||
$itemLength = mb_strlen($itemString);
|
||||
$this->itemEndPositions[$index] = $currentX + $itemLength;
|
||||
|
||||
// Update position after this item
|
||||
$currentX += $itemLength;
|
||||
}
|
||||
|
||||
// Pad to screen width
|
||||
@@ -53,25 +126,33 @@ final readonly class MenuBar
|
||||
*/
|
||||
public function isClickInMenuArea(int $x, int $y): bool
|
||||
{
|
||||
// Menu bar is always at y=1 (first line)
|
||||
return $y === 1 && $x >= 1;
|
||||
// Menu bar is at lines 2-3 (line 1 is empty spacing)
|
||||
return ($y === 2 || $y === 3) && $x >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get menu item at click position
|
||||
* Get menu item at click position using tracked exact positions
|
||||
*/
|
||||
public function getItemAtPosition(int $x): ?string
|
||||
{
|
||||
// Simple calculation: approximate position based on item lengths
|
||||
// This is a simplified version - in a real implementation, you'd track exact positions
|
||||
$currentPos = 3; // Start after initial padding
|
||||
// If positions haven't been tracked yet (render not called), return null
|
||||
if (empty($this->itemPositions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$itemLength = mb_strlen($item) + 4; // Include prefix and spacing
|
||||
if ($x >= $currentPos && $x < $currentPos + $itemLength) {
|
||||
// Find which item contains this X position using tracked start and end positions
|
||||
foreach ($this->items as $index => $item) {
|
||||
$startX = $this->itemPositions[$index] ?? null;
|
||||
$endX = $this->itemEndPositions[$index] ?? null;
|
||||
|
||||
if ($startX === null || $endX === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if click is within this item's range (startX inclusive, endX exclusive)
|
||||
if ($x >= $startX && $x < $endX) {
|
||||
return $item;
|
||||
}
|
||||
$currentPos += $itemLength + 3; // Add separator length
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -113,5 +194,26 @@ final readonly class MenuBar
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get X position of a menu item (must be called after render)
|
||||
*/
|
||||
public function getItemXPosition(int $index): ?int
|
||||
{
|
||||
return $this->itemPositions[$index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get X position of a menu item by name (must be called after render)
|
||||
*/
|
||||
public function getItemXPositionByName(string $itemName): ?int
|
||||
{
|
||||
$index = array_search($itemName, $this->items, true);
|
||||
if ($index === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->itemPositions[$index] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,5 +42,16 @@ final readonly class MouseEvent
|
||||
{
|
||||
return in_array($this->button, [64, 65], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a mouse move event (mouse movement without button press)
|
||||
* Button code 32 (0x20) indicates mouse move without any button pressed
|
||||
*/
|
||||
public function isMouseMove(): bool
|
||||
{
|
||||
// Mouse move is indicated by button code 32 (bit 5 set)
|
||||
// or by pressed=false when button is 0 (movement tracking)
|
||||
return ($this->button & 0x20) !== 0 || (!$this->pressed && $this->button === 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,18 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
/**
|
||||
* Handles all keyboard input and navigation for the TUI
|
||||
*/
|
||||
final readonly class TuiInputHandler
|
||||
final class TuiInputHandler
|
||||
{
|
||||
/**
|
||||
* Track last hover update time to throttle mouse move events
|
||||
*/
|
||||
private float $lastHoverUpdate = 0.0;
|
||||
|
||||
/**
|
||||
* Minimum time between hover updates (50ms = 20 updates per second max)
|
||||
*/
|
||||
private const float HOVER_UPDATE_INTERVAL = 0.05;
|
||||
|
||||
public function __construct(
|
||||
private TuiCommandExecutor $commandExecutor,
|
||||
private ?MenuBar $menuBar = null
|
||||
@@ -27,10 +37,25 @@ final readonly class TuiInputHandler
|
||||
public function handleInput(string $key, TuiState $state, CommandHistory $history): void
|
||||
{
|
||||
// Global shortcuts first
|
||||
if ($this->handleGlobalShortcuts($key, $state)) {
|
||||
$wasMenuAction = $this->handleGlobalShortcuts($key, $state);
|
||||
if ($wasMenuAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle dropdown navigation if dropdown is open
|
||||
if ($state->getActiveDropdownMenu() !== null) {
|
||||
$handled = $this->handleDropdownInput($key, $state);
|
||||
if ($handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Close active menu when any non-menu action is performed
|
||||
// This prevents menu from staying open and causing flicker
|
||||
if ($state->getActiveMenu() !== null && $state->getActiveDropdownMenu() === null) {
|
||||
$state->setActiveMenu(null);
|
||||
}
|
||||
|
||||
// View-specific handling
|
||||
match ($state->getCurrentView()) {
|
||||
TuiView::CATEGORIES => $this->handleCategoriesInput($key, $state),
|
||||
@@ -49,9 +74,19 @@ final readonly class TuiInputHandler
|
||||
private function handleGlobalShortcuts(string $key, 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':
|
||||
case TuiKeyCode::ESCAPE->value:
|
||||
$state->setRunning(false);
|
||||
|
||||
return true;
|
||||
@@ -296,12 +331,16 @@ final readonly class TuiInputHandler
|
||||
{
|
||||
switch ($key) {
|
||||
case TuiKeyCode::ARROW_UP->value:
|
||||
$state->navigateUp();
|
||||
// Use history-specific navigation with item count for proper wrap-around
|
||||
$items = $this->getHistoryItems($state, $history);
|
||||
$state->navigateHistoryItemUpWithCount(count($items));
|
||||
|
||||
break;
|
||||
|
||||
case TuiKeyCode::ARROW_DOWN->value:
|
||||
$state->navigateDown();
|
||||
// Use history-specific navigation with item count for proper wrap-around
|
||||
$items = $this->getHistoryItems($state, $history);
|
||||
$state->navigateHistoryItemDownWithCount(count($items));
|
||||
|
||||
break;
|
||||
|
||||
@@ -494,24 +533,735 @@ final readonly class TuiInputHandler
|
||||
/**
|
||||
* Handle mouse events
|
||||
*/
|
||||
public function handleMouseEvent(MouseEvent $event, TuiState $state): void
|
||||
public function handleMouseEvent(MouseEvent $event, TuiState $state, CommandHistory $history): bool
|
||||
{
|
||||
// Only handle button presses, not releases
|
||||
if (! $event->pressed) {
|
||||
return;
|
||||
// Handle mouse move events for hover effects
|
||||
if ($event->isMouseMove()) {
|
||||
return $this->handleMouseMove($event, $state, $history);
|
||||
}
|
||||
|
||||
// Only handle button presses, not releases (except for scroll)
|
||||
if (! $event->pressed && ! $event->isScrollEvent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle scroll events (mouse wheel)
|
||||
if ($event->isScrollEvent()) {
|
||||
// Only process scroll in views that support navigation
|
||||
$currentView = $state->getCurrentView();
|
||||
$scrollableViews = [
|
||||
TuiView::CATEGORIES,
|
||||
TuiView::COMMANDS,
|
||||
TuiView::SEARCH,
|
||||
TuiView::HISTORY,
|
||||
];
|
||||
|
||||
if (in_array($currentView, $scrollableViews, true)) {
|
||||
if ($event->button === 64) {
|
||||
// Scroll Up
|
||||
if ($currentView === TuiView::HISTORY) {
|
||||
// Use history-specific navigation with item count for proper wrap-around
|
||||
$items = $this->getHistoryItems($state, $history);
|
||||
$state->navigateHistoryItemUpWithCount(count($items));
|
||||
} else {
|
||||
$state->navigateUp();
|
||||
}
|
||||
} elseif ($event->button === 65) {
|
||||
// Scroll Down
|
||||
if ($currentView === TuiView::HISTORY) {
|
||||
// Use history-specific navigation with item count for proper wrap-around
|
||||
$items = $this->getHistoryItems($state, $history);
|
||||
$state->navigateHistoryItemDownWithCount(count($items));
|
||||
} else {
|
||||
$state->navigateDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Re-render needed for scroll
|
||||
}
|
||||
|
||||
// Only update status for menu clicks, not all mouse movements
|
||||
// This reduces flickering
|
||||
|
||||
// Handle dropdown clicks first (if dropdown is open)
|
||||
if ($state->getActiveDropdownMenu() !== null) {
|
||||
$dropdownItemIndex = $this->handleDropdownClick($event, $state);
|
||||
if ($dropdownItemIndex !== null) {
|
||||
// Dropdown click was handled
|
||||
return true; // Re-render needed
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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; // Re-render needed
|
||||
}
|
||||
|
||||
// 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; // Re-render needed
|
||||
}
|
||||
|
||||
// Handle item clicks (left and right button)
|
||||
if ($event->button === 0) {
|
||||
// Left click - primary action
|
||||
$this->handleItemClick($event, $state, $history, false);
|
||||
return true; // Re-render needed
|
||||
} elseif ($event->button === 2) {
|
||||
// Right click - alternative action
|
||||
$this->handleItemClick($event, $state, $history, true);
|
||||
return true; // Re-render needed
|
||||
}
|
||||
|
||||
return false; // No re-render needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move events for hover effects
|
||||
* Returns true if hover state changed (needs re-render)
|
||||
*/
|
||||
private function handleMouseMove(MouseEvent $event, TuiState $state, CommandHistory $history): bool
|
||||
{
|
||||
// Throttle mouse move events to reduce CPU usage
|
||||
$now = microtime(true);
|
||||
if (($now - $this->lastHoverUpdate) < self::HOVER_UPDATE_INTERVAL) {
|
||||
return false; // Skip this event, too soon since last update
|
||||
}
|
||||
$this->lastHoverUpdate = $now;
|
||||
|
||||
$hoverChanged = false;
|
||||
$oldHoveredMenuItem = $state->getHoveredMenuItem();
|
||||
$oldHoveredDropdownItem = $state->getHoveredDropdownItem();
|
||||
$oldHoveredContentType = $state->getHoveredContentItemType();
|
||||
$oldHoveredContentIndex = $state->getHoveredContentItemIndex();
|
||||
|
||||
// Check if mouse is over menu bar (lines 2-3)
|
||||
if ($this->menuBar !== null && $this->menuBar->isClickInMenuArea($event->x, $event->y)) {
|
||||
$menuItem = $this->menuBar->getItemAtPosition($event->x);
|
||||
if ($menuItem !== null) {
|
||||
$state->setHoveredMenuItem($menuItem);
|
||||
$state->setHoveredDropdownItem(null);
|
||||
$state->setHoveredContentItem(null, null);
|
||||
|
||||
if ($oldHoveredMenuItem !== $menuItem) {
|
||||
$hoverChanged = true;
|
||||
}
|
||||
} else {
|
||||
$state->setHoveredMenuItem(null);
|
||||
if ($oldHoveredMenuItem !== null) {
|
||||
$hoverChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $hoverChanged;
|
||||
}
|
||||
|
||||
// Check if mouse is over dropdown menu
|
||||
if ($state->getActiveDropdownMenu() !== null) {
|
||||
$dropdownItemIndex = $this->calculateDropdownHoverIndex($event, $state);
|
||||
$state->setHoveredDropdownItem($dropdownItemIndex);
|
||||
$state->setHoveredMenuItem(null);
|
||||
$state->setHoveredContentItem(null, null);
|
||||
|
||||
if ($oldHoveredDropdownItem !== $dropdownItemIndex) {
|
||||
$hoverChanged = true;
|
||||
}
|
||||
|
||||
return $hoverChanged;
|
||||
}
|
||||
|
||||
// Check if mouse is over content items
|
||||
$contentItemIndex = $this->calculateContentItemIndex($event->y, $state);
|
||||
$currentView = $state->getCurrentView();
|
||||
|
||||
if ($contentItemIndex !== null) {
|
||||
$itemType = match ($currentView) {
|
||||
TuiView::CATEGORIES => 'category',
|
||||
TuiView::COMMANDS => 'command',
|
||||
TuiView::SEARCH => 'search',
|
||||
TuiView::HISTORY => 'history',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($itemType !== null) {
|
||||
$state->setHoveredContentItem($itemType, $contentItemIndex);
|
||||
$state->setHoveredMenuItem(null);
|
||||
$state->setHoveredDropdownItem(null);
|
||||
|
||||
if ($oldHoveredContentType !== $itemType || $oldHoveredContentIndex !== $contentItemIndex) {
|
||||
$hoverChanged = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear content hover
|
||||
$state->setHoveredContentItem(null, null);
|
||||
$state->setHoveredMenuItem(null);
|
||||
$state->setHoveredDropdownItem(null);
|
||||
|
||||
if ($oldHoveredContentType !== null || $oldHoveredContentIndex !== null) {
|
||||
$hoverChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $hoverChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate content item index from Y position
|
||||
*/
|
||||
private function calculateContentItemIndex(int $y, TuiState $state): ?int
|
||||
{
|
||||
return match ($state->getCurrentView()) {
|
||||
TuiView::CATEGORIES => $this->calculateCategoryIndex($y),
|
||||
TuiView::COMMANDS => $this->calculateCommandIndex($y, $state),
|
||||
TuiView::SEARCH => $this->calculateSearchResultIndex($y, $state),
|
||||
TuiView::HISTORY => $this->calculateHistoryItemIndex($y),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate which dropdown item is hovered based on mouse position
|
||||
*/
|
||||
private function calculateDropdownHoverIndex(MouseEvent $event, TuiState $state): ?int
|
||||
{
|
||||
if ($this->menuBar === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeDropdown = $state->getActiveDropdownMenu();
|
||||
if ($activeDropdown === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$submenuItems = $this->menuBar->getSubmenu($activeDropdown);
|
||||
if (empty($submenuItems)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$menuItems = $this->menuBar->getItems();
|
||||
$activeIndex = array_search($activeDropdown, $menuItems, true);
|
||||
|
||||
if ($activeIndex === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dropdownX = $this->menuBar->getItemXPosition($activeIndex) ?? 1;
|
||||
|
||||
// Check if mouse is in dropdown area (line 4+, column dropdownX+)
|
||||
if ($event->y < 4 || $event->x < $dropdownX) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dropdownItemIndex = $event->y - 4;
|
||||
|
||||
if ($dropdownItemIndex < 0 || $dropdownItemIndex >= count($submenuItems)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't hover over separators
|
||||
if (($submenuItems[$dropdownItemIndex] ?? '') === '---') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dropdownItemIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicks on list items
|
||||
*
|
||||
* @param bool $isRightClick True for right click (alternative action), false for left click (primary action)
|
||||
*/
|
||||
private function handleItemClick(MouseEvent $event, TuiState $state, CommandHistory $history, bool $isRightClick): void
|
||||
{
|
||||
$currentView = $state->getCurrentView();
|
||||
|
||||
// Menu bar takes lines 1-2, items start below
|
||||
if ($event->y <= 2) {
|
||||
return; // Click is in menu bar area
|
||||
}
|
||||
|
||||
// Calculate item index from Y position based on view
|
||||
$itemIndex = $this->calculateItemIndexFromY($event->y, $currentView, $state);
|
||||
|
||||
if ($itemIndex === null) {
|
||||
return; // Click is not on an item
|
||||
}
|
||||
|
||||
// Select item and execute action based on view
|
||||
if ($isRightClick) {
|
||||
// Right click - alternative actions
|
||||
match ($currentView) {
|
||||
TuiView::CATEGORIES => $this->handleCategoryClick($itemIndex, $state), // Same as left click
|
||||
TuiView::COMMANDS => $this->handleCommandRightClick($itemIndex, $state),
|
||||
TuiView::SEARCH => $this->handleSearchResultRightClick($itemIndex, $state),
|
||||
TuiView::HISTORY => $this->handleHistoryItemRightClick($itemIndex, $state, $history),
|
||||
default => null,
|
||||
};
|
||||
} else {
|
||||
// Left click - primary actions
|
||||
match ($currentView) {
|
||||
TuiView::CATEGORIES => $this->handleCategoryClick($itemIndex, $state),
|
||||
TuiView::COMMANDS => $this->handleCommandClick($itemIndex, $state),
|
||||
TuiView::SEARCH => $this->handleSearchResultClick($itemIndex, $state),
|
||||
TuiView::HISTORY => $this->handleHistoryItemClick($itemIndex, $state, $history),
|
||||
default => null, // Other views don't support item clicks
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate item index from Y position
|
||||
*
|
||||
* Layout:
|
||||
* - Line 1-2: Menu bar + separator
|
||||
* - Line 3: Header (varies by view)
|
||||
* - Line 4: Empty line (or search box in search view)
|
||||
* - Line 5+: Items (varies by view)
|
||||
*/
|
||||
private function calculateItemIndexFromY(int $y, TuiView $view, TuiState $state): ?int
|
||||
{
|
||||
return match ($view) {
|
||||
TuiView::CATEGORIES => $this->calculateCategoryIndex($y),
|
||||
TuiView::COMMANDS => $this->calculateCommandIndex($y, $state),
|
||||
TuiView::SEARCH => $this->calculateSearchResultIndex($y, $state),
|
||||
TuiView::HISTORY => $this->calculateHistoryItemIndex($y),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate category index from Y position
|
||||
* Layout: Empty(1) + Menu(2-3) + Header(4) + Empty(5) + Items(6+)
|
||||
*/
|
||||
private function calculateCategoryIndex(int $y): ?int
|
||||
{
|
||||
// Items start at line 6 (after empty 1 + menu 2-3 + header "📂 Select Category:" at 4 + empty at 5)
|
||||
$startLine = 6;
|
||||
|
||||
if ($y < $startLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Each category takes 1 line
|
||||
$index = $y - $startLine;
|
||||
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate command index from Y position
|
||||
* Layout: Empty(1) + Menu(2-3) + Header(4) + Empty(5) + Items(6+)
|
||||
* Commands can take 1-2 lines (name + optional description)
|
||||
*/
|
||||
private function calculateCommandIndex(int $y, TuiState $state): ?int
|
||||
{
|
||||
// Items start at line 6 (after empty 1 + menu 2-3 + header at 4 + empty at 5)
|
||||
$startLine = 6;
|
||||
|
||||
if ($y < $startLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$category = $state->getCurrentCategory();
|
||||
if (! $category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$commands = $category['commands'] ?? [];
|
||||
$currentLine = $startLine;
|
||||
$index = 0;
|
||||
|
||||
foreach ($commands as $command) {
|
||||
// Check if click is on this command's line
|
||||
if ($y === $currentLine) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
// Move to next item (1 line for name, +1 if has description)
|
||||
$commandDescription = $this->getCommandDescription($command);
|
||||
$lines = empty($commandDescription) ? 1 : 2;
|
||||
$currentLine += $lines;
|
||||
$index++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate search result index from Y position
|
||||
* Layout: Empty(1) + Menu(2-3) + Header(4) + Empty(5) + SearchBox(6) + Empty(7) + "Found X"(8) + Empty(9) + Items(10+)
|
||||
*/
|
||||
private function calculateSearchResultIndex(int $y, TuiState $state): ?int
|
||||
{
|
||||
// Items start at line 10 (after empty 1 + menu 2-3 + header 4 + empty 5 + searchbox 6 + empty 7 + "Found" 8 + empty 9)
|
||||
$startLine = 10;
|
||||
|
||||
if ($y < $startLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$results = $state->getSearchResults();
|
||||
if (empty($results)) {
|
||||
return null; // No results to click
|
||||
}
|
||||
|
||||
$currentLine = $startLine;
|
||||
$index = 0;
|
||||
|
||||
foreach ($results as $result) {
|
||||
// Check if click is on this result's line
|
||||
if ($y === $currentLine) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
// Move to next item (1 line for name, +1 if has description)
|
||||
$commandDescription = $this->getCommandDescription($result);
|
||||
$lines = empty($commandDescription) ? 1 : 2;
|
||||
$currentLine += $lines;
|
||||
$index++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate history item index from Y position
|
||||
* Layout: Empty(1) + Menu(2-3) + Header(4) + Empty(5) + Tabs(6) + Empty(7) + Items(8+)
|
||||
*/
|
||||
private function calculateHistoryItemIndex(int $y): ?int
|
||||
{
|
||||
// Items start at line 8 (after empty 1 + menu 2-3 + header 4 + empty 5 + tabs 6 + empty 7)
|
||||
$startLine = 8;
|
||||
|
||||
if ($y < $startLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Each history item takes 1 line
|
||||
$index = $y - $startLine;
|
||||
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command description from command object
|
||||
*/
|
||||
private function getCommandDescription(object $command): string
|
||||
{
|
||||
if ($command instanceof \App\Framework\Discovery\ValueObjects\DiscoveredAttribute) {
|
||||
$attribute = $command->createAttributeInstance();
|
||||
return $attribute?->description ?? '';
|
||||
}
|
||||
|
||||
return $command->description ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dropdown menu input (navigation and selection)
|
||||
*/
|
||||
private function handleDropdownInput(string $key, TuiState $state): bool
|
||||
{
|
||||
if ($this->menuBar === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$activeDropdown = $state->getActiveDropdownMenu();
|
||||
if ($activeDropdown === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$submenuItems = $this->menuBar->getSubmenu($activeDropdown);
|
||||
if (empty($submenuItems)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxIndex = count($submenuItems) - 1;
|
||||
$currentIndex = $state->getSelectedDropdownItem();
|
||||
|
||||
switch ($key) {
|
||||
case TuiKeyCode::ARROW_UP->value:
|
||||
// Navigate up with wrap-around
|
||||
$newIndex = $currentIndex - 1;
|
||||
if ($newIndex < 0) {
|
||||
$newIndex = $maxIndex;
|
||||
}
|
||||
// Skip separators
|
||||
while ($newIndex >= 0 && ($submenuItems[$newIndex] ?? '') === '---') {
|
||||
$newIndex--;
|
||||
}
|
||||
if ($newIndex < 0) {
|
||||
$newIndex = $maxIndex;
|
||||
}
|
||||
$state->setSelectedDropdownItem($newIndex);
|
||||
return true;
|
||||
|
||||
case TuiKeyCode::ARROW_DOWN->value:
|
||||
// Navigate down with wrap-around
|
||||
$newIndex = $currentIndex + 1;
|
||||
if ($newIndex > $maxIndex) {
|
||||
$newIndex = 0;
|
||||
}
|
||||
// Skip separators
|
||||
while ($newIndex <= $maxIndex && ($submenuItems[$newIndex] ?? '') === '---') {
|
||||
$newIndex++;
|
||||
}
|
||||
if ($newIndex > $maxIndex) {
|
||||
$newIndex = 0;
|
||||
}
|
||||
$state->setSelectedDropdownItem($newIndex);
|
||||
return true;
|
||||
|
||||
case TuiKeyCode::ENTER->value:
|
||||
// Execute selected dropdown item
|
||||
$selectedItem = $submenuItems[$currentIndex] ?? null;
|
||||
if ($selectedItem !== null && $selectedItem !== '---') {
|
||||
$this->executeDropdownItem($activeDropdown, $selectedItem, $state);
|
||||
$state->setActiveDropdownMenu(null);
|
||||
$state->setActiveMenu(null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dropdown menu clicks
|
||||
*/
|
||||
private function handleDropdownClick(MouseEvent $event, TuiState $state): ?int
|
||||
{
|
||||
if ($this->menuBar === null || $event->button !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeDropdown = $state->getActiveDropdownMenu();
|
||||
if ($activeDropdown === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$submenuItems = $this->menuBar->getSubmenu($activeDropdown);
|
||||
if (empty($submenuItems)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dropdown is rendered starting at line 4, column dropdownX (after menu bar at 2-3)
|
||||
// We need to check if click is within dropdown area
|
||||
$menuItems = $this->menuBar->getItems();
|
||||
$activeIndex = array_search($activeDropdown, $menuItems, true);
|
||||
|
||||
if ($activeIndex === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get dropdown X position (approximate for now)
|
||||
$dropdownX = $this->menuBar->getItemXPosition($activeIndex) ?? 1;
|
||||
|
||||
// Check if click is in dropdown area (line 4+, column dropdownX+)
|
||||
if ($event->y < 4 || $event->x < $dropdownX) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate which dropdown item was clicked
|
||||
$dropdownItemIndex = $event->y - 4;
|
||||
|
||||
if ($dropdownItemIndex < 0 || $dropdownItemIndex >= count($submenuItems)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clickedItem = $submenuItems[$dropdownItemIndex] ?? null;
|
||||
if ($clickedItem === null || $clickedItem === '---') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Execute clicked item
|
||||
$this->executeDropdownItem($activeDropdown, $clickedItem, $state);
|
||||
$state->setActiveDropdownMenu(null);
|
||||
$state->setActiveMenu(null);
|
||||
|
||||
return $dropdownItemIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 category click - select category and navigate to commands
|
||||
*/
|
||||
private function handleCategoryClick(int $index, TuiState $state): void
|
||||
{
|
||||
$categories = $state->getCategories();
|
||||
|
||||
if ($index < 0 || $index >= count($categories)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedCategory($index);
|
||||
$state->setCurrentView(TuiView::COMMANDS);
|
||||
$state->setSelectedCommand(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle command click - select command and execute it
|
||||
*/
|
||||
private function handleCommandClick(int $index, TuiState $state): void
|
||||
{
|
||||
$category = $state->getCurrentCategory();
|
||||
if (! $category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$commands = $category['commands'] ?? [];
|
||||
|
||||
if ($index < 0 || $index >= count($commands)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedCommand($index);
|
||||
$command = $commands[$index];
|
||||
|
||||
if ($command) {
|
||||
$this->commandExecutor->executeSelectedCommand($command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search result click - select result and execute it
|
||||
*/
|
||||
private function handleSearchResultClick(int $index, TuiState $state): void
|
||||
{
|
||||
$results = $state->getSearchResults();
|
||||
|
||||
if ($index < 0 || $index >= count($results)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedSearchResult($index);
|
||||
$command = $results[$index];
|
||||
|
||||
if ($command) {
|
||||
// Handle DiscoveredAttribute objects
|
||||
if ($command instanceof \App\Framework\Discovery\ValueObjects\DiscoveredAttribute) {
|
||||
$attribute = $command->createAttributeInstance();
|
||||
if ($attribute !== null) {
|
||||
$this->commandExecutor->executeCommand($attribute->name);
|
||||
}
|
||||
} else {
|
||||
$this->commandExecutor->executeCommand($command->name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle history item click - select item and execute it
|
||||
*/
|
||||
private function handleHistoryItemClick(int $index, TuiState $state, CommandHistory $history): void
|
||||
{
|
||||
$items = $this->getHistoryItems($state, $history);
|
||||
|
||||
if ($index < 0 || $index >= count($items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedHistoryItem($index);
|
||||
|
||||
// Execute the history item (same as Enter key)
|
||||
if (isset($items[$index])) {
|
||||
$command = $items[$index]['command'];
|
||||
$history->addToHistory($command);
|
||||
$this->commandExecutor->executeCommand($command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle command right click - open parameter form
|
||||
*/
|
||||
private function handleCommandRightClick(int $index, TuiState $state): void
|
||||
{
|
||||
$category = $state->getCurrentCategory();
|
||||
if (! $category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$commands = $category['commands'] ?? [];
|
||||
|
||||
if ($index < 0 || $index >= count($commands)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedCommand($index);
|
||||
$command = $commands[$index];
|
||||
|
||||
if ($command) {
|
||||
$state->setSelectedCommandForForm($command);
|
||||
$state->setCurrentView(TuiView::FORM);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search result right click - open parameter form
|
||||
*/
|
||||
private function handleSearchResultRightClick(int $index, TuiState $state): void
|
||||
{
|
||||
$results = $state->getSearchResults();
|
||||
|
||||
if ($index < 0 || $index >= count($results)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedSearchResult($index);
|
||||
$command = $results[$index];
|
||||
|
||||
if ($command) {
|
||||
$state->setSelectedCommandForForm($command);
|
||||
$state->setCurrentView(TuiView::FORM);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle history item right click - toggle favorite
|
||||
*/
|
||||
private function handleHistoryItemRightClick(int $index, TuiState $state, CommandHistory $history): void
|
||||
{
|
||||
$items = $this->getHistoryItems($state, $history);
|
||||
|
||||
if ($index < 0 || $index >= count($items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->setSelectedHistoryItem($index);
|
||||
|
||||
// Toggle favorite (same as F key)
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,22 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
/**
|
||||
* Handles all rendering and display logic for the TUI
|
||||
*/
|
||||
final readonly class TuiRenderer
|
||||
final class TuiRenderer
|
||||
{
|
||||
private MenuBar $menuBar;
|
||||
private bool $menuBarInitialized = false;
|
||||
|
||||
public function __construct(
|
||||
private ConsoleOutputInterface $output
|
||||
) {
|
||||
// Initialize menu bar with default items
|
||||
$this->menuBar = new MenuBar(['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe']);
|
||||
// Initialize menu bar with default items and submenus
|
||||
$submenus = [
|
||||
'Datei' => ['Neu', 'Öffnen', 'Speichern', '---', 'Beenden'],
|
||||
'Bearbeiten' => ['Rückgängig', 'Wiederholen', '---', 'Ausschneiden', 'Kopieren', 'Einfügen'],
|
||||
'Ansicht' => ['Vergrößern', 'Verkleinern', 'Normal', '---', 'Vollbild'],
|
||||
'Hilfe' => ['Hilfe anzeigen', 'Über'],
|
||||
];
|
||||
$this->menuBar = new MenuBar(['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe'], $submenus);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,18 +40,28 @@ final readonly class TuiRenderer
|
||||
*/
|
||||
public function render(TuiState $state, CommandHistory $history): void
|
||||
{
|
||||
$this->clearScreen();
|
||||
|
||||
// Get terminal size for layout
|
||||
$terminalSize = TerminalSize::detect();
|
||||
|
||||
// Render menu bar at top
|
||||
// On first render, clear entire screen to remove any welcome screen content
|
||||
if (!$this->menuBarInitialized) {
|
||||
$this->clearScreen();
|
||||
$this->menuBarInitialized = true;
|
||||
} else {
|
||||
// On subsequent renders, clear ONLY the content area (preserve menu bar on lines 1-3)
|
||||
// Line 1: empty spacing
|
||||
// Lines 2-3: menu bar
|
||||
// Line 4+: content (this will be cleared and redrawn)
|
||||
$this->clearContentArea();
|
||||
}
|
||||
|
||||
// Render menu bar on lines 2-3 FIRST, before any content
|
||||
$this->renderMenuBar($state, $terminalSize->width);
|
||||
|
||||
// Render header (or skip if menu bar replaces it)
|
||||
// $this->renderHeader();
|
||||
// Position cursor at line 4, before rendering content
|
||||
$this->output->write(CursorControlCode::POSITION->format(4, 1));
|
||||
|
||||
// Render main content
|
||||
// Render main content starting at line 4 (after spacing + menu bar)
|
||||
match ($state->getCurrentView()) {
|
||||
TuiView::CATEGORIES => $this->renderCategories($state),
|
||||
TuiView::COMMANDS => $this->renderCommands($state),
|
||||
@@ -57,10 +74,24 @@ final readonly class TuiRenderer
|
||||
|
||||
// Render status line at bottom
|
||||
$this->renderStatusLine($state, $terminalSize->width);
|
||||
|
||||
// Render dropdown menu if a menu is active and has submenus
|
||||
$this->renderDropdownMenu($state, $terminalSize->width);
|
||||
|
||||
// Position cursor safely after rendering (below status line)
|
||||
$terminalSize = TerminalSize::detect();
|
||||
$cursorLine = min($terminalSize->height + 1, $terminalSize->height);
|
||||
$this->output->write(CursorControlCode::POSITION->format($cursorLine, 1));
|
||||
|
||||
// Final flush to ensure everything is displayed
|
||||
if (function_exists('fflush')) {
|
||||
fflush(STDOUT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear screen and reset cursor
|
||||
* Note: This clears the entire screen. Use clearContentArea() to preserve the menu bar.
|
||||
*/
|
||||
private function clearScreen(): void
|
||||
{
|
||||
@@ -68,6 +99,26 @@ final readonly class TuiRenderer
|
||||
$this->output->write(CursorControlCode::POSITION->format(1, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only the content area (from line 4 downwards), preserving the menu bar (lines 1-3)
|
||||
*/
|
||||
private function clearContentArea(): void
|
||||
{
|
||||
// First, clear line 1 explicitly (spacing line)
|
||||
$this->output->write(CursorControlCode::POSITION->format(1, 1));
|
||||
$terminalSize = TerminalSize::detect();
|
||||
$this->output->write(str_repeat(' ', $terminalSize->width));
|
||||
|
||||
// Position cursor at line 4 (start of content area)
|
||||
$this->output->write(CursorControlCode::POSITION->format(4, 1));
|
||||
|
||||
// Clear everything from cursor downwards (preserves lines 1-3: spacing + menu bar)
|
||||
$this->output->write(ScreenControlCode::CLEAR_BELOW->format());
|
||||
|
||||
// Position cursor back at line 4 for content rendering
|
||||
$this->output->write(CursorControlCode::POSITION->format(4, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render header with title
|
||||
*/
|
||||
@@ -90,7 +141,8 @@ final readonly class TuiRenderer
|
||||
$categories = $state->getCategories();
|
||||
foreach ($categories as $index => $category) {
|
||||
$isSelected = $index === $state->getSelectedCategory();
|
||||
$this->renderCategoryItem($category, $isSelected);
|
||||
$isHovered = $state->isContentItemHovered('category', $index);
|
||||
$this->renderCategoryItem($category, $isSelected, $isHovered);
|
||||
}
|
||||
|
||||
$this->output->newLine();
|
||||
@@ -108,14 +160,15 @@ final readonly class TuiRenderer
|
||||
/**
|
||||
* Render a category item
|
||||
*/
|
||||
private function renderCategoryItem(array $category, bool $isSelected): void
|
||||
private function renderCategoryItem(array $category, bool $isSelected, bool $isHovered = false): void
|
||||
{
|
||||
$icon = $category['icon'] ?? '📁';
|
||||
$name = $category['name'];
|
||||
$count = count($category['commands']);
|
||||
|
||||
$prefix = $isSelected ? '▶ ' : ' ';
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
|
||||
// Determine color: selected > hovered > normal
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
|
||||
|
||||
$this->output->writeLine(
|
||||
"{$prefix}{$icon} {$name} ({$count} commands)",
|
||||
@@ -139,7 +192,8 @@ final readonly class TuiRenderer
|
||||
|
||||
foreach ($category['commands'] as $index => $command) {
|
||||
$isSelected = $index === $state->getSelectedCommand();
|
||||
$this->renderCommandItem($command, $isSelected);
|
||||
$isHovered = $state->isContentItemHovered('command', $index);
|
||||
$this->renderCommandItem($command, $isSelected, $isHovered);
|
||||
}
|
||||
|
||||
$this->output->newLine();
|
||||
@@ -159,10 +213,11 @@ final readonly class TuiRenderer
|
||||
/**
|
||||
* Render a command item
|
||||
*/
|
||||
private function renderCommandItem(object $command, bool $isSelected): void
|
||||
private function renderCommandItem(object $command, bool $isSelected, bool $isHovered = false): void
|
||||
{
|
||||
$prefix = $isSelected ? '▶ ' : ' ';
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
|
||||
// Determine color: selected > hovered > normal
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
|
||||
|
||||
// Handle DiscoveredAttribute objects
|
||||
if ($command instanceof DiscoveredAttribute) {
|
||||
@@ -214,7 +269,8 @@ final readonly class TuiRenderer
|
||||
|
||||
foreach ($results as $index => $command) {
|
||||
$isSelected = $index === $state->getSelectedSearchResult();
|
||||
$this->renderCommandItem($command, $isSelected);
|
||||
$isHovered = $state->isContentItemHovered('search', $index);
|
||||
$this->renderCommandItem($command, $isSelected, $isHovered);
|
||||
}
|
||||
|
||||
$current = $state->getSelectedSearchResult() + 1;
|
||||
@@ -259,7 +315,8 @@ final readonly class TuiRenderer
|
||||
} else {
|
||||
foreach ($items as $index => $item) {
|
||||
$isSelected = $index === $state->getSelectedHistoryItem();
|
||||
$this->renderHistoryItem($item, $isSelected, $history);
|
||||
$isHovered = $state->isContentItemHovered('history', $index);
|
||||
$this->renderHistoryItem($item, $isSelected, $history, $isHovered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,10 +369,11 @@ final readonly class TuiRenderer
|
||||
/**
|
||||
* Render history item
|
||||
*/
|
||||
private function renderHistoryItem(array $item, bool $isSelected, CommandHistory $history): void
|
||||
private function renderHistoryItem(array $item, bool $isSelected, CommandHistory $history, bool $isHovered = false): void
|
||||
{
|
||||
$prefix = $isSelected ? '▶ ' : ' ';
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
|
||||
// Determine color: selected > hovered > normal
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
|
||||
|
||||
$command = $item['command'];
|
||||
$isFavorite = $history->isFavorite($command);
|
||||
@@ -466,7 +524,121 @@ final readonly class TuiRenderer
|
||||
*/
|
||||
private function renderMenuBar(TuiState $state, int $screenWidth): void
|
||||
{
|
||||
$this->menuBar->render($this->output, $state->getActiveMenu(), $screenWidth);
|
||||
// Explicitly position cursor at line 2 before rendering menu bar
|
||||
// Line 1 is empty spacing, menu bar is at lines 2-3
|
||||
$this->output->write(CursorControlCode::POSITION->format(2, 1));
|
||||
|
||||
// Render menu bar (will write to lines 2-3)
|
||||
$this->menuBar->render($this->output, $state->getActiveMenu(), $screenWidth, $state->getHoveredMenuItem());
|
||||
|
||||
// Flush to ensure menu bar is immediately visible
|
||||
if (function_exists('fflush')) {
|
||||
fflush(STDOUT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render dropdown menu under active menu item
|
||||
*/
|
||||
private function renderDropdownMenu(TuiState $state, int $screenWidth): void
|
||||
{
|
||||
$activeMenu = $state->getActiveMenu();
|
||||
|
||||
// Only render dropdown if a menu is active and has submenus
|
||||
if ($activeMenu === null || !$this->menuBar->hasSubmenu($activeMenu)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$submenuItems = $this->menuBar->getSubmenu($activeMenu);
|
||||
if (empty($submenuItems)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the X position of the active menu item
|
||||
// We'll need to get it from the menu bar's tracked positions
|
||||
$menuItems = $this->menuBar->getItems();
|
||||
$activeIndex = array_search($activeMenu, $menuItems, true);
|
||||
|
||||
if ($activeIndex === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the X position of this menu item from tracked positions
|
||||
// Note: MenuBar must be rendered first to track positions, but we render dropdown before menu bar
|
||||
// So we'll use the approximate calculation
|
||||
$dropdownX = $this->menuBar->getItemXPosition($activeIndex) ?? $this->calculateDropdownXPosition($activeIndex, $activeMenu);
|
||||
|
||||
// Render dropdown starting at line 4, column dropdownX (after menu bar at 2-3)
|
||||
// Find the maximum width of submenu items
|
||||
$maxWidth = 0;
|
||||
foreach ($submenuItems as $item) {
|
||||
if ($item === '---') {
|
||||
continue; // Separator
|
||||
}
|
||||
$maxWidth = max($maxWidth, mb_strlen($item) + 2); // +2 for padding
|
||||
}
|
||||
$dropdownWidth = min($maxWidth, $screenWidth - $dropdownX + 1);
|
||||
|
||||
// Render dropdown box at line 4
|
||||
$this->output->write(CursorControlCode::POSITION->format(4, $dropdownX));
|
||||
|
||||
$selectedIndex = $state->getSelectedDropdownItem();
|
||||
$itemCount = count($submenuItems);
|
||||
|
||||
foreach ($submenuItems as $index => $item) {
|
||||
// Position at line 4 + index
|
||||
$line = 4 + $index;
|
||||
$this->output->write(CursorControlCode::POSITION->format($line, $dropdownX));
|
||||
|
||||
if ($item === '---') {
|
||||
// Render separator
|
||||
$this->output->write(str_repeat('─', $dropdownWidth - 2), ConsoleColor::GRAY);
|
||||
} else {
|
||||
$isSelected = $index === $selectedIndex;
|
||||
$isHovered = $index === $state->getHoveredDropdownItem();
|
||||
|
||||
// Render item with selection indicator
|
||||
$prefix = $isSelected ? '▶ ' : ' ';
|
||||
$text = $prefix . $item;
|
||||
|
||||
// Pad to dropdown width
|
||||
$padding = max(0, $dropdownWidth - mb_strlen($text) - 2);
|
||||
$text .= str_repeat(' ', $padding);
|
||||
|
||||
// Determine color: selected > hovered > normal
|
||||
$color = $isSelected ? ConsoleColor::BRIGHT_WHITE : ($isHovered ? ConsoleColor::BRIGHT_CYAN : ConsoleColor::WHITE);
|
||||
$this->output->writeLine($text, $color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate X position for dropdown based on menu item index
|
||||
*/
|
||||
private function calculateDropdownXPosition(int $menuIndex, string $menuItem): int
|
||||
{
|
||||
// Approximate calculation: we need to sum up widths of all previous items
|
||||
$items = $this->menuBar->getItems();
|
||||
$x = 1; // Start at column 1
|
||||
|
||||
for ($i = 0; $i < $menuIndex; $i++) {
|
||||
$item = $items[$i];
|
||||
// Approximate width: separator (3) + prefix (2) + item length
|
||||
$separatorWidth = $i > 0 ? 3 : 0; // " | " separator
|
||||
$prefixWidth = 2; // " " or "▶ "
|
||||
$itemWidth = mb_strlen($item);
|
||||
$x += $separatorWidth + $prefixWidth + $itemWidth;
|
||||
}
|
||||
|
||||
// Add separator for current item (if not first)
|
||||
if ($menuIndex > 0) {
|
||||
$x += 3; // " | "
|
||||
}
|
||||
|
||||
// Add prefix width for current item
|
||||
$x += 2; // " " or "▶ "
|
||||
|
||||
return $x;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,20 @@ final class TuiState
|
||||
|
||||
private ?string $activeMenu = null;
|
||||
|
||||
// Dropdown menu state
|
||||
private ?string $activeDropdownMenu = null;
|
||||
|
||||
private int $selectedDropdownItem = 0;
|
||||
|
||||
// Hover state (for mouse-over effects)
|
||||
private ?string $hoveredMenuItem = null;
|
||||
|
||||
private ?int $hoveredDropdownItem = null;
|
||||
|
||||
private ?string $hoveredContentItemType = null; // 'category', 'command', 'search', 'history'
|
||||
|
||||
private ?int $hoveredContentItemIndex = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
@@ -201,10 +215,10 @@ final class TuiState
|
||||
public function navigateUp(): void
|
||||
{
|
||||
match ($this->currentView) {
|
||||
TuiView::CATEGORIES => $this->setSelectedCategory($this->selectedCategory - 1),
|
||||
TuiView::COMMANDS => $this->setSelectedCommand($this->selectedCommand - 1),
|
||||
TuiView::SEARCH => $this->setSelectedSearchResult($this->selectedSearchResult - 1),
|
||||
TuiView::HISTORY => $this->setSelectedHistoryItem($this->selectedHistoryItem - 1),
|
||||
TuiView::CATEGORIES => $this->navigateCategoryUp(),
|
||||
TuiView::COMMANDS => $this->navigateCommandUp(),
|
||||
TuiView::SEARCH => $this->navigateSearchResultUp(),
|
||||
TuiView::HISTORY => $this->navigateHistoryItemUp(),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
@@ -212,14 +226,196 @@ final class TuiState
|
||||
public function navigateDown(): void
|
||||
{
|
||||
match ($this->currentView) {
|
||||
TuiView::CATEGORIES => $this->setSelectedCategory($this->selectedCategory + 1),
|
||||
TuiView::COMMANDS => $this->setSelectedCommand($this->selectedCommand + 1),
|
||||
TuiView::SEARCH => $this->setSelectedSearchResult($this->selectedSearchResult + 1),
|
||||
TuiView::HISTORY => $this->setSelectedHistoryItem($this->selectedHistoryItem + 1),
|
||||
TuiView::CATEGORIES => $this->navigateCategoryDown(),
|
||||
TuiView::COMMANDS => $this->navigateCommandDown(),
|
||||
TuiView::SEARCH => $this->navigateSearchResultDown(),
|
||||
TuiView::HISTORY => $this->navigateHistoryItemDown(),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate category up with wrap-around
|
||||
*/
|
||||
private function navigateCategoryUp(): void
|
||||
{
|
||||
$count = count($this->categories);
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedCategory - 1;
|
||||
if ($newIndex < 0) {
|
||||
$newIndex = $count - 1; // Wrap to last item
|
||||
}
|
||||
|
||||
$this->selectedCategory = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate category down with wrap-around
|
||||
*/
|
||||
private function navigateCategoryDown(): void
|
||||
{
|
||||
$count = count($this->categories);
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedCategory + 1;
|
||||
if ($newIndex >= $count) {
|
||||
$newIndex = 0; // Wrap to first item
|
||||
}
|
||||
|
||||
$this->selectedCategory = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate command up with wrap-around
|
||||
*/
|
||||
private function navigateCommandUp(): void
|
||||
{
|
||||
$category = $this->getCurrentCategory();
|
||||
if (! $category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = count($category['commands'] ?? []);
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedCommand - 1;
|
||||
if ($newIndex < 0) {
|
||||
$newIndex = $count - 1; // Wrap to last item
|
||||
}
|
||||
|
||||
$this->selectedCommand = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate command down with wrap-around
|
||||
*/
|
||||
private function navigateCommandDown(): void
|
||||
{
|
||||
$category = $this->getCurrentCategory();
|
||||
if (! $category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = count($category['commands'] ?? []);
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedCommand + 1;
|
||||
if ($newIndex >= $count) {
|
||||
$newIndex = 0; // Wrap to first item
|
||||
}
|
||||
|
||||
$this->selectedCommand = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate search result up with wrap-around
|
||||
*/
|
||||
private function navigateSearchResultUp(): void
|
||||
{
|
||||
$count = count($this->searchResults);
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedSearchResult - 1;
|
||||
if ($newIndex < 0) {
|
||||
$newIndex = $count - 1; // Wrap to last item
|
||||
}
|
||||
|
||||
$this->selectedSearchResult = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate search result down with wrap-around
|
||||
*/
|
||||
private function navigateSearchResultDown(): void
|
||||
{
|
||||
$count = count($this->searchResults);
|
||||
if ($count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedSearchResult + 1;
|
||||
if ($newIndex >= $count) {
|
||||
$newIndex = 0; // Wrap to first item
|
||||
}
|
||||
|
||||
$this->selectedSearchResult = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate history item up with wrap-around
|
||||
* Note: This needs the actual item count, which is not stored in state.
|
||||
* For now, we wrap at 0 but don't know the max count.
|
||||
* Use navigateHistoryItemUpWithCount() for proper wrap-around.
|
||||
*/
|
||||
private function navigateHistoryItemUp(): void
|
||||
{
|
||||
// History item count is not stored in state, so we can only wrap at 0
|
||||
$newIndex = $this->selectedHistoryItem - 1;
|
||||
if ($newIndex < 0) {
|
||||
// Can't wrap without knowing the count, so stay at 0
|
||||
$newIndex = 0;
|
||||
}
|
||||
|
||||
$this->selectedHistoryItem = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate history item down with wrap-around
|
||||
* Note: This needs the actual item count, which is not stored in state.
|
||||
* For now, we don't wrap because we don't know the max count.
|
||||
* Use navigateHistoryItemDownWithCount() for proper wrap-around.
|
||||
*/
|
||||
private function navigateHistoryItemDown(): void
|
||||
{
|
||||
// History item count is not stored in state, so we can only increment
|
||||
$this->selectedHistoryItem = $this->selectedHistoryItem + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate history item up with wrap-around, using provided item count
|
||||
*/
|
||||
public function navigateHistoryItemUpWithCount(int $itemCount): void
|
||||
{
|
||||
if ($itemCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedHistoryItem - 1;
|
||||
if ($newIndex < 0) {
|
||||
$newIndex = $itemCount - 1; // Wrap to last item
|
||||
}
|
||||
|
||||
$this->selectedHistoryItem = $newIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate history item down with wrap-around, using provided item count
|
||||
*/
|
||||
public function navigateHistoryItemDownWithCount(int $itemCount): void
|
||||
{
|
||||
if ($itemCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIndex = $this->selectedHistoryItem + 1;
|
||||
if ($newIndex >= $itemCount) {
|
||||
$newIndex = 0; // Wrap to first item
|
||||
}
|
||||
|
||||
$this->selectedHistoryItem = $newIndex;
|
||||
}
|
||||
|
||||
public function resetSearchState(): void
|
||||
{
|
||||
$this->searchQuery = '';
|
||||
@@ -344,5 +540,123 @@ final class TuiState
|
||||
public function setActiveMenu(?string $menu): void
|
||||
{
|
||||
$this->activeMenu = $menu;
|
||||
// Close dropdown if menu is closed
|
||||
if ($menu === null) {
|
||||
$this->activeDropdownMenu = null;
|
||||
$this->selectedDropdownItem = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active dropdown menu
|
||||
*/
|
||||
public function setActiveDropdownMenu(?string $menu): void
|
||||
{
|
||||
$this->activeDropdownMenu = $menu;
|
||||
if ($menu === null) {
|
||||
$this->selectedDropdownItem = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active dropdown menu
|
||||
*/
|
||||
public function getActiveDropdownMenu(): ?string
|
||||
{
|
||||
return $this->activeDropdownMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected dropdown item index
|
||||
*/
|
||||
public function setSelectedDropdownItem(int $index): void
|
||||
{
|
||||
$this->selectedDropdownItem = max(0, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected dropdown item index
|
||||
*/
|
||||
public function getSelectedDropdownItem(): int
|
||||
{
|
||||
return $this->selectedDropdownItem;
|
||||
}
|
||||
|
||||
// Hover state management
|
||||
|
||||
/**
|
||||
* Set hovered menu item
|
||||
*/
|
||||
public function setHoveredMenuItem(?string $menuItem): void
|
||||
{
|
||||
$this->hoveredMenuItem = $menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hovered menu item
|
||||
*/
|
||||
public function getHoveredMenuItem(): ?string
|
||||
{
|
||||
return $this->hoveredMenuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hovered dropdown item index
|
||||
*/
|
||||
public function setHoveredDropdownItem(?int $index): void
|
||||
{
|
||||
$this->hoveredDropdownItem = $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hovered dropdown item index
|
||||
*/
|
||||
public function getHoveredDropdownItem(): ?int
|
||||
{
|
||||
return $this->hoveredDropdownItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hovered content item
|
||||
*/
|
||||
public function setHoveredContentItem(?string $itemType, ?int $index): void
|
||||
{
|
||||
$this->hoveredContentItemType = $itemType;
|
||||
$this->hoveredContentItemIndex = $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hovered content item type
|
||||
*/
|
||||
public function getHoveredContentItemType(): ?string
|
||||
{
|
||||
return $this->hoveredContentItemType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hovered content item index
|
||||
*/
|
||||
public function getHoveredContentItemIndex(): ?int
|
||||
{
|
||||
return $this->hoveredContentItemIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a content item is hovered
|
||||
*/
|
||||
public function isContentItemHovered(string $itemType, int $index): bool
|
||||
{
|
||||
return $this->hoveredContentItemType === $itemType && $this->hoveredContentItemIndex === $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all hover states
|
||||
*/
|
||||
public function clearHoverStates(): void
|
||||
{
|
||||
$this->hoveredMenuItem = null;
|
||||
$this->hoveredDropdownItem = null;
|
||||
$this->hoveredContentItemType = null;
|
||||
$this->hoveredContentItemIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ enum MouseControlCode: string
|
||||
case DISABLE_ALL = '?1000l'; // Disable mouse tracking
|
||||
case ENABLE_SGR = '?1006h'; // Enable SGR (Sixel Graphics Raster) mouse reports
|
||||
case DISABLE_SGR = '?1006l'; // Disable SGR mouse reports
|
||||
case ENABLE_MOVE = '?1003h'; // Enable mouse move tracking (for hover effects)
|
||||
case DISABLE_MOVE = '?1003l'; // Disable mouse move tracking
|
||||
|
||||
/**
|
||||
* Format the mouse control code as ANSI sequence
|
||||
|
||||
82
src/Framework/Http/Url/Subdomain.php
Normal file
82
src/Framework/Http/Url/Subdomain.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
use Stringable;
|
||||
|
||||
final class Subdomain implements Stringable
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
){}
|
||||
|
||||
public static function fromHost(string $host, string $base): Subdomain
|
||||
{
|
||||
if(!str_ends_with($host, '.'.$base)) {
|
||||
throw new \InvalidArgumentException('Host does not end with base domain');
|
||||
}
|
||||
|
||||
$subdomain = self::str_trim_end($host, '.'.$base);
|
||||
|
||||
return new Subdomain($subdomain);
|
||||
}
|
||||
|
||||
public static function tryFromHost(string $host, string $base): ?Subdomain
|
||||
{
|
||||
try {
|
||||
return self::fromHost($host, $base);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
private function validate(string $subdomain):bool
|
||||
{
|
||||
return is_string($subdomain);
|
||||
}
|
||||
|
||||
private static function str_trim_end(string $string, string $end, bool $utf8 = false): string
|
||||
{
|
||||
if($end === '') {
|
||||
return $string;
|
||||
}
|
||||
|
||||
if ($utf8) {
|
||||
if (!self::mb_str_ends_with($string, $end)) {
|
||||
return $string;
|
||||
}
|
||||
return mb_substr($string, 0, -mb_strlen($end));
|
||||
|
||||
}
|
||||
|
||||
if(!str_ends_with($string, $end)) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
return substr($string, 0, -strlen($end));
|
||||
}
|
||||
|
||||
private static function mb_str_ends_with(string $haystack, string $needle, string $encoding = 'UTF-8'): bool
|
||||
{
|
||||
$len = mb_strlen($needle, $encoding);
|
||||
if ($len === 0) {
|
||||
return true;
|
||||
}
|
||||
return (mb_substr($haystack, -$len, null, $encoding) === $needle);
|
||||
}
|
||||
|
||||
private static function mb_str_starts_with(string $haystack, string $needle, string $encoding = 'UTF-8'): bool
|
||||
{
|
||||
$len = mb_strlen($needle, $encoding);
|
||||
if ($len === 0) {
|
||||
return true;
|
||||
}
|
||||
return (mb_substr($haystack, 0, $len, $encoding) === $needle);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ final class ChannelLoggerRegistry
|
||||
// Falls nicht möglich (custom channel), verwende den String direkt
|
||||
$logChannel = $channel instanceof LogChannel
|
||||
? $channel
|
||||
: (LogChannel::tryFrom($channelName) ?? LogChannel::APPLICATION);
|
||||
: (LogChannel::tryFrom($channelName) ?? LogChannel::APP);
|
||||
|
||||
$this->channelLoggers[$channelName] = new DefaultChannelLogger(
|
||||
$this->logger,
|
||||
|
||||
@@ -11,6 +11,9 @@ use App\Framework\Logging\LogRecord;
|
||||
|
||||
/**
|
||||
* Handler für die Ausgabe von Log-Einträgen in der Konsole.
|
||||
*
|
||||
* Bei CLI: Nutzt stderr für WARNING+ und stdout für niedrigere Levels.
|
||||
* Bei Web-Requests: Alle Logs gehen auf stderr (POSIX-konform, Docker-kompatibel).
|
||||
*/
|
||||
class ConsoleHandler implements LogHandler
|
||||
{
|
||||
@@ -52,18 +55,12 @@ class ConsoleHandler implements LogHandler
|
||||
*/
|
||||
public function isHandling(LogRecord $record): bool
|
||||
{
|
||||
// Nur im CLI-Modus aktiv - NIE bei Web-Requests!
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optional: Debug-Modus-Check nur in CLI
|
||||
// Optional: Debug-Modus-Check
|
||||
if ($this->debugOnly && ! filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return $record->getLevel()->value >= $this->minLevel->value;
|
||||
return $record->level->value >= $this->minLevel->value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,12 +81,16 @@ class ConsoleHandler implements LogHandler
|
||||
$output .= PHP_EOL;
|
||||
}
|
||||
|
||||
// Fehler und Warnungen auf stderr, alles andere auf stdout
|
||||
if ($record->getLevel()->value >= $this->stderrLevel->value) {
|
||||
// WARNING, ERROR, CRITICAL, ALERT, EMERGENCY -> stderr
|
||||
// Bei Web-Requests: Alle Logs auf stderr (kein stdout, da das in HTTP-Response gehen würde)
|
||||
// Bei CLI: stderr für WARNING+, stdout für niedrigere Levels
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
// Web-Requests: Immer stderr verwenden
|
||||
file_put_contents('php://stderr', $output);
|
||||
} elseif ($record->level->value >= $this->stderrLevel->value) {
|
||||
// CLI: WARNING, ERROR, CRITICAL, ALERT, EMERGENCY -> stderr
|
||||
file_put_contents('php://stderr', $output);
|
||||
} else {
|
||||
// DEBUG, INFO, NOTICE -> stdout
|
||||
// CLI: DEBUG, INFO, NOTICE -> stdout
|
||||
echo $output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ use App\Framework\Logging\Handlers\FileHandler;
|
||||
use App\Framework\Logging\Handlers\MultiFileHandler;
|
||||
use App\Framework\Logging\Handlers\NullHandler;
|
||||
use App\Framework\Logging\Handlers\QueuedLogHandler;
|
||||
use App\Framework\Logging\Handlers\WebHandler;
|
||||
use App\Framework\Logging\LogHandler;
|
||||
use App\Framework\Queue\Queue;
|
||||
use App\Framework\Queue\RedisQueue;
|
||||
@@ -152,13 +151,20 @@ final readonly class LoggerInitializer
|
||||
): array {
|
||||
$handlers = [];
|
||||
|
||||
// Docker/Console Logging Handler
|
||||
// Console/Docker Logging Handler - für CLI und Web-Requests
|
||||
if (PHP_SAPI === 'cli') {
|
||||
// CLI: Docker JSON oder Console Handler
|
||||
$handlers[] = $this->createCliHandler($config, $env, $minLevel);
|
||||
} else {
|
||||
// Web-Requests: Console Handler auf stderr
|
||||
$webFormatter = new LineFormatter(
|
||||
format: '[{timestamp}] [{level_name}] {request_id}{channel}{message}',
|
||||
timestampFormat: 'Y-m-d H:i:s'
|
||||
);
|
||||
$handlers[] = new ConsoleHandler($webFormatter, $minLevel, debugOnly: false);
|
||||
}
|
||||
|
||||
//$handlers[] = new QueuedLogHandler($queue);
|
||||
$handlers[] = new WebHandler();
|
||||
|
||||
// MultiFileHandler für automatisches Channel-Routing
|
||||
$multiFileFormatter = new LineFormatter();
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Framework\Logging\ValueObjects\LogContext;
|
||||
#[Singleton]
|
||||
final class PhpErrorLogInterceptor
|
||||
{
|
||||
/* @var resource|null */
|
||||
private $interceptStream = null;
|
||||
|
||||
private ?string $originalErrorLog = null;
|
||||
@@ -20,8 +21,9 @@ final class PhpErrorLogInterceptor
|
||||
private bool $isInstalled = false;
|
||||
|
||||
public function __construct(
|
||||
private Logger $logger
|
||||
private readonly Logger $logger
|
||||
) {
|
||||
$this->logger->debug('PhpErrorLogInterceptor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging;
|
||||
|
||||
use App\Framework\Attributes\Initializer;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
|
||||
/**
|
||||
* Initializer für PHP Error Log Interceptor
|
||||
|
||||
@@ -5,24 +5,39 @@ declare(strict_types=1);
|
||||
namespace App\Framework\Router;
|
||||
|
||||
use App\Framework\Attributes\Singleton;
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\Config\EnvKey;
|
||||
use App\Framework\Core\DynamicRoute;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\Url\Subdomain;
|
||||
|
||||
#[Singleton]
|
||||
final readonly class HttpRouter implements Router
|
||||
{
|
||||
public function __construct(
|
||||
public CompiledRoutes $optimizedRoutes,
|
||||
) {
|
||||
}
|
||||
private Environment $environment,
|
||||
) {}
|
||||
|
||||
public function match(Request $request): RouteContext
|
||||
{
|
||||
$method = $request->method;
|
||||
$path = $request->path;
|
||||
$host = $request->server->getHttpHost();
|
||||
$subdomain = $this->extractSubdomain($host);
|
||||
|
||||
$appUrl = $this->environment->getString(EnvKey::APP_URL);
|
||||
|
||||
|
||||
$pos = stripos($appUrl, '://');
|
||||
if($pos !== false) {
|
||||
$appUrl = substr($appUrl,$pos + 3);
|
||||
}
|
||||
|
||||
$subdomain = Subdomain::tryfromHost($host, $appUrl) ?? '';
|
||||
$subdomain = (string)$subdomain;
|
||||
|
||||
#$subdomain = $this->extractSubdomain($host);
|
||||
|
||||
error_log("🔍 ROUTER DEBUG: Host={$host}, Subdomain=" . ($subdomain ?: 'NONE') . ", Path={$path}, Method={$method->value}");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Framework\Router;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\Context\ContextType;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\Core\RouteCache;
|
||||
@@ -77,7 +78,7 @@ final readonly class RouterSetup
|
||||
$optimizedRoutes = $this->routeCompiler->compileOptimized();
|
||||
}
|
||||
|
||||
$router = new HttpRouter($optimizedRoutes);
|
||||
$router = new HttpRouter($optimizedRoutes, $this->container->get(Environment::class));
|
||||
|
||||
$this->container->bind(CompiledRoutes::class, $optimizedRoutes);
|
||||
$this->container->bind(HttpRouter::class, $router);
|
||||
|
||||
Reference in New Issue
Block a user