Skip to content

Commit aa483da

Browse files
committed
Support parsing APC, DPS, PM and other generic C1 sequences
1 parent e1f4e22 commit aa483da

File tree

4 files changed

+103
-46
lines changed

4 files changed

+103
-46
lines changed

README.md

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,28 @@ These chunks do not necessarily represent complete control code byte sequences,
3333
as a sequence may be broken up into multiple chunks.
3434
This class reassembles these sequences by buffering incomplete ones.
3535

36-
One of the most common forms of control code sequences is
37-
[CSI (Control Sequence Introducer)](https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_codes).
38-
For example, CSI is used to print colored console output, also known as
39-
"ANSI color codes" or the more technical term
40-
[SGR (Select Graphic Rendition)](https://en.wikipedia.org/wiki/ANSI_escape_code#graphics).
41-
CSI codes also appear on `STDIN`, for example when the user hits special keys,
42-
such as the cursor, `HOME`, `END` etc. keys.
43-
Each CSI code gets emitted as a `csi` event with its raw byte sequence:
36+
The following [C1 control codes](https://en.wikipedia.org/wiki/C0_and_C1_control_codes#C1_set)
37+
are supported as defined in [ISO/IEC 2022](https://en.wikipedia.org/wiki/ISO/IEC_2022):
38+
39+
* [CSI (Control Sequence Introducer)](https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_codes)
40+
is one of the most common forms of control code sequences.
41+
For example, CSI is used to print colored console output, also known as
42+
"ANSI color codes" or the more technical term
43+
[SGR (Select Graphic Rendition)](https://en.wikipedia.org/wiki/ANSI_escape_code#graphics).
44+
CSI codes also appear on `STDIN`, for example when the user hits special keys,
45+
such as the cursor, `HOME`, `END` etc. keys.
46+
47+
* OSC (Operating System Command)
48+
is another common form of control code sequences.
49+
For example, OSC is used to change the window title or window icon.
50+
51+
* APC (Application Program-Control)
52+
53+
* DPS (Device-Control string)
54+
55+
* PM (Privacy Message)
56+
57+
Each code sequence gets emitted with a dedicated event with its raw byte sequence:
4458

4559
```php
4660
$stream->on('csi', function ($sequence) {
@@ -50,16 +64,19 @@ $stream->on('csi', function ($sequence) {
5064
echo 'cursor DOWN pressed';
5165
}
5266
});
67+
68+
$stream->on('osc', function ($sequence) { … });
69+
$stream->on('apc', function ($sequence) { … });
70+
$stream->on('dps', function ($sequence) { … });
71+
$stream->on('pm', function ($sequence) { … });
5372
```
5473

55-
Another common form of control code sequences is OSC (Operating System Command).
56-
For example, OSC is used to change the window title or window icon.
57-
Each OSC code gets emitted as an `osc` event with its raw byte sequence:
74+
Other lesser known [C1 control codes](https://en.wikipedia.org/wiki/C0_and_C1_control_codes#C1_set)
75+
not listed above are supported by just emitting their 2-byte sequence.
76+
Each generic C1 code gets emitted as an `c1` event with its raw 2-byte sequence:
5877

5978
```php
60-
$stream->on('osc', function ($sequence) {
61-
// handle byte sequence
62-
});
79+
$stream->on('c1', function ($sequence) { … });
6380
```
6481

6582
## Install

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "clue/term-react",
33
"description": "Streaming terminal emulator, built on top of React PHP",
4-
"keywords": ["terminal", "control codes", "xterm", "csi", "osc", "streaming", "ReactPHP"],
4+
"keywords": ["terminal", "control codes", "xterm", "csi", "osc", "apc", "dps", "pm", "c1", "streaming", "ReactPHP"],
55
"homepage": "https://github.com/clue/php-term-react",
66
"license": "MIT",
77
"authors": [

src/ControlCodeParser.php

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,34 @@ class ControlCodeParser extends EventEmitter implements ReadableStreamInterface
1313
private $closed = false;
1414
private $buffer = '';
1515

16+
/**
17+
* we know about the following C1 types (7 bit only)
18+
*
19+
* followed by "[" means it's CSI (Control Sequence Introducer)
20+
* followed by "]" means it's OSC (Operating System Controls)
21+
* followed by "_" means it's APC (Application Program-Control)
22+
* followed by "P" means it's DPS (Device-Control string)
23+
* followed by "^" means it's PM (Privacy Message)
24+
*
25+
* Each of these will be parsed until the sequence ends and then emitted
26+
* under their respective name.
27+
*
28+
* All other C1 types will be emitted under the "c1" name without any
29+
* further processing.
30+
*
31+
* C1 types in 8 bit are currently not supported, as they require special
32+
* care with regards to whether UTF-8 mode is enabled. So far this has
33+
* turned out to be a non-issue because most terminal emulators *accept*
34+
* boths formats, but usually *send* in 7 bit mode exclusively.
35+
*/
36+
private $types = array(
37+
'[' => 'csi',
38+
']' => 'osc',
39+
'_' => 'apc',
40+
'P' => 'dps',
41+
'^' => 'pm',
42+
);
43+
1644
public function __construct(ReadableStreamInterface $input)
1745
{
1846
$this->input = $input;
@@ -95,11 +123,20 @@ public function handleData($data)
95123
break;
96124
}
97125

98-
$found = false;
126+
// if this is an unknown type, just emit as "c1" without further parsing
127+
if (!isset($this->types[$this->buffer[1]])) {
128+
$data = substr($this->buffer, 0, 2);
129+
$this->buffer = (string)substr($this->buffer, 2);
99130

100-
if ($this->buffer[1] === '[') {
101-
// followed by "[" means it's CSI
131+
$this->emit('c1', array($data));
132+
continue;
133+
}
134+
135+
// this is known type, check for the sequence end
136+
$type = $this->types[$this->buffer[1]];
137+
$found = false;
102138

139+
if ($type === 'csi') {
103140
// CSI is now at the start of the buffer, search final character
104141
for ($i = 2; isset($this->buffer[$i]); ++$i) {
105142
$code = ord($this->buffer[$i]);
@@ -109,39 +146,32 @@ public function handleData($data)
109146
$data = substr($this->buffer, 0, $i + 1);
110147
$this->buffer = (string)substr($this->buffer, $i + 1);
111148

112-
$this->emit('csi', array($data));
149+
$this->emit($type, array($data));
113150
$found = true;
114151
break;
115152
}
116153
}
117-
} elseif ($this->buffer[1] === ']') {
118-
// followed by "]" means it's OSC (Operating System Controls)
119-
120-
// terminated by ST or BEL (whichever comes first)
154+
} else {
155+
// all other types are terminated by ST
156+
// only OSC can also be terminted by BEL (whichever comes first)
121157
$st = strpos($this->buffer, "\x1B\\");
122-
$bel = strpos($this->buffer, "\x07");
158+
$bel = ($type === 'osc') ? strpos($this->buffer, "\x07") : false;
123159

124160
if ($st !== false && ($bel === false || $bel > $st)) {
125161
// ST comes before BEL or no BEL found
126162
$data = substr($this->buffer, 0, $st + 2);
127163
$this->buffer = (string)substr($this->buffer, $st + 2);
128164

129-
$this->emit('osc', array($data));
165+
$this->emit($type, array($data));
130166
$found = true;
131167
} elseif ($bel !== false) {
132168
// BEL comes before ST or no ST found
133169
$data = substr($this->buffer, 0, $bel + 1);
134170
$this->buffer = (string)substr($this->buffer, $bel + 1);
135171

136-
$this->emit('osc', array($data));
172+
$this->emit($type, array($data));
137173
$found = true;
138174
}
139-
} else {
140-
$data = substr($this->buffer, 0, 2);
141-
$this->buffer = (string)substr($this->buffer, 2);
142-
143-
$this->emit('data', array($data));
144-
continue;
145175
}
146176

147177
// no final character found => wait for next data chunk

tests/ControlCodeParserTest.php

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,20 +100,6 @@ public function testEmitsDataAndCsiAndData()
100100
$this->assertEquals('helloworld', $buffer);
101101
}
102102

103-
public function testEmitsNonCsiAsData()
104-
{
105-
$buffer = '';
106-
$this->parser->on('data', function ($chunk) use (&$buffer) {
107-
$buffer .= $chunk;
108-
});
109-
$this->parser->on('csi', $this->expectCallableNever());
110-
111-
$this->input->emit('data', array("hello\x1B?world"));
112-
113-
$this->assertEquals("hello\x1B?world", $buffer);
114-
}
115-
116-
117103
public function testEmitsOscBelAsOneChunk()
118104
{
119105
$this->parser->on('data', $this->expectCallableNever());
@@ -148,6 +134,30 @@ public function testEmitsChunkedMiddleOscAsOneChunk()
148134
$this->input->emit('data', array("d\x07"));
149135
}
150136

137+
public function testEmitsDpsStAsOneChunk()
138+
{
139+
$this->parser->on('data', $this->expectCallableNever());
140+
$this->parser->on('dps', $this->expectCallableOnceWith("\x1BPasd\x1B\\"));
141+
142+
$this->input->emit('data', array("\x1BPasd\x1B\\"));
143+
}
144+
145+
public function testDoesNotEmitDpsIfItDoesNotEndWithSt()
146+
{
147+
$this->parser->on('data', $this->expectCallableNever());
148+
$this->parser->on('dps', $this->expectCallableNever());
149+
150+
$this->input->emit('data', array("\x1BPasd\x07"));
151+
}
152+
153+
public function testEmitsUnknownC1AsOneChunk()
154+
{
155+
$this->parser->on('data', $this->expectCallableNever());
156+
$this->parser->on('c1', $this->expectCallableOnceWith("\x1B="));
157+
158+
$this->input->emit('data', array("\x1B="));
159+
}
160+
151161
public function testClosingInputWillCloseParser()
152162
{
153163
$this->parser->on('close', $this->expectCallableOnce());

0 commit comments

Comments
 (0)