Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a65366e

Browse files
y5c4l3encukoupablogsalDHowettwheeheee
authoredFeb 23, 2025
gh-124096: Enable REPL virtual terminal support on Windows (#124119)
To support virtual terminal mode in Windows PYREPL, we need a scanner to read over the supported escaped VT sequences. Windows REPL input was using virtual key mode, which does not support terminal escape sequences. This patch calls `SetConsoleMode` properly when initializing and send sequences to enable bracketed-paste modes to support verbatim copy-and-paste. Signed-off-by: y5c4l3 <y5c4l3@proton.me> Co-authored-by: Petr Viktorin <encukou@gmail.com> Co-authored-by: Pablo Galindo Salgado <Pablogsal@gmail.com> Co-authored-by: Dustin L. Howett <dustin@howett.net> Co-authored-by: wheeheee <104880306+wheeheee@users.noreply.github.com>
1 parent 25a7ddf commit a65366e

File tree

6 files changed

+264
-112
lines changed

6 files changed

+264
-112
lines changed
 

‎Lib/_pyrepl/base_eventqueue.py

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright 2000-2008 Michael Hudson-Doyle <micahel@gmail.com>
2+
# Armin Rigo
3+
#
4+
# All Rights Reserved
5+
#
6+
#
7+
# Permission to use, copy, modify, and distribute this software and
8+
# its documentation for any purpose is hereby granted without fee,
9+
# provided that the above copyright notice appear in all copies and
10+
# that both that copyright notice and this permission notice appear in
11+
# supporting documentation.
12+
#
13+
# THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO
14+
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
15+
# AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
16+
# INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
17+
# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
18+
# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
19+
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
20+
21+
"""
22+
OS-independent base for an event and VT sequence scanner
23+
24+
See unix_eventqueue and windows_eventqueue for subclasses.
25+
"""
26+
27+
from collections import deque
28+
29+
from . import keymap
30+
from .console import Event
31+
from .trace import trace
32+
33+
class BaseEventQueue:
34+
def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None:
35+
self.compiled_keymap = keymap.compile_keymap(keymap_dict)
36+
self.keymap = self.compiled_keymap
37+
trace("keymap {k!r}", k=self.keymap)
38+
self.encoding = encoding
39+
self.events: deque[Event] = deque()
40+
self.buf = bytearray()
41+
42+
def get(self) -> Event | None:
43+
"""
44+
Retrieves the next event from the queue.
45+
"""
46+
if self.events:
47+
return self.events.popleft()
48+
else:
49+
return None
50+
51+
def empty(self) -> bool:
52+
"""
53+
Checks if the queue is empty.
54+
"""
55+
return not self.events
56+
57+
def flush_buf(self) -> bytearray:
58+
"""
59+
Flushes the buffer and returns its contents.
60+
"""
61+
old = self.buf
62+
self.buf = bytearray()
63+
return old
64+
65+
def insert(self, event: Event) -> None:
66+
"""
67+
Inserts an event into the queue.
68+
"""
69+
trace('added event {event}', event=event)
70+
self.events.append(event)
71+
72+
def push(self, char: int | bytes) -> None:
73+
"""
74+
Processes a character by updating the buffer and handling special key mappings.
75+
"""
76+
ord_char = char if isinstance(char, int) else ord(char)
77+
char = bytes(bytearray((ord_char,)))
78+
self.buf.append(ord_char)
79+
if char in self.keymap:
80+
if self.keymap is self.compiled_keymap:
81+
# sanity check, buffer is empty when a special key comes
82+
assert len(self.buf) == 1
83+
k = self.keymap[char]
84+
trace('found map {k!r}', k=k)
85+
if isinstance(k, dict):
86+
self.keymap = k
87+
else:
88+
self.insert(Event('key', k, self.flush_buf()))
89+
self.keymap = self.compiled_keymap
90+
91+
elif self.buf and self.buf[0] == 27: # escape
92+
# escape sequence not recognized by our keymap: propagate it
93+
# outside so that i can be recognized as an M-... key (see also
94+
# the docstring in keymap.py
95+
trace('unrecognized escape sequence, propagating...')
96+
self.keymap = self.compiled_keymap
97+
self.insert(Event('key', '\033', bytearray(b'\033')))
98+
for _c in self.flush_buf()[1:]:
99+
self.push(_c)
100+
101+
else:
102+
try:
103+
decoded = bytes(self.buf).decode(self.encoding)
104+
except UnicodeError:
105+
return
106+
else:
107+
self.insert(Event('key', decoded, self.flush_buf()))
108+
self.keymap = self.compiled_keymap

‎Lib/_pyrepl/unix_eventqueue.py

+5-81
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,9 @@
1818
# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
1919
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
2020

21-
from collections import deque
22-
23-
from . import keymap
24-
from .console import Event
2521
from . import curses
2622
from .trace import trace
23+
from .base_eventqueue import BaseEventQueue
2724
from termios import tcgetattr, VERASE
2825
import os
2926

@@ -70,83 +67,10 @@ def get_terminal_keycodes() -> dict[bytes, str]:
7067
keycodes.update(CTRL_ARROW_KEYCODES)
7168
return keycodes
7269

73-
class EventQueue:
70+
class EventQueue(BaseEventQueue):
7471
def __init__(self, fd: int, encoding: str) -> None:
75-
self.keycodes = get_terminal_keycodes()
72+
keycodes = get_terminal_keycodes()
7673
if os.isatty(fd):
7774
backspace = tcgetattr(fd)[6][VERASE]
78-
self.keycodes[backspace] = "backspace"
79-
self.compiled_keymap = keymap.compile_keymap(self.keycodes)
80-
self.keymap = self.compiled_keymap
81-
trace("keymap {k!r}", k=self.keymap)
82-
self.encoding = encoding
83-
self.events: deque[Event] = deque()
84-
self.buf = bytearray()
85-
86-
def get(self) -> Event | None:
87-
"""
88-
Retrieves the next event from the queue.
89-
"""
90-
if self.events:
91-
return self.events.popleft()
92-
else:
93-
return None
94-
95-
def empty(self) -> bool:
96-
"""
97-
Checks if the queue is empty.
98-
"""
99-
return not self.events
100-
101-
def flush_buf(self) -> bytearray:
102-
"""
103-
Flushes the buffer and returns its contents.
104-
"""
105-
old = self.buf
106-
self.buf = bytearray()
107-
return old
108-
109-
def insert(self, event: Event) -> None:
110-
"""
111-
Inserts an event into the queue.
112-
"""
113-
trace('added event {event}', event=event)
114-
self.events.append(event)
115-
116-
def push(self, char: int | bytes) -> None:
117-
"""
118-
Processes a character by updating the buffer and handling special key mappings.
119-
"""
120-
ord_char = char if isinstance(char, int) else ord(char)
121-
char = bytes(bytearray((ord_char,)))
122-
self.buf.append(ord_char)
123-
if char in self.keymap:
124-
if self.keymap is self.compiled_keymap:
125-
#sanity check, buffer is empty when a special key comes
126-
assert len(self.buf) == 1
127-
k = self.keymap[char]
128-
trace('found map {k!r}', k=k)
129-
if isinstance(k, dict):
130-
self.keymap = k
131-
else:
132-
self.insert(Event('key', k, self.flush_buf()))
133-
self.keymap = self.compiled_keymap
134-
135-
elif self.buf and self.buf[0] == 27: # escape
136-
# escape sequence not recognized by our keymap: propagate it
137-
# outside so that i can be recognized as an M-... key (see also
138-
# the docstring in keymap.py
139-
trace('unrecognized escape sequence, propagating...')
140-
self.keymap = self.compiled_keymap
141-
self.insert(Event('key', '\033', bytearray(b'\033')))
142-
for _c in self.flush_buf()[1:]:
143-
self.push(_c)
144-
145-
else:
146-
try:
147-
decoded = bytes(self.buf).decode(self.encoding)
148-
except UnicodeError:
149-
return
150-
else:
151-
self.insert(Event('key', decoded, self.flush_buf()))
152-
self.keymap = self.compiled_keymap
75+
keycodes[backspace] = "backspace"
76+
BaseEventQueue.__init__(self, encoding, keycodes)

‎Lib/_pyrepl/windows_console.py

+58-9
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from .console import Event, Console
4343
from .trace import trace
4444
from .utils import wlen
45+
from .windows_eventqueue import EventQueue
4546

4647
try:
4748
from ctypes import GetLastError, WinDLL, windll, WinError # type: ignore[attr-defined]
@@ -94,7 +95,9 @@ def __init__(self, err: int | None, descr: str | None = None) -> None:
9495
0x83: "f20", # VK_F20
9596
}
9697

97-
# Console escape codes: https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
98+
# Virtual terminal output sequences
99+
# Reference: https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#output-sequences
100+
# Check `windows_eventqueue.py` for input sequences
98101
ERASE_IN_LINE = "\x1b[K"
99102
MOVE_LEFT = "\x1b[{}D"
100103
MOVE_RIGHT = "\x1b[{}C"
@@ -110,6 +113,12 @@ def __init__(self, err: int | None, descr: str | None = None) -> None:
110113
class _error(Exception):
111114
pass
112115

116+
def _supports_vt():
117+
try:
118+
import nt
119+
return nt._supports_virtual_terminal()
120+
except (ImportError, AttributeError):
121+
return False
113122

114123
class WindowsConsole(Console):
115124
def __init__(
@@ -121,17 +130,29 @@ def __init__(
121130
):
122131
super().__init__(f_in, f_out, term, encoding)
123132

133+
self.__vt_support = _supports_vt()
134+
135+
if self.__vt_support:
136+
trace('console supports virtual terminal')
137+
138+
# Save original console modes so we can recover on cleanup.
139+
original_input_mode = DWORD()
140+
GetConsoleMode(InHandle, original_input_mode)
141+
trace(f'saved original input mode 0x{original_input_mode.value:x}')
142+
self.__original_input_mode = original_input_mode.value
143+
124144
SetConsoleMode(
125145
OutHandle,
126146
ENABLE_WRAP_AT_EOL_OUTPUT
127147
| ENABLE_PROCESSED_OUTPUT
128148
| ENABLE_VIRTUAL_TERMINAL_PROCESSING,
129149
)
150+
130151
self.screen: list[str] = []
131152
self.width = 80
132153
self.height = 25
133154
self.__offset = 0
134-
self.event_queue: deque[Event] = deque()
155+
self.event_queue = EventQueue(encoding)
135156
try:
136157
self.out = io._WindowsConsoleIO(self.output_fd, "w") # type: ignore[attr-defined]
137158
except ValueError:
@@ -295,6 +316,12 @@ def _enable_blinking(self):
295316
def _disable_blinking(self):
296317
self.__write("\x1b[?12l")
297318

319+
def _enable_bracketed_paste(self) -> None:
320+
self.__write("\x1b[?2004h")
321+
322+
def _disable_bracketed_paste(self) -> None:
323+
self.__write("\x1b[?2004l")
324+
298325
def __write(self, text: str) -> None:
299326
if "\x1a" in text:
300327
text = ''.join(["^Z" if x == '\x1a' else x for x in text])
@@ -324,8 +351,15 @@ def prepare(self) -> None:
324351
self.__gone_tall = 0
325352
self.__offset = 0
326353

354+
if self.__vt_support:
355+
SetConsoleMode(InHandle, self.__original_input_mode | ENABLE_VIRTUAL_TERMINAL_INPUT)
356+
self._enable_bracketed_paste()
357+
327358
def restore(self) -> None:
328-
pass
359+
if self.__vt_support:
360+
# Recover to original mode before running REPL
361+
self._disable_bracketed_paste()
362+
SetConsoleMode(InHandle, self.__original_input_mode)
329363

330364
def _move_relative(self, x: int, y: int) -> None:
331365
"""Moves relative to the current posxy"""
@@ -346,7 +380,7 @@ def move_cursor(self, x: int, y: int) -> None:
346380
raise ValueError(f"Bad cursor position {x}, {y}")
347381

348382
if y < self.__offset or y >= self.__offset + self.height:
349-
self.event_queue.insert(0, Event("scroll", ""))
383+
self.event_queue.insert(Event("scroll", ""))
350384
else:
351385
self._move_relative(x, y)
352386
self.posxy = x, y
@@ -394,10 +428,8 @@ def get_event(self, block: bool = True) -> Event | None:
394428
"""Return an Event instance. Returns None if |block| is false
395429
and there is no event pending, otherwise waits for the
396430
completion of an event."""
397-
if self.event_queue:
398-
return self.event_queue.pop()
399431

400-
while True:
432+
while self.event_queue.empty():
401433
rec = self._read_input(block)
402434
if rec is None:
403435
return None
@@ -428,20 +460,25 @@ def get_event(self, block: bool = True) -> Event | None:
428460
key = f"ctrl {key}"
429461
elif key_event.dwControlKeyState & ALT_ACTIVE:
430462
# queue the key, return the meta command
431-
self.event_queue.insert(0, Event(evt="key", data=key, raw=key))
463+
self.event_queue.insert(Event(evt="key", data=key, raw=key))
432464
return Event(evt="key", data="\033") # keymap.py uses this for meta
433465
return Event(evt="key", data=key, raw=key)
434466
if block:
435467
continue
436468

437469
return None
470+
elif self.__vt_support:
471+
# If virtual terminal is enabled, scanning VT sequences
472+
self.event_queue.push(rec.Event.KeyEvent.uChar.UnicodeChar)
473+
continue
438474

439475
if key_event.dwControlKeyState & ALT_ACTIVE:
440476
# queue the key, return the meta command
441-
self.event_queue.insert(0, Event(evt="key", data=key, raw=raw_key))
477+
self.event_queue.insert(Event(evt="key", data=key, raw=raw_key))
442478
return Event(evt="key", data="\033") # keymap.py uses this for meta
443479

444480
return Event(evt="key", data=key, raw=raw_key)
481+
return self.event_queue.get()
445482

446483
def push_char(self, char: int | bytes) -> None:
447484
"""
@@ -563,6 +600,13 @@ class INPUT_RECORD(Structure):
563600
MOUSE_EVENT = 0x02
564601
WINDOW_BUFFER_SIZE_EVENT = 0x04
565602

603+
ENABLE_PROCESSED_INPUT = 0x0001
604+
ENABLE_LINE_INPUT = 0x0002
605+
ENABLE_ECHO_INPUT = 0x0004
606+
ENABLE_MOUSE_INPUT = 0x0010
607+
ENABLE_INSERT_MODE = 0x0020
608+
ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
609+
566610
ENABLE_PROCESSED_OUTPUT = 0x01
567611
ENABLE_WRAP_AT_EOL_OUTPUT = 0x02
568612
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x04
@@ -594,6 +638,10 @@ class INPUT_RECORD(Structure):
594638
]
595639
ScrollConsoleScreenBuffer.restype = BOOL
596640

641+
GetConsoleMode = _KERNEL32.GetConsoleMode
642+
GetConsoleMode.argtypes = [HANDLE, POINTER(DWORD)]
643+
GetConsoleMode.restype = BOOL
644+
597645
SetConsoleMode = _KERNEL32.SetConsoleMode
598646
SetConsoleMode.argtypes = [HANDLE, DWORD]
599647
SetConsoleMode.restype = BOOL
@@ -620,6 +668,7 @@ def _win_only(*args, **kwargs):
620668
GetStdHandle = _win_only
621669
GetConsoleScreenBufferInfo = _win_only
622670
ScrollConsoleScreenBuffer = _win_only
671+
GetConsoleMode = _win_only
623672
SetConsoleMode = _win_only
624673
ReadConsoleInput = _win_only
625674
GetNumberOfConsoleInputEvents = _win_only
There was a problem loading the remainder of the diff.

0 commit comments

Comments
 (0)
Failed to load comments.