parseEscapeSequence($firstChar); } // Check for Ctrl+C (ASCII 3) if ($firstChar === "\003") { stream_set_blocking(STDIN, $originalBlocking); return new KeyEvent(key: 'C', ctrl: true); } // Regular character stream_set_blocking(STDIN, $originalBlocking); return new KeyEvent(key: $firstChar); } finally { stream_set_blocking(STDIN, $originalBlocking); } } /** * Parse escape sequence (mouse or keyboard) */ private function parseEscapeSequence(string $firstChar): MouseEvent|KeyEvent|null { $sequence = $firstChar; // Read next character with timeout $next = $this->readCharWithTimeout(); if ($next === null) { return new KeyEvent(key: "\033"); } $sequence .= $next; // Mouse events start with \e[< if ($next === '[') { $third = $this->readCharWithTimeout(); if ($third === '<') { return $this->parseMouseEvent($sequence . $third); } // Keyboard escape sequence like \e[A (arrow up) if ($third !== null) { $sequence .= $third; return $this->parseKeyboardSequence($sequence); } } // Just escape key return new KeyEvent(key: "\033"); } /** * Parse SGR mouse event: \e[ $timeout) { return null; } usleep(1000); continue; } $buffer .= $char; // Mouse event ends with 'M' (press) or 'm' (release) if ($char === 'M' || $char === 'm') { break; } // Safety: limit buffer size if (strlen($buffer) > 20) { return null; } } // Parse format: b;x;y where b is button code, x and y are coordinates // Remove the final M/m $data = substr($buffer, 0, -1); $parts = explode(';', $data); if (count($parts) < 3) { // Invalid mouse event format, discard return null; } $buttonCode = (int) $parts[0]; $x = (int) $parts[1]; $y = (int) $parts[2]; // Validate coordinates (basic sanity check to catch corrupted events) if ($x < 1 || $y < 1 || $x > 1000 || $y > 1000) { // Invalid coordinates, likely corrupted event - discard silently return null; } // Decode button and modifiers // Bit flags in button code: // Bit 0-1: Button (0=left, 1=middle, 2=right, 3=release) // Bit 2: Shift // Bit 3: Meta/Alt // Bit 4: Ctrl // Bit 5: Mouse move (32 = 0x20) // Bit 6-7: Scroll (64=scroll up, 65=scroll down) $button = $buttonCode & 0x03; $shift = ($buttonCode & 0x04) !== 0; $alt = ($buttonCode & 0x08) !== 0; $ctrl = ($buttonCode & 0x10) !== 0; // Handle scroll events (button codes 64 and 65) if ($buttonCode >= 64 && $buttonCode <= 65) { $button = $buttonCode; } elseif (($buttonCode & 0x20) !== 0) { // Mouse move (button code 32 or bit 5 set) // Store full button code to detect mouse move $button = $buttonCode; } $pressed = $buffer[-1] === 'M'; return new MouseEvent( x: $x, y: $y, button: $button, pressed: $pressed, shift: $shift, ctrl: $ctrl, alt: $alt ); } /** * Parse keyboard escape sequence (arrow keys, function keys, etc.) */ private function parseKeyboardSequence(string $sequence): KeyEvent { // Map common escape sequences $keyMap = [ "\033[A" => 'ArrowUp', "\033[B" => 'ArrowDown', "\033[C" => 'ArrowRight', "\033[D" => 'ArrowLeft', "\033[H" => 'Home', "\033[F" => 'End', "\033[5~" => 'PageUp', "\033[6~" => 'PageDown', "\033[3~" => 'Delete', "\n" => 'Enter', "\r" => 'Enter', "\033" => 'Escape', "\t" => 'Tab', ]; // Check if we need to read more characters if (strlen($sequence) >= 3 && in_array($sequence[2], ['5', '6', '3'], true)) { $fourth = $this->readCharWithTimeout(); if ($fourth !== null) { $sequence .= $fourth; } } // Check for Enter key if ($sequence === "\n" || $sequence === "\r") { return new KeyEvent(key: 'Enter', code: "\n"); } // Check for Escape if ($sequence === "\033") { return new KeyEvent(key: 'Escape', code: "\033"); } // Map to known key if (isset($keyMap[$sequence])) { return new KeyEvent(key: $keyMap[$sequence], code: $sequence); } // Unknown sequence, return as-is return new KeyEvent(key: $sequence, code: $sequence); } /** * Read a single character with small timeout */ private function readCharWithTimeout(int $timeoutMs = 10): ?string { $startTime = microtime(true) * 1000; while (true) { $char = fgetc(STDIN); if ($char !== false) { return $char; } $elapsed = (microtime(true) * 1000) - $startTime; if ($elapsed > $timeoutMs) { return null; } 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++; } } }