Skip to content

Commit c3560f5

Browse files
committed
Add ansi_escape_code and support for keyboard protocol in gambaterm-ssh
1 parent b9058b2 commit c3560f5

6 files changed

Lines changed: 438 additions & 80 deletions

File tree

gambaterm/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +0,0 @@
1-
from .main import main
2-
3-
__all__ = ["main"]

gambaterm/ansi_escape_code.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import sys
2+
from enum import StrEnum, auto
3+
from dataclasses import dataclass
4+
from string import ascii_lowercase, ascii_uppercase
5+
from typing import Callable, Generator, TypeAlias, TypeVar, cast
6+
7+
from asyncssh import SSHServerProcess
8+
import asyncssh
9+
from prompt_toolkit.application import AppSession, create_app_session
10+
11+
T = TypeVar("T")
12+
13+
14+
class SupportStatus(StrEnum):
15+
Supported = auto()
16+
Unsupported = auto()
17+
Undecided = auto()
18+
19+
def is_supported(self) -> bool:
20+
return self == SupportStatus.Supported
21+
22+
23+
@dataclass
24+
class CSI:
25+
code: str
26+
payload: str
27+
28+
CODES = r"@[\]^_`{|}~"
29+
CODES += ascii_uppercase
30+
CODES += ascii_lowercase
31+
32+
33+
@dataclass
34+
class OSC:
35+
payload: str
36+
37+
BELL = "\x07"
38+
ST1 = "\x5c"
39+
ST2 = "\x9c"
40+
41+
42+
@dataclass
43+
class DCS:
44+
payload: str
45+
46+
ST1 = "\x5c"
47+
ST2 = "\x9c"
48+
49+
50+
EscapeCode: TypeAlias = CSI | OSC | DCS
51+
52+
53+
def parse_ansi_escape_code() -> Generator[EscapeCode | None, str, None]:
54+
ready: EscapeCode | None = None
55+
while True:
56+
char = yield ready
57+
while char != "\033":
58+
char = yield None
59+
code = yield None
60+
if code == "[":
61+
ready = yield from parse_csi()
62+
elif code == "]":
63+
ready = yield from parse_osc()
64+
elif code == "P":
65+
ready = yield from parse_dcs()
66+
else:
67+
ready = None
68+
69+
70+
def parse_csi() -> Generator[None, str, CSI]:
71+
payload = ""
72+
while True:
73+
char = yield None
74+
if char in CSI.CODES:
75+
break
76+
payload += char
77+
return CSI(char, payload)
78+
79+
80+
def parse_osc() -> Generator[None, str, OSC]:
81+
payload = ""
82+
while True:
83+
char = yield None
84+
while char == "\033":
85+
extra = yield None
86+
if extra == OSC.ST1:
87+
return OSC(payload)
88+
payload += char
89+
char = extra
90+
if char in (OSC.BELL, OSC.ST2):
91+
return OSC(payload)
92+
payload += char
93+
94+
95+
def parse_dcs() -> Generator[None, str, DCS]:
96+
payload = ""
97+
while True:
98+
char = yield None
99+
while char == "\033":
100+
extra = yield None
101+
if extra == OSC.ST1:
102+
return DCS(payload)
103+
payload += char
104+
char = extra
105+
if char == OSC.ST2:
106+
return DCS(payload)
107+
payload += char
108+
109+
110+
def detect_true_color_support_with_DCS() -> Generator[str | None, str, SupportStatus]:
111+
"""
112+
This detection is restrictive, i.e it has not known positive
113+
However, it falsely reports no support for gnome terminal
114+
"""
115+
# Set unlikely RGB value
116+
command = "\033[48;2;1;2;3m"
117+
# Query current configuration
118+
command += "\033P$qm\033\\"
119+
# Reset
120+
command += "\033[m"
121+
# Query primary device attributes
122+
command += "\033[c"
123+
# Send command
124+
char = yield command
125+
# Prepare coroutine
126+
coro = parse_ansi_escape_code()
127+
assert next(coro) is None
128+
# Loop over characters
129+
result = SupportStatus.Undecided
130+
while True:
131+
item = coro.send(char)
132+
if isinstance(item, CSI):
133+
if item.code == "c":
134+
return result
135+
if isinstance(item, DCS):
136+
if item.payload.endswith(":1:2:3m"):
137+
result = SupportStatus.Supported
138+
char = yield None
139+
140+
141+
def detect_true_color_support_with_OSC() -> Generator[str | None, str, SupportStatus]:
142+
"""
143+
This detection is too permissive, i.e. it has no known false negative.
144+
However, it falsely reports support for rxvt-unicode
145+
"""
146+
# Get color at slot 255
147+
command = "\033]4;255;?\033\\"
148+
# Query primary device attributes
149+
command += "\033[c"
150+
# Send command
151+
char = yield command
152+
# Prepare coroutine
153+
coro = parse_ansi_escape_code()
154+
assert next(coro) is None
155+
# Loop over characters
156+
result = SupportStatus.Unsupported
157+
while True:
158+
item = coro.send(char)
159+
if isinstance(item, CSI):
160+
if item.code == "c":
161+
return result
162+
if isinstance(item, OSC):
163+
if "rgb" in item.payload:
164+
result = SupportStatus.Undecided
165+
char = yield None
166+
167+
168+
def detect_true_color_support_parser() -> Generator[str | None, str, SupportStatus]:
169+
permissive_status = yield from detect_true_color_support_with_OSC()
170+
if permissive_status == SupportStatus.Unsupported:
171+
return permissive_status
172+
restrictive_status = yield from detect_true_color_support_with_DCS()
173+
if restrictive_status == SupportStatus.Supported:
174+
return restrictive_status
175+
return SupportStatus.Undecided
176+
177+
178+
def detect_keyboard_protocol_support_parser() -> (
179+
Generator[str | None, str, SupportStatus]
180+
):
181+
# Query the keyboard protocol flags
182+
command = "\033[?u"
183+
# Query primary device attributes
184+
command += "\033[c"
185+
# Send command
186+
char = yield command
187+
# Prepare coroutine
188+
coro = parse_ansi_escape_code()
189+
assert next(coro) is None
190+
# Loop over characters
191+
result = SupportStatus.Unsupported
192+
while True:
193+
item = coro.send(char)
194+
if isinstance(item, CSI):
195+
if item.code == "c":
196+
return result
197+
if item.code == "u":
198+
result = SupportStatus.Supported
199+
char = yield None
200+
201+
202+
def run_parser_in_app_session(
203+
app_session: AppSession, parser: Callable[[], Generator[str | None, str, T]]
204+
) -> T:
205+
coro = parser()
206+
command = next(coro)
207+
assert isinstance(command, str)
208+
app_session.output.write_raw(command)
209+
app_session.output.flush()
210+
211+
# Loop until stop iteration
212+
try:
213+
while True:
214+
# Get next key
215+
for key in app_session.input.read_keys():
216+
if key.key == "c-c":
217+
raise KeyboardInterrupt
218+
219+
# Send each char of the key into the coroutine
220+
for char in key.data:
221+
command = coro.send(char)
222+
223+
# Send extra command
224+
if command is not None:
225+
app_session.output.write_raw(command)
226+
app_session.output.flush()
227+
228+
# Get the result
229+
except StopIteration as exc:
230+
return cast("T", exc.value)
231+
232+
233+
async def run_parser_in_ssh_server_process(
234+
process: SSHServerProcess[str],
235+
parser: Callable[[], Generator[str | None, str, T]],
236+
) -> T:
237+
coro = parser()
238+
command = next(coro)
239+
assert isinstance(command, str)
240+
process.stdout.write(command)
241+
await process.stdout.drain()
242+
243+
# Loop until stop iteration
244+
try:
245+
while True:
246+
try:
247+
char = await process.stdin.read(1)
248+
except asyncssh.TerminalSizeChanged:
249+
continue
250+
else:
251+
command = coro.send(char)
252+
if command is not None:
253+
process.stdout.write(command)
254+
await process.stdout.drain()
255+
256+
# Get the result
257+
except StopIteration as exc:
258+
return cast("T", exc.value)
259+
260+
261+
def main() -> None:
262+
"""Entry point to test terminal capabilites."""
263+
264+
# Check that stdin is a tty
265+
if not sys.stdin.isatty():
266+
print("Stdin is not a tty")
267+
exit(1)
268+
269+
# Use prompt-toolkit to enable raw mode
270+
with create_app_session() as app_session:
271+
with app_session.input.raw_mode():
272+
true_color_status = run_parser_in_app_session(
273+
app_session, detect_true_color_support_parser
274+
)
275+
keyboard_protocol_status = run_parser_in_app_session(
276+
app_session, detect_keyboard_protocol_support_parser
277+
)
278+
279+
# Print the results
280+
print(f"True color mode : {true_color_status.value}")
281+
print(f"Keyboard terminal : {keyboard_protocol_status.value}")
282+
283+
284+
if __name__ == "__main__":
285+
main()

gambaterm/colors.py

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

3+
from dataclasses import dataclass
34
import os
45
import time
56
from enum import IntEnum
6-
7+
from typing import Generator
8+
from string import ascii_lowercase, ascii_uppercase
79
from prompt_toolkit.application import AppSession
810

911
BASIC_TERMINALS = [
@@ -95,3 +97,59 @@ def detect_true_color_support(app_session: AppSession, timeout: float = 0.1) ->
9597
time.sleep(0.01)
9698
# Return whether true color is supported
9799
return "P1$r" in data and "48:2" in data and "1:2:3m" in data
100+
101+
102+
@dataclass
103+
class CSI:
104+
code: str
105+
payload: str
106+
107+
CODES = r"@[\]^_`{|}~"
108+
CODES += ascii_uppercase
109+
CODES += ascii_lowercase
110+
111+
112+
@dataclass
113+
class OSC:
114+
payload: str
115+
116+
BELL = "\x07"
117+
ST1 = "\x5c"
118+
ST2 = "\x9c"
119+
120+
121+
def parse_ansi_escape_code() -> Generator[CSI | OSC | None, str, None]:
122+
while True:
123+
while (yield None) == "\033":
124+
pass
125+
code = yield None
126+
if code == "[":
127+
yield from parse_csi()
128+
if code == "]":
129+
yield from parse_osc()
130+
131+
132+
def parse_csi() -> Generator[CSI | OSC | None, str, None]:
133+
payload = ""
134+
while True:
135+
char = yield None
136+
if char in CSI.CODES:
137+
break
138+
payload += char
139+
yield CSI(char, payload)
140+
141+
142+
def parse_osc() -> Generator[CSI | OSC | None, str, None]:
143+
payload = ""
144+
while True:
145+
char = yield None
146+
while char == "\033":
147+
extra = yield None
148+
if extra == OSC.ST1:
149+
break
150+
payload += char
151+
char = extra
152+
if char in (OSC.BELL, OSC.ST2):
153+
break
154+
payload += char
155+
yield OSC(payload)

0 commit comments

Comments
 (0)