Skip to content
70 changes: 40 additions & 30 deletions Lib/_pyrepl/windows_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ def move_cursor(self, x: int, y: int) -> None:
raise ValueError(f"Bad cursor position {x}, {y}")

if y < self.__offset or y >= self.__offset + self.height:
self.event_queue.insert(0, Event("scroll", ""))
self.event_queue.appendleft(Event("scroll", ""))
else:
self._move_relative(x, y)
self.__posxy = x, y
Expand Down Expand Up @@ -411,37 +411,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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only repeats the returned Esc event for ALT_ACTIVE keys. Alt+a with wRepeatCount == 2 becomes Esc, a, Esc, so we lose the second a. Can we repeat the full translated sequence here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when a key with ALT_ACTIVE repeats (wRepeatCount > 1)? _event_from_keyevent already queues the real key and returns the meta \033, so this loop only duplicates the meta event and we lose the repeated key, leaving a dangling meta. We probably want to repeat the whole meta+key sequence here, no?

self.event_queue.appendleft(event)
elif block:
# The key event didn't actually 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.appendleft(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.appendleft(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 +499,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.event_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.