Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-127947: Repeat PyREPL key events on Windows when wRepeatCount > 1 #127948

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 43 additions & 29 deletions Lib/_pyrepl/windows_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def __init__(
self.height = 25
self.__offset = 0
self.event_queue: deque[Event] = deque()
self.key_repeat_queue: deque[Event] = deque()
try:
self.out = io._WindowsConsoleIO(self.output_fd, "w") # type: ignore[attr-defined]
except ValueError:
Expand Down Expand Up @@ -394,6 +395,9 @@ def get_event(self, block: bool = True) -> Event | None:
"""Return an Event instance. Returns None if |block| is false
and there is no event pending, otherwise waits for the
completion of an event."""
if self.key_repeat_queue:
return self.key_repeat_queue.pop()

if self.event_queue:
return self.event_queue.pop()

Expand All @@ -411,37 +415,47 @@ def get_event(self, block: bool = True) -> Event | None:
continue
return None

key_event = rec.Event.KeyEvent
raw_key = key = key_event.uChar.UnicodeChar

if key == "\r":
# Make enter unix-like
return Event(evt="key", data="\n", raw=b"\n")
elif key_event.wVirtualKeyCode == 8:
# Turn backspace directly into the command
key = "backspace"
elif key == "\x00":
# Handle special keys like arrow keys and translate them into the appropriate command
key = VK_MAP.get(key_event.wVirtualKeyCode)
if key:
if key_event.dwControlKeyState & CTRL_ACTIVE:
key = f"ctrl {key}"
elif key_event.dwControlKeyState & ALT_ACTIVE:
# queue the key, return the meta command
self.event_queue.insert(0, Event(evt="key", data=key, raw=key))
return Event(evt="key", data="\033") # keymap.py uses this for meta
return Event(evt="key", data=key, raw=key)
if block:
continue
event = self._event_from_keyevent(rec.Event.KeyEvent)

if event is not None:
# Queue this key event to be repeated if wRepeatCount > 1, such as when a 'dead key' is pressed twice
for _ in range(rec.Event.KeyEvent.wRepeatCount - 1):
self.key_repeat_queue.appendleft(event)
elif block:
# The key event didn't ectually type a character, block until next event
continue

return event

def _event_from_keyevent(self, key_event: KeyEvent) -> Event | None:
raw_key = key = key_event.uChar.UnicodeChar

if key == "\r":
# Make enter unix-like
return Event(evt="key", data="\n", raw=b"\n")
elif key_event.wVirtualKeyCode == 8:
# Turn backspace directly into the command
key = "backspace"
elif key == "\x00":
# Handle special keys like arrow keys and translate them into the appropriate command
key = VK_MAP.get(key_event.wVirtualKeyCode)
if key:
if key_event.dwControlKeyState & CTRL_ACTIVE:
key = f"ctrl {key}"
elif key_event.dwControlKeyState & ALT_ACTIVE:
# queue the key, return the meta command
self.event_queue.insert(0, Event(evt="key", data=key, raw=key))
return Event(evt="key", data="\033") # keymap.py uses this for meta
return Event(evt="key", data=key, raw=key)

return None
return None

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

return Event(evt="key", data=key, raw=raw_key)
return Event(evt="key", data=key, raw=raw_key)

def push_char(self, char: int | bytes) -> None:
"""
Expand Down Expand Up @@ -489,7 +503,7 @@ def wait(self, timeout: float | None) -> bool:
# Poor man's Windows select loop
start_time = time.time()
while True:
if msvcrt.kbhit(): # type: ignore[attr-defined]
if msvcrt.kbhit() or self.key_repeat_queue: # type: ignore[attr-defined]
return True
if timeout and time.time() - start_time > timeout / 1000:
return False
Expand Down
49 changes: 47 additions & 2 deletions Lib/test/test_pyrepl/test_windows_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,42 @@
MOVE_UP,
MOVE_DOWN,
ERASE_IN_LINE,
INPUT_RECORD,
ConsoleEvent,
KeyEvent,
Char,
KEY_EVENT
)
except ImportError:
pass


def make_input_record(character: str, repeat_count: int = 1, virtual_keycode: int = 0):
assert len(character) == 1

rec = INPUT_RECORD()
rec.EventType = KEY_EVENT
rec.Event = ConsoleEvent()
rec.Event.KeyEvent = KeyEvent()

rec.Event.KeyEvent.bKeyDown = True
rec.Event.KeyEvent.wRepeatCount = repeat_count
rec.Event.KeyEvent.wVirtualKeyCode = virtual_keycode # Only used for special keys (see VK_MAP in windows_console.py)
rec.Event.KeyEvent.wVirtualScanCode = 0 # Not used by WindowsConsole
rec.Event.KeyEvent.uChar = Char()
rec.Event.KeyEvent.uChar.UnicodeChar = character
rec.Event.KeyEvent.dwControlKeyState = False
return rec


class WindowsConsoleTests(TestCase):
def console(self, events, **kwargs) -> Console:
def console(self, events, mock_input_record=False, **kwargs) -> Console:
console = WindowsConsole()
console.get_event = MagicMock(side_effect=events)
if mock_input_record:
# Mock the lower level _read_input method instead of get_event
console._read_input = MagicMock(side_effect=events)
else:
console.get_event = MagicMock(side_effect=events)
console._scroll = MagicMock()
console._hide_cursor = MagicMock()
console._show_cursor = MagicMock()
Expand All @@ -49,6 +76,9 @@ def console(self, events, **kwargs) -> Console:
def handle_events(self, events: Iterable[Event], **kwargs):
return handle_all_events(events, partial(self.console, **kwargs))

def handle_input_records(self, input_records: Iterable[INPUT_RECORD], **kwargs):
return handle_all_events(input_records, partial(self.console, mock_input_record=True, **kwargs))

def handle_events_narrow(self, events):
return self.handle_events(events, width=5)

Expand All @@ -58,6 +88,21 @@ def handle_events_short(self, events):
def handle_events_height_3(self, events):
return self.handle_events(events, height=3)

def test_key_records_no_repeat(self):
input_records = [make_input_record(c) for c in "12+34"]
_, con = self.handle_input_records(input_records)
expected_calls = [call(c.encode()) for c in "12+34"]
con.out.write.assert_has_calls(expected_calls)
con.restore()

def test_key_records_with_repeat(self):
input_records = [make_input_record(c, 2) for c in "12+34"]
input_records.append(make_input_record("5", 3))
_, con = self.handle_input_records(input_records)
expected_calls = [call(c.encode()) for c in "1122++3344555"]
con.out.write.assert_has_calls(expected_calls)
con.restore()

def test_simple_addition(self):
code = "12+34"
events = code_to_events(code)
Expand Down
1 change: 1 addition & 0 deletions Misc/ACKS
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Jay Berry
Eric Beser
Steven Bethard
Stephen Bevan
Simon van Bezooijen
Ron Bickers
Natalia B. Bidart
Adrian von Bidder
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix double dead key presses in PyREPL being typed just once into Windows Terminal.
Loading