Skip to content

Commit 37785c3

Browse files
authored
Merge pull request #35 from clue-labs/stdio
The Stdio is now a well-behaving duplex stream
2 parents 3662847 + ee131bf commit 37785c3

File tree

5 files changed

+696
-45
lines changed

5 files changed

+696
-45
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,17 @@ $loop = React\EventLoop\Factory::create();
4141
$stdio = new Stdio($loop);
4242
```
4343

44+
See below for waiting for user input and writing output.
45+
Alternatively, the `Stdio` is also a well-behaving duplex stream
46+
(implementing React's `DuplexStreamInterface`) that emits each complete
47+
line as a `data` event (including the trailing newline). This is considered
48+
advanced usage.
49+
4450
#### Output
4551

52+
The `Stdio` is a well-behaving writable stream
53+
implementing React's `WritableStreamInterface`.
54+
4655
The `writeln($line)` method can be used to print a line to console output.
4756
A trailing newline will be added automatically.
4857

@@ -58,9 +67,23 @@ $stdio->write('hello');
5867
$stdio->write(" world\n");
5968
```
6069

70+
The `overwrite($text)` method can be used to overwrite/replace the last
71+
incomplete line with the given text:
72+
73+
```php
74+
$stdio->write('Loading…');
75+
$stdio->overwrite('Done!');
76+
```
77+
78+
Alternatively, you can also use the `Stdio` as a writable stream.
79+
You can `pipe()` any readable stream into this stream.
80+
6181
#### Input
6282

63-
The `Stdio` will emit a `line` event for every line read from console input.
83+
The `Stdio` is a well-behaving readable stream
84+
implementing React's `ReadableStreamInterface`.
85+
86+
It will emit a `line` event for every line read from console input.
6487
The event will contain the input buffer as-is, without the trailing newline.
6588
You can register any number of event handlers like this:
6689

@@ -79,6 +102,10 @@ Using the `line` event is the recommended way to wait for user input.
79102
Alternatively, using the `Readline` as a readable stream is considered advanced
80103
usage.
81104

105+
Alternatively, you can also use the `Stdio` as a readable stream, which emits
106+
each complete line as a `data` event (including the trailing newline).
107+
This can be used to `pipe()` this stream into other writable streams.
108+
82109
### Readline
83110

84111
The [`Readline`](#readline) class is responsible for reacting to user input and presenting a prompt to the user.

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"require": {
1414
"php": ">=5.3",
1515
"react/event-loop": "0.3.*|0.4.*",
16-
"react/stream": "0.3.*|0.4.*"
16+
"react/stream": "^0.4.2"
1717
},
1818
"autoload": {
1919
"psr-4": { "Clue\\React\\Stdio\\": "src/" }

examples/progress.php

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,16 @@
88
$loop = React\EventLoop\Factory::create();
99

1010
$stdio = new Stdio($loop);
11-
$stdio->getInput()->close();
12-
1311
$stdio->writeln('Will print (fake) progress and then exit');
1412

1513
$progress = new ProgressBar($stdio);
1614
$progress->setMaximum(mt_rand(20, 200));
1715

18-
$loop->addPeriodicTimer(0.2, function ($timer) use ($stdio, $progress) {
16+
$loop->addPeriodicTimer(0.1, function ($timer) use ($stdio, $progress) {
1917
$progress->advance();
2018

2119
if ($progress->isComplete()) {
22-
$stdio->overwrite();
23-
$stdio->writeln("Finished processing nothing!");
20+
$stdio->overwrite("Finished processing nothing!" . PHP_EOL);
2421

2522
$stdio->end();
2623
$timer->cancel();

src/Stdio.php

Lines changed: 177 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,64 @@
22

33
namespace Clue\React\Stdio;
44

5-
use React\Stream\StreamInterface;
6-
use React\Stream\CompositeStream;
5+
use Evenement\EventEmitter;
6+
use React\Stream\DuplexStreamInterface;
77
use React\EventLoop\LoopInterface;
8-
use React\Stream\ReadableStream;
9-
use React\Stream\Stream;
8+
use React\Stream\ReadableStreamInterface;
9+
use React\Stream\WritableStreamInterface;
10+
use React\Stream\Util;
1011

11-
class Stdio extends CompositeStream
12+
class Stdio extends EventEmitter implements DuplexStreamInterface
1213
{
1314
private $input;
1415
private $output;
15-
1616
private $readline;
17-
private $needsNewline = false;
1817

19-
public function __construct(LoopInterface $loop, $input = true)
18+
private $ending = false;
19+
private $closed = false;
20+
private $incompleteLine = '';
21+
22+
public function __construct(LoopInterface $loop, ReadableStreamInterface $input = null, WritableStreamInterface $output = null, Readline $readline = null)
2023
{
21-
$this->input = new Stdin($loop);
24+
if ($input === null) {
25+
$input = new Stdin($loop);
26+
}
27+
28+
if ($output === null) {
29+
$output = new Stdout(STDOUT);
30+
}
2231

23-
$this->output = new Stdout(STDOUT);
32+
if ($readline === null) {
33+
$readline = new Readline($input, $output);
34+
}
2435

25-
$this->readline = new Readline($this->input, $this->output);
36+
$this->input = $input;
37+
$this->output = $output;
38+
$this->readline = $readline;
2639

2740
$that = $this;
2841

29-
// stdin emits single chars
30-
$this->input->on('data', function ($data) use ($that) {
31-
$that->emit('char', array($data, $that));
32-
});
33-
3442
// readline data emits a new line
35-
$this->readline->on('data', function($line) use ($that) {
43+
$incomplete =& $this->incompleteLine;
44+
$this->readline->on('data', function($line) use ($that, &$incomplete) {
45+
// readline emits a new line on enter, so start with a blank line
46+
$incomplete = '';
47+
48+
// emit data with trailing newline in order to preserve readable API
49+
$that->emit('data', array($line . PHP_EOL));
50+
51+
// emit custom line event for ease of use
3652
$that->emit('line', array($line, $that));
3753
});
3854

39-
if (!$input) {
40-
$this->pause();
41-
}
55+
// handle all input events (readline forwards all input events)
56+
$this->readline->on('error', array($this, 'handleError'));
57+
$this->readline->on('end', array($this, 'handleEnd'));
58+
$this->readline->on('close', array($this, 'handleCloseInput'));
59+
60+
// handle all output events
61+
$this->output->on('error', array($this, 'handleError'));
62+
$this->output->on('close', array($this, 'handleCloseOutput'));
4263
}
4364

4465
public function pause()
@@ -51,6 +72,23 @@ public function resume()
5172
$this->input->resume();
5273
}
5374

75+
public function isReadable()
76+
{
77+
return $this->input->isReadable();
78+
}
79+
80+
public function isWritable()
81+
{
82+
return $this->output->isWritable();
83+
}
84+
85+
public function pipe(WritableStreamInterface $dest, array $options = array())
86+
{
87+
Util::pipe($this, $dest, $options);
88+
89+
return $dest;
90+
}
91+
5492
public function handleBuffer()
5593
{
5694
$that = $this;
@@ -61,26 +99,75 @@ public function handleBuffer()
6199

62100
public function write($data)
63101
{
64-
// switch back to last output position
65-
$this->readline->clear();
102+
if ($this->ending || (string)$data === '') {
103+
return;
104+
}
105+
106+
$out = $data;
107+
108+
$lastNewline = strrpos($data, "\n");
109+
110+
$restoreReadline = false;
111+
112+
if ($this->incompleteLine !== '') {
113+
// the last write did not end with a newline => append to existing row
114+
115+
// move one line up and move cursor to last position before writing data
116+
$out = "\033[A" . "\r\033[" . $this->width($this->incompleteLine) . "C" . $out;
117+
118+
// data contains a newline, so this will overwrite the readline prompt
119+
if ($lastNewline !== false) {
120+
// move cursor to beginning of readline prompt and clear line
121+
// clearing is important because $data may not overwrite the whole line
122+
$out = "\r\033[K" . $out;
123+
124+
// make sure to restore readline after this output
125+
$restoreReadline = true;
126+
}
127+
} else {
128+
// here, we're writing to a new line => overwrite readline prompt
66129

67-
// Erase characters from cursor to end of line
68-
$this->output->write("\r\033[K");
130+
// move cursor to beginning of readline prompt and clear line
131+
$out = "\r\033[K" . $out;
69132

70-
// move one line up?
71-
if ($this->needsNewline) {
72-
$this->output->write("\033[A");
133+
// we always overwrite the readline prompt, so restore it on next line
134+
$restoreReadline = true;
73135
}
74136

75-
$this->output->write($data);
137+
// following write will have have to append to this line if it does not end with a newline
138+
$endsWithNewline = substr($data, -1) === "\n";
76139

77-
$this->needsNewline = substr($data, -1) !== "\n";
140+
if ($endsWithNewline) {
141+
// line ends with newline, so this is line is considered complete
142+
$this->incompleteLine = '';
143+
} else {
144+
// always end data with newline in order to append readline on next line
145+
$out .= "\n";
78146

79-
// repeat current prompt + linebuffer
80-
if ($this->needsNewline) {
81-
$this->output->write("\n");
147+
if ($lastNewline === false) {
148+
// contains no newline at all, everything is incomplete
149+
$this->incompleteLine .= $data;
150+
} else {
151+
// contains a newline, everything behind it is incomplete
152+
$this->incompleteLine = (string)substr($data, $lastNewline + 1);
153+
}
154+
}
155+
156+
if ($restoreReadline) {
157+
// write output and restore original readline prompt and line buffer
158+
$this->output->write($out);
159+
$this->readline->redraw();
160+
} else {
161+
// restore original cursor position in readline prompt
162+
$pos = $this->width($this->readline->getPrompt()) + $this->readline->getCursorCell();
163+
if ($pos !== 0) {
164+
// we always start at beginning of line, move right by X
165+
$out .= "\033[" . $pos . "C";
166+
}
167+
168+
// write to actual output stream
169+
$this->output->write($out);
82170
}
83-
$this->readline->redraw();
84171
}
85172

86173
public function writeln($line)
@@ -90,25 +177,44 @@ public function writeln($line)
90177

91178
public function overwrite($data = '')
92179
{
93-
// TODO: remove existing characters
180+
if ($this->incompleteLine !== '') {
181+
// move one line up, move to start of line and clear everything
182+
$data = "\033[A\r\033[K" . $data;
183+
$this->incompleteLine = '';
184+
}
94185

95-
$this->write("\r" . $data);
186+
$this->write($data);
96187
}
97188

98189
public function end($data = null)
99190
{
191+
if ($this->ending) {
192+
return;
193+
}
194+
100195
if ($data !== null) {
101196
$this->write($data);
102197
}
103198

199+
$this->ending = true;
200+
201+
// clear readline output, close input and end output
104202
$this->readline->setInput('')->setPrompt('')->clear();
105-
$this->input->pause();
203+
$this->input->close();
106204
$this->output->end();
107205
}
108206

109207
public function close()
110208
{
111-
$this->readline->setInput('')->setPrompt('')->clear();
209+
if ($this->closed) {
210+
return;
211+
}
212+
213+
$this->ending = true;
214+
$this->closed = true;
215+
216+
// clear readline output and then close
217+
$this->readline->setInput('')->setPrompt('')->clear()->close();
112218
$this->input->close();
113219
$this->output->close();
114220
}
@@ -127,4 +233,38 @@ public function getReadline()
127233
{
128234
return $this->readline;
129235
}
236+
237+
private function width($str)
238+
{
239+
return mb_strwidth($str, 'utf-8') - 2 * substr_count($str, "\x08");
240+
}
241+
242+
/** @internal */
243+
public function handleError(\Exception $e)
244+
{
245+
$this->emit('error', array($e));
246+
$this->close();
247+
}
248+
249+
/** @internal */
250+
public function handleEnd()
251+
{
252+
$this->emit('end', array());
253+
}
254+
255+
/** @internal */
256+
public function handleCloseInput()
257+
{
258+
if (!$this->output->isWritable()) {
259+
$this->close();
260+
}
261+
}
262+
263+
/** @internal */
264+
public function handleCloseOutput()
265+
{
266+
if (!$this->input->isReadable()) {
267+
$this->close();
268+
}
269+
}
130270
}

0 commit comments

Comments
 (0)