diff --git a/README.md b/README.md index 2202a94..f632f25 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ any extensions or special installation. * [Cursor](#cursor) * [History](#history) * [Autocomplete](#autocomplete) + * [Keys](#keys) * [Pitfalls](#pitfalls) * [Install](#install) * [Tests](#tests) @@ -503,6 +504,57 @@ disable the autocomplete function: $readline->setAutocomplete(null); ``` +#### Keys + +The `Readline` class is responsible for reading user input from `STDIN` and +registering appropriate key events. +By default, `Readline` uses a hard-coded key mapping that resembles the one +usually found in common terminals. +This means that normal Unicode character keys ("a" and "b", but also "?", "ä", +"µ" etc.) will be processed as user input, while special control keys can be +used for [cursor movement](#cursor), [history](#history) and +[autocomplete](#autocomplete) functions. +Unknown special keys will be ignored and will not processed as part of the user +input by default. + +Additionally, you can bind custom functions to any key code you want. +If a custom function is bound to a certain key code, the default behavior will +no longer trigger. +This allows you to register entirely new functions to keys or to overwrite any +of the existing behavior. + +For example, you can use the following code to print some help text when the +user hits a certain key: + +```php +$readline->on('?', function () use ($stdio) { + $stdio->write('Here\'s some help: …' . PHP_EOL); +}); +``` + +Similarly, this can be used to manipulate the user input and replace some of the +input when the user hits a certain key: + +```php +$readline->on('ä', function () use ($readline) { + $readline->addInput('a'); +}); +``` + +The `Readline` uses raw binary key codes as emitted by the terminal. +This means that you can use the normal UTF-8 character representation for normal +Unicode characters. +Special keys use binary control code sequences (refer to ANSI / VT100 control +codes for more details). +For example, the following code can be used to register a custom function to the +UP arrow cursor key: + +```php +$readline->on("\033[A", function () use ($readline) { + $readline->setInput(strtoupper($readline->getInput())); +}); +``` + ## Pitfalls The [`Readline`](#readline) has to redraw the current user diff --git a/examples/04-bindings.php b/examples/04-bindings.php new file mode 100644 index 0000000..9262546 --- /dev/null +++ b/examples/04-bindings.php @@ -0,0 +1,46 @@ +getReadline(); + +$readline->setPrompt('> '); + +// add some special key bindings +$readline->on('a', function () use ($readline) { + $readline->addInput('ä'); +}); +$readline->on('o', function () use ($readline) { + $readline->addInput('ö'); +}); +$readline->on('u', function () use ($readline) { + $readline->addInput('ü'); +}); + +$readline->on('?', function () use ($stdio) { + $stdio->write('Do you need help?'); +}); + +// bind CTRL+E +$readline->on("\x05", function () use ($stdio) { + $stdio->write("ignore CTRL+E" . PHP_EOL); +}); +// bind CTRL+H +$readline->on("\x08", function () use ($stdio) { + $stdio->write('Use "?" if you need help.' . PHP_EOL); +}); + +$stdio->write('Welcome to this interactive demo' . PHP_EOL); + +// end once the user enters a command +$stdio->on('data', function ($line) use ($stdio, $readline) { + $line = rtrim($line, "\r\n"); + $stdio->end('you just said: ' . $line . ' (' . strlen($line) . ')' . PHP_EOL); +}); + +$loop->run(); diff --git a/examples/05-cursor.php b/examples/05-cursor.php new file mode 100644 index 0000000..08da168 --- /dev/null +++ b/examples/05-cursor.php @@ -0,0 +1,45 @@ +getReadline(); + +$value = 10; +$readline->on("\033[A", function () use (&$value, $readline) { + $value++; + $readline->setPrompt('Value: ' . $value); +}); +$readline->on("\033[B", function () use (&$value, $readline) { + --$value; + $readline->setPrompt('Value: ' . $value); +}); + +// hijack enter to just print our current value +$readline->on("\n", function () use ($readline, $stdio, &$value) { + $stdio->write("Your choice was $value\n"); +}); + +// quit on "q" +$readline->on('q', function () use ($stdio) { + $stdio->end(); +}); + +// user can still type all keys, but we simply hide user input +$readline->setEcho(false); + +// instead of showing user input, we just show a custom prompt +$readline->setPrompt('Value: ' . $value); + +$stdio->write('Welcome to this cursor demo + +Use cursor UP/DOWN to change value. + +Use "q" to quit +'); + +$loop->run(); diff --git a/src/Readline.php b/src/Readline.php index ac0e5df..56327a2 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -63,6 +63,11 @@ public function __construct(ReadableStreamInterface $input, WritableStreamInterf // "\033[20~" => 'onKeyF10', ); $decode = function ($code) use ($codes, $that) { + if ($that->listeners($code)) { + $that->emit($code, array($code)); + return; + } + if (isset($codes[$code])) { $method = $codes[$code]; $that->$method($code); @@ -724,7 +729,26 @@ public function onKeyDown() */ public function onFallback($chars) { - $this->addInput($chars); + // check if there's any special key binding for any of the chars + $buffer = ''; + foreach ($this->strsplit($chars) as $char) { + if ($this->listeners($char)) { + // special key binding for this character found + // process all characters before this one before invoking function + if ($buffer !== '') { + $this->addInput($buffer); + $buffer = ''; + } + $this->emit($char, array($char)); + } else { + $buffer .= $char; + } + } + + // process remaining input characters after last special key binding + if ($buffer !== '') { + $this->addInput($buffer); + } } /** @@ -837,6 +861,11 @@ public function strwidth($str) )); } + private function strsplit($str) + { + return preg_split('//u', $str, null, PREG_SPLIT_NO_EMPTY); + } + /** @internal */ public function handleEnd() { diff --git a/tests/FunctionalExampleTest.php b/tests/FunctionalExampleTest.php index 8865aa7..e25588b 100644 --- a/tests/FunctionalExampleTest.php +++ b/tests/FunctionalExampleTest.php @@ -52,6 +52,20 @@ public function testPeriodicExampleWithClosedInputAndOutputQuitsImmediatelyWitho $this->assertEquals('', $output); } + public function testBindingsExampleWithPipedInputEndsBecauseInputEnds() + { + $output = $this->execExample('echo test | php 04-bindings.php'); + + $this->assertContains('you just said: test (4)' . PHP_EOL, $output); + } + + public function testBindingsExampleWithPipedInputEndsWithSpecialBindingsReplacedBecauseInputEnds() + { + $output = $this->execExample('echo hello | php 04-bindings.php'); + + $this->assertContains('you just said: hellö (6)' . PHP_EOL, $output); + } + public function testStubShowStdinIsReadableByDefault() { $output = $this->execExample('php ../tests/stub/01-check-stdin.php'); diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index ad7760a..2fe9eb0 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -943,6 +943,44 @@ public function testAutocompleteShowsLimitedNumberOfAvailableOptionsWhenMultiple $this->assertContains("\na b c d e f g (+19 others)\n", $buffer); } + public function testBindCustomFunctionOverwritesInput() + { + $this->readline->on('a', $this->expectCallableOnceWith('a')); + + $this->input->emit('data', array("a")); + + $this->assertEquals('', $this->readline->getInput()); + } + + public function testBindCustomFunctionOverwritesInputButKeepsRest() + { + $this->readline->on('e', $this->expectCallableOnceWith('e')); + + $this->input->emit('data', array("test")); + + $this->assertEquals('tst', $this->readline->getInput()); + } + + public function testBindCustomFunctionCanOverwriteInput() + { + $readline = $this->readline; + $readline->on('a', function () use ($readline) { + $readline->addInput('ä'); + }); + + $this->input->emit('data', array("hallo")); + + $this->assertEquals('hällo', $this->readline->getInput()); + } + + public function testBindCustomFunctionCanOverwriteAutocompleteBehavior() + { + $this->readline->on("\t", $this->expectCallableOnceWith("\t")); + $this->readline->setAutocomplete($this->expectCallableNever()); + + $this->input->emit('data', array("\t")); + } + public function testEmitEmptyInputOnEnter() { $this->readline->on('data', $this->expectCallableOnceWith("\n"));