284 lines
8.0 KiB
PHP
284 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Console\Components;
|
|
|
|
/**
|
|
* Parses ANSI escape sequences for mouse and keyboard events
|
|
*/
|
|
final class InputParser
|
|
{
|
|
/**
|
|
* Read and parse input from STDIN using stream_select()
|
|
*
|
|
* @return MouseEvent|KeyEvent|null Returns parsed event or null if no input available
|
|
*/
|
|
public function readEvent(): MouseEvent|KeyEvent|null
|
|
{
|
|
// Use stream_select for non-blocking I/O
|
|
$read = [STDIN];
|
|
$write = null;
|
|
$except = null;
|
|
|
|
// Wait up to 0.01 seconds (10ms) for input
|
|
$result = stream_select($read, $write, $except, 0, 10000);
|
|
|
|
if ($result === false || $result === 0 || !in_array(STDIN, $read, true)) {
|
|
return null;
|
|
}
|
|
|
|
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
|
|
stream_set_blocking(STDIN, false);
|
|
|
|
try {
|
|
$firstChar = fgetc(STDIN);
|
|
if ($firstChar === false) {
|
|
return null;
|
|
}
|
|
|
|
// Check for escape sequence
|
|
if ($firstChar === "\033") {
|
|
return $this->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[<b;x;yM (press) or \e[<b;x;ym (release)
|
|
*/
|
|
private function parseMouseEvent(string $prefix): MouseEvent|null
|
|
{
|
|
$buffer = '';
|
|
$timeout = 10000; // 10ms
|
|
$startTime = microtime(true) * 1000;
|
|
|
|
// Read until we get 'M' or 'm'
|
|
while (true) {
|
|
$char = fgetc(STDIN);
|
|
if ($char === false) {
|
|
// Check timeout
|
|
$elapsed = (microtime(true) * 1000) - $startTime;
|
|
if ($elapsed > $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++;
|
|
}
|
|
}
|
|
}
|
|
|