diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index e738fd09c65758..e4341e61adac6e 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -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 @@ -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): + 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: """ @@ -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 diff --git a/Lib/test/test_pyrepl/test_windows_console.py b/Lib/test/test_pyrepl/test_windows_console.py index 4a3b2baf64a944..867ca7a2461435 100644 --- a/Lib/test/test_pyrepl/test_windows_console.py +++ b/Lib/test/test_pyrepl/test_windows_console.py @@ -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() @@ -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) @@ -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) diff --git a/Misc/ACKS b/Misc/ACKS index deda334bee7417..bafa78d8c8c169 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -166,6 +166,7 @@ Jay Berry Eric Beser Steven Bethard Stephen Bevan +Simon van Bezooijen Ron Bickers Natalia B. Bidart Adrian von Bidder diff --git a/Misc/NEWS.d/next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst b/Misc/NEWS.d/next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst new file mode 100644 index 00000000000000..c1f269eac8763a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst @@ -0,0 +1 @@ +Fix double dead key presses in PyREPL being typed just once into Windows Terminal.