From 975bf7b97412f82cc0c00c790ec3a39e052f4ad4 Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:38:03 +0100 Subject: [PATCH 01/10] Repeat PyREPL key events on Windows when wRepeatCount > 1 Keys that are repeated into PyREPL were typed only once. This change makes those characters be typed the correct number of times. --- Lib/_pyrepl/windows_console.py | 57 +++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index d457d2b5a338eb..16523498d052e6 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -128,6 +128,7 @@ def __init__( self.height = 25 self.__offset = 0 self.event_queue: deque[Event] = deque() + self.key_repeat_queue: dequeue[Event] = deque() try: self.out = io._WindowsConsoleIO(self.output_fd, "w") # type: ignore[attr-defined] except ValueError: @@ -390,6 +391,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() @@ -407,31 +411,40 @@ def get_event(self, block: bool = True) -> Event | None: continue return None - key = rec.Event.KeyEvent.uChar.UnicodeChar - - if rec.Event.KeyEvent.uChar.UnicodeChar == "\r": - # Make enter make unix-like - return Event(evt="key", data="\n", raw=b"\n") - elif rec.Event.KeyEvent.wVirtualKeyCode == 8: - # Turn backspace directly into the command + event = self._event_from_keyevent(rec.Event.KeyEvent) + if event is None and block: + continue + + # 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) + + return event + + def _event_from_keyevent(self, keyevent: KeyEvent) -> Event | None: + key = keyevent.uChar.UnicodeChar + + if keyevent.uChar.UnicodeChar == "\r": + # Make enter make unix-like + return Event(evt="key", data="\n", raw=b"\n") + elif keyevent.wVirtualKeyCode == 8: + # Turn backspace directly into the command + return Event( + evt="key", + data="backspace", + raw=keyevent.uChar.UnicodeChar, + ) + elif keyevent.uChar.UnicodeChar == "\x00": + # Handle special keys like arrow keys and translate them into the appropriate command + code = VK_MAP.get(keyevent.wVirtualKeyCode) + if code: return Event( - evt="key", - data="backspace", - raw=rec.Event.KeyEvent.uChar.UnicodeChar, + evt="key", data=code, raw=keyevent.uChar.UnicodeChar ) - elif rec.Event.KeyEvent.uChar.UnicodeChar == "\x00": - # Handle special keys like arrow keys and translate them into the appropriate command - code = VK_MAP.get(rec.Event.KeyEvent.wVirtualKeyCode) - if code: - return Event( - evt="key", data=code, raw=rec.Event.KeyEvent.uChar.UnicodeChar - ) - if block: - continue - return None + return None - return Event(evt="key", data=key, raw=rec.Event.KeyEvent.uChar.UnicodeChar) + return Event(evt="key", data=key, raw=keyevent.uChar.UnicodeChar) def push_char(self, char: int | bytes) -> None: """ @@ -479,7 +492,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 From 215894de8b2258b9784a795b30ce178c7e0f055b Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Sat, 14 Dec 2024 17:14:12 +0100 Subject: [PATCH 02/10] Fixed typo in type hint --- Lib/_pyrepl/windows_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 16523498d052e6..4481703f00e2f6 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -128,7 +128,7 @@ def __init__( self.height = 25 self.__offset = 0 self.event_queue: deque[Event] = deque() - self.key_repeat_queue: dequeue[Event] = deque() + self.key_repeat_queue: deque[Event] = deque() try: self.out = io._WindowsConsoleIO(self.output_fd, "w") # type: ignore[attr-defined] except ValueError: From 50f260acbb7582e67d94e379115a9e91ce78890e Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Sat, 14 Dec 2024 17:20:26 +0100 Subject: [PATCH 03/10] Fixed mypy type error --- Lib/_pyrepl/windows_console.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 4481703f00e2f6..005dea5e68b399 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -415,9 +415,10 @@ def get_event(self, block: bool = True) -> Event | None: if event is None and block: continue - # 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) + 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) return event From 655a98edf548e1b4373ba39652c27fcf75fa0609 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 14 Dec 2024 16:28:34 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst 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..3eb97aa7baaa6a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst @@ -0,0 +1 @@ +Repeat PyREPL key events on Windows when wRepeatCount > 1. This fixes double dead key presses from only being typed once in the Windows Terminal. From e77c89d8e94b36101f7bae7d8e5c2cc14c62e112 Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Fri, 10 Jan 2025 19:36:31 +0100 Subject: [PATCH 05/10] Shortened news --- .../next/Library/2024-12-14-16-28-33.gh-issue-127947.2W5rYh.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3eb97aa7baaa6a..c1f269eac8763a 100644 --- 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 @@ -1 +1 @@ -Repeat PyREPL key events on Windows when wRepeatCount > 1. This fixes double dead key presses from only being typed once in the Windows Terminal. +Fix double dead key presses in PyREPL being typed just once into Windows Terminal. From 825ccafd4463bd87e0866b7caf05a01d99d019a4 Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Fri, 10 Jan 2025 19:56:09 +0100 Subject: [PATCH 06/10] Simplified if-logic --- Lib/_pyrepl/windows_console.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 005dea5e68b399..6a385e4c785a06 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -412,13 +412,14 @@ def get_event(self, block: bool = True) -> Event | None: return None event = self._event_from_keyevent(rec.Event.KeyEvent) - if event is None and block: - continue 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 From 72e380d05f41d404fcbea38bc33dc6f1667429c0 Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Sun, 12 Jan 2025 17:40:40 +0100 Subject: [PATCH 07/10] Added tests for wRepeatCount to PyREPL on Windows By simulating INPUT_RECORD being received --- Lib/test/test_pyrepl/test_windows_console.py | 49 +++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) 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) From a68e045ea3573bcd29e6bc3b759a4545e91d8c3d Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Sun, 12 Jan 2025 17:42:56 +0100 Subject: [PATCH 08/10] Added self to ACKS --- Misc/ACKS | 1 + 1 file changed, 1 insertion(+) diff --git a/Misc/ACKS b/Misc/ACKS index 086930666822ad..8e3882f503e018 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 From 1f7bf1f8721fea5d3a0326e1e7b096540bec1b42 Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:45:41 +0100 Subject: [PATCH 09/10] Removed key_repeat_queue, only use the event_queue And replaced deque.insert(0, o) with deque.appendleft(o) for O(1) insertion --- Lib/_pyrepl/windows_console.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 72562bc0b66d2a..4982f7001337c8 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -132,7 +132,6 @@ 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: @@ -347,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 @@ -395,9 +394,6 @@ 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() @@ -420,7 +416,7 @@ def get_event(self, block: bool = True) -> Event | None: 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) + self.event_queue.appendleft(event) elif block: # The key event didn't ectually type a character, block until next event continue @@ -444,7 +440,7 @@ def _event_from_keyevent(self, key_event: KeyEvent) -> Event | None: 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)) + 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) @@ -452,7 +448,7 @@ def _event_from_keyevent(self, key_event: KeyEvent) -> Event | 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)) + 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) @@ -503,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() or self.key_repeat_queue: # 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 From fa12c702337933c886357f7c697fe6ecc1a85399 Mon Sep 17 00:00:00 2001 From: SimonvBez <31727669+SimonvBez@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:04:18 +0100 Subject: [PATCH 10/10] Fixed typo in code comment --- Lib/_pyrepl/windows_console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/windows_console.py b/Lib/_pyrepl/windows_console.py index 4982f7001337c8..e4341e61adac6e 100644 --- a/Lib/_pyrepl/windows_console.py +++ b/Lib/_pyrepl/windows_console.py @@ -418,7 +418,7 @@ def get_event(self, block: bool = True) -> Event | None: for _ in range(rec.Event.KeyEvent.wRepeatCount - 1): self.event_queue.appendleft(event) elif block: - # The key event didn't ectually type a character, block until next event + # The key event didn't actually type a character, block until next event continue return event