Skip to content

Commit 7c4adc9

Browse files
committed
Improve ansi escape code parser
1 parent e4261e2 commit 7c4adc9

3 files changed

Lines changed: 144 additions & 91 deletions

File tree

gambaterm/ansi_escape_code.py

Lines changed: 139 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from itertools import count
12
import sys
23
from enum import Enum, auto
34
from dataclasses import dataclass
@@ -40,8 +41,8 @@ class OSC:
4041

4142
CHAR = "]"
4243
BELL = "\x07"
43-
ST1 = "\x5c"
44-
ST2 = "\x9c"
44+
ESC_ST = "\x5c"
45+
SINGLE_ST = "\x9c"
4546

4647
def raw(self) -> str:
4748
return f"\033{self.CHAR}{self.payload}{self.BELL}"
@@ -52,77 +53,126 @@ class DCS:
5253
payload: str
5354

5455
CHAR = "P"
55-
ST1 = "\x5c"
56-
ST2 = "\x9c"
56+
ESC_ST = "\x5c"
57+
SINGLE_ST = "\x9c"
5758

5859
def raw(self) -> str:
59-
return f"\033{self.CHAR}{self.payload}\033{self.ST1}"
60+
return f"\033{self.CHAR}{self.payload}\033{self.ESC_ST}"
6061

6162

6263
EscapeCode: TypeAlias = CSI | OSC | DCS
64+
Ready: TypeAlias = list[EscapeCode | str]
6365

6466

65-
def parse_ansi_escape_code() -> Generator[EscapeCode | str | None, str, None]:
66-
ready: EscapeCode | str | None = None
67+
def parse_ansi_escape_code() -> Generator[Ready, str, None]:
68+
# Initialize result list
69+
ready: Ready = []
70+
71+
# Get first data
72+
data = yield ready
73+
74+
# Loop over ansi escape code
6775
while True:
68-
char = yield ready
69-
while char != "\033":
70-
char = yield char
71-
code = yield None
76+
# Wait for escape
77+
while "\033" not in data:
78+
ready.append(data)
79+
data = yield ready
80+
ready = []
81+
82+
# Split the data
83+
before, data = data.split("\033", maxsplit=1)
84+
ready.append(before)
85+
86+
# Wait for new data if necessary
87+
while not data:
88+
data = yield ready
89+
ready = []
90+
91+
# Match code
92+
code = data[0]
7293
if code == CSI.CHAR:
73-
ready = yield from parse_csi()
94+
data, ready = yield from parse_csi(data, ready)
7495
elif code == OSC.CHAR:
75-
ready = yield from parse_osc()
96+
data, ready = yield from parse_osc(data, ready)
7697
elif code == DCS.CHAR:
77-
ready = yield from parse_dcs()
98+
data, ready = yield from parse_dcs(data, ready)
7899
else:
79-
ready = char + code
100+
ready.append("\033")
80101

81102

82-
def parse_csi() -> Generator[None, str, CSI]:
83-
payload = ""
84-
while True:
85-
char = yield None
103+
def parse_csi(
104+
data: str, ready: list[EscapeCode | str]
105+
) -> Generator[Ready, str, tuple[str, Ready]]:
106+
# Loop over characters
107+
for i in count(1):
108+
# Get more data if necessary
109+
if i == len(data):
110+
data += yield ready
111+
ready = []
112+
char = data[i]
113+
114+
# Check for stop code
86115
if char in CSI.CODES:
87-
break
88-
payload += char
89-
return CSI(char, payload)
116+
ready.append(CSI(char, data[1:i]))
117+
return data[i + 1 :], ready
90118

119+
assert False
91120

92-
def parse_osc() -> Generator[None, str, OSC]:
93-
payload = ""
94-
while True:
95-
char = yield None
96-
while char == "\033":
97-
extra = yield None
98-
if extra == OSC.ST1:
99-
return OSC(payload)
100-
payload += char
101-
char = extra
102-
if char in (OSC.BELL, OSC.ST2):
103-
return OSC(payload)
104-
payload += char
105-
106-
107-
def parse_dcs() -> Generator[None, str, DCS]:
108-
payload = ""
109-
while True:
110-
char = yield None
111-
while char == "\033":
112-
extra = yield None
113-
if extra == OSC.ST1:
114-
return DCS(payload)
115-
payload += char
116-
char = extra
117-
if char == OSC.ST2:
118-
return DCS(payload)
119-
payload += char
121+
122+
def parse_osc(
123+
data: str, ready: list[EscapeCode | str]
124+
) -> Generator[Ready, str, tuple[str, Ready]]:
125+
# Loop over characters
126+
for i in count(1):
127+
# Get more data if necessary
128+
if i == len(data):
129+
data += yield ready
130+
ready = []
131+
before = data[i - 1]
132+
char = data[i]
133+
134+
# Check for double character stop code
135+
if before == "\033" and char == OSC.ESC_ST:
136+
ready.append(OSC(data[1 : i - 1]))
137+
return data[i + 1 :], ready
138+
139+
# Check for single character stop code
140+
if char in (OSC.BELL, OSC.SINGLE_ST):
141+
ready.append(OSC(data[1:i]))
142+
return data[i + 1 :], ready
143+
144+
assert False
145+
146+
147+
def parse_dcs(
148+
data: str, ready: list[EscapeCode | str]
149+
) -> Generator[Ready, str, tuple[str, Ready]]:
150+
# Loop over characters
151+
for i in count(1):
152+
# Get more data if necessary
153+
if i == len(data):
154+
data += yield ready
155+
ready = []
156+
before = data[i - 1]
157+
char = data[i]
158+
159+
# Check for double character stop code
160+
if before == "\033" and char == DCS.ESC_ST:
161+
ready.append(DCS(data[1 : i - 1]))
162+
return data[i + 1 :], ready
163+
164+
# Check for single character stop code
165+
if char == DCS.SINGLE_ST:
166+
ready.append(DCS(data[1:i]))
167+
return data[i + 1 :], ready
168+
169+
assert False
120170

121171

122172
def detect_true_color_support_with_DCS() -> Generator[str | None, str, SupportStatus]:
123173
"""
124-
This detection is restrictive, i.e it has not known positive
125-
However, it falsely reports no support for gnome terminal
174+
This detection is restrictive, i.e it has no known false positive.
175+
However, it falsely reports no support for gnome-terminal
126176
"""
127177
# Set unlikely RGB value
128178
command = "\033[48;2;1;2;3m"
@@ -133,21 +183,22 @@ def detect_true_color_support_with_DCS() -> Generator[str | None, str, SupportSt
133183
# Query primary device attributes
134184
command += "\033[c"
135185
# Send command
136-
char = yield command
186+
data = yield command
137187
# Prepare coroutine
138188
coro = parse_ansi_escape_code()
139-
assert next(coro) is None
189+
assert not next(coro)
140190
# Loop over characters
141191
result = SupportStatus.Undecided
142192
while True:
143-
item = coro.send(char)
144-
if isinstance(item, CSI):
145-
if item.code == "c":
146-
return result
147-
if isinstance(item, DCS):
148-
if item.payload.endswith(":1:2:3m"):
149-
result = SupportStatus.Supported
150-
char = yield None
193+
ready = coro.send(data)
194+
for item in ready:
195+
if isinstance(item, CSI):
196+
if item.code == "c":
197+
return result
198+
if isinstance(item, DCS):
199+
if item.payload.endswith(":1:2:3m"):
200+
result = SupportStatus.Supported
201+
data = yield None
151202

152203

153204
def detect_true_color_support_with_OSC() -> Generator[str | None, str, SupportStatus]:
@@ -160,21 +211,22 @@ def detect_true_color_support_with_OSC() -> Generator[str | None, str, SupportSt
160211
# Query primary device attributes
161212
command += "\033[c"
162213
# Send command
163-
char = yield command
214+
data = yield command
164215
# Prepare coroutine
165216
coro = parse_ansi_escape_code()
166-
assert next(coro) is None
217+
assert not next(coro)
167218
# Loop over characters
168219
result = SupportStatus.Unsupported
169220
while True:
170-
item = coro.send(char)
171-
if isinstance(item, CSI):
172-
if item.code == "c":
173-
return result
174-
if isinstance(item, OSC):
175-
if "rgb" in item.payload:
176-
result = SupportStatus.Undecided
177-
char = yield None
221+
ready = coro.send(data)
222+
for item in ready:
223+
if isinstance(item, CSI):
224+
if item.code == "c":
225+
return result
226+
if isinstance(item, OSC):
227+
if "rgb" in item.payload:
228+
result = SupportStatus.Undecided
229+
data = yield None
178230

179231

180232
def detect_true_color_support_parser() -> Generator[str | None, str, SupportStatus]:
@@ -195,20 +247,21 @@ def detect_keyboard_protocol_support_parser() -> (
195247
# Query primary device attributes
196248
command += "\033[c"
197249
# Send command
198-
char = yield command
250+
data = yield command
199251
# Prepare coroutine
200252
coro = parse_ansi_escape_code()
201-
assert next(coro) is None
253+
assert not next(coro)
202254
# Loop over characters
203255
result = SupportStatus.Unsupported
204256
while True:
205-
item = coro.send(char)
206-
if isinstance(item, CSI):
207-
if item.code == "c":
208-
return result
209-
if item.code == "u":
210-
result = SupportStatus.Supported
211-
char = yield None
257+
ready = coro.send(data)
258+
for item in ready:
259+
if isinstance(item, CSI):
260+
if item.code == "c":
261+
return result
262+
if item.code == "u":
263+
result = SupportStatus.Supported
264+
data = yield None
212265

213266

214267
def run_parser_in_app_session(
@@ -229,13 +282,12 @@ def run_parser_in_app_session(
229282
raise KeyboardInterrupt
230283

231284
# Send each char of the key into the coroutine
232-
for char in key.data:
233-
command = coro.send(char)
285+
command = coro.send(key.data)
234286

235-
# Send extra command
236-
if command is not None:
237-
app_session.output.write_raw(command)
238-
app_session.output.flush()
287+
# Send extra command
288+
if command is not None:
289+
app_session.output.write_raw(command)
290+
app_session.output.flush()
239291

240292
# Get the result
241293
except StopIteration as exc:

gambaterm/keyboard_protocol_input.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,15 +326,15 @@ def __init__(self, vt100_input: Vt100Parser) -> None:
326326
super().__init__(vt100_input.feed_key_callback)
327327
self.pressed: set[Keys] = set()
328328
self.ansi_escape_code_parser = parse_ansi_escape_code()
329-
assert next(self.ansi_escape_code_parser) is None
329+
assert not next(self.ansi_escape_code_parser)
330330

331331
def get_pressed(self) -> set[Keys]:
332332
return self.pressed
333333

334334
def feed(self, data: str) -> None:
335335
data_out: list[str] = []
336-
for char in data:
337-
item = self.ansi_escape_code_parser.send(char)
336+
ready = self.ansi_escape_code_parser.send(data)
337+
for item in ready:
338338
if item is None:
339339
continue
340340
if isinstance(item, str):

gambaterm/keys.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from typing import TypeAlias
34
from enum import Enum, auto
45

56

@@ -319,4 +320,4 @@ class FunctionalKeys(Enum):
319320
ISO_LEVEL5_SHIFT = auto()
320321

321322

322-
Keys = LatinKeys | FunctionalKeys
323+
Keys: TypeAlias = LatinKeys | FunctionalKeys

0 commit comments

Comments
 (0)