From 04b1386825267fd7abf485e07b853b63556f3558 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 2 Sep 2024 17:18:36 +0200 Subject: [PATCH 1/4] gh-119034, REPL: Change page up/down keys to search in history Change and keys of the Python REPL to history search forward/backward. --- Lib/_pyrepl/historical_reader.py | 40 ++++++++++++++++++- Lib/test/test_pyrepl/test_pyrepl.py | 39 ++++++++++++++++++ ...-09-02-17-32-15.gh-issue-119034.HYh5Vj.rst | 2 + 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2024-09-02-17-32-15.gh-issue-119034.HYh5Vj.rst diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index 7f4d0672d02094..67df2a4b6de461 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -71,6 +71,18 @@ def do(self) -> None: r.select_item(r.historyi - 1) +class history_search_backward(commands.Command): + def do(self) -> None: + r = self.reader + r.search_next(False) + + +class history_search_forward(commands.Command): + def do(self) -> None: + r = self.reader + r.search_next(True) + + class restore_history(commands.Command): def do(self) -> None: r = self.reader @@ -234,6 +246,8 @@ def __post_init__(self) -> None: isearch_forwards, isearch_backwards, operate_and_get_next, + history_search_backward, + history_search_forward, ]: self.commands[c.__name__] = c self.commands[c.__name__.replace("_", "-")] = c @@ -251,8 +265,8 @@ def collect_keymap(self) -> tuple[tuple[KeySpec, CommandName], ...]: (r"\C-s", "forward-history-isearch"), (r"\M-r", "restore-history"), (r"\M-.", "yank-arg"), - (r"\", "last-history"), - (r"\", "first-history"), + (r"\", "history-search-forward"), + (r"\", "history-search-backward"), ) def select_item(self, i: int) -> None: @@ -305,6 +319,28 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: else: return super().get_prompt(lineno, cursor_on_line) + def search_next(self, forwards: bool) -> None: + old_pos = self.pos + # st is empty is old_pos is zero + st = self.get_unicode()[:old_pos] + i = self.historyi + s = self.get_unicode() + while 1: + if (forwards and i >= len(self.history) - 1) or (not forwards and i == 0): + self.error("not found") + return + if forwards: + i += 1 + s = self.get_item(i) + else: + i -= 1 + s = self.get_item(i) + + if s.startswith(st): + self.select_item(i) + self.pos = old_pos + return + def isearch_next(self) -> None: st = self.isearch_term p = self.pos diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 012ce7c5a6ba19..03797619adc27d 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -658,6 +658,45 @@ def test_control_character(self): self.assertEqual(output, "c\x1d") self.assertEqual(clean_screen(reader.screen), "c") + def test_history_search_backward(self): + # Test history search backward with "imp" input + events = itertools.chain( + code_to_events("import os\n"), + code_to_events("imp"), + [ + Event(evt='key', data='page up', raw=bytearray(b'\x1b[5~')), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + # fill the history + reader = self.prepare_reader(events) + multiline_input(reader) + + # search for "imp" in history + output = multiline_input(reader) + self.assertEqual(output, "import os") + self.assertEqual(clean_screen(reader.screen), "import os") + + def test_history_search_backward_empty(self): + # Test history search backward with an empty input + events = itertools.chain( + code_to_events("import os\n"), + [ + Event(evt='key', data='page up', raw=bytearray(b'\x1b[5~')), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + # fill the history + reader = self.prepare_reader(events) + multiline_input(reader) + + # search backward in history + output = multiline_input(reader) + self.assertEqual(output, "import os") + self.assertEqual(clean_screen(reader.screen), "import os") + class TestPyReplCompleter(TestCase): def prepare_reader(self, events, namespace): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2024-09-02-17-32-15.gh-issue-119034.HYh5Vj.rst b/Misc/NEWS.d/next/Core_and_Builtins/2024-09-02-17-32-15.gh-issue-119034.HYh5Vj.rst new file mode 100644 index 00000000000000..f528691e1b6f9f --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2024-09-02-17-32-15.gh-issue-119034.HYh5Vj.rst @@ -0,0 +1,2 @@ +Change ```` and ```` keys of the Python REPL to history +search forward/backward. Patch by Victor Stinner. From bad8ce8667a4792e652752ba938f964a5e8c562b Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Tue, 3 Sep 2024 14:44:27 +0200 Subject: [PATCH 2/4] When not matching prefix, move cursor to the end --- Lib/_pyrepl/historical_reader.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index 67df2a4b6de461..d6eda4951c93cd 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -74,13 +74,13 @@ def do(self) -> None: class history_search_backward(commands.Command): def do(self) -> None: r = self.reader - r.search_next(False) + r.search_next(forwards=False) class history_search_forward(commands.Command): def do(self) -> None: r = self.reader - r.search_next(True) + r.search_next(forwards=True) class restore_history(commands.Command): @@ -319,12 +319,22 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: else: return super().get_prompt(lineno, cursor_on_line) - def search_next(self, forwards: bool) -> None: - old_pos = self.pos - # st is empty is old_pos is zero - st = self.get_unicode()[:old_pos] - i = self.historyi + def search_next(self, *, forwards: bool) -> None: + pos = self.pos s = self.get_unicode() + i = self.historyi + st = s[:pos] + + match_prefix = True + if i < len(self.history): + len_item = len(self.get_item(i)) + else: + len_item = 0 + if len_item and pos == len_item: + match_prefix = False + elif not pos: + match_prefix = False + while 1: if (forwards and i >= len(self.history) - 1) or (not forwards and i == 0): self.error("not found") @@ -336,9 +346,13 @@ def search_next(self, forwards: bool) -> None: i -= 1 s = self.get_item(i) + if not match_prefix: + self.select_item(i) + return + if s.startswith(st): self.select_item(i) - self.pos = old_pos + self.pos = pos return def isearch_next(self) -> None: From 330297d231d51c9955b7c53269e2b3c2795c81cd Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Tue, 3 Sep 2024 15:17:10 +0200 Subject: [PATCH 3/4] Fix last --- Lib/_pyrepl/historical_reader.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index d6eda4951c93cd..3e59bb68eb1ffe 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -337,8 +337,14 @@ def search_next(self, *, forwards: bool) -> None: while 1: if (forwards and i >= len(self.history) - 1) or (not forwards and i == 0): - self.error("not found") + if forwards and not match_prefix: + self.pos = 0 + self.buffer = [] + self.dirty = True + else: + self.error("not found") return + if forwards: i += 1 s = self.get_item(i) From 89a6ea930a018e76068e7fa229f0bc0e3879a4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 6 Sep 2024 12:44:52 +0200 Subject: [PATCH 4/4] Make multiline searches work, fix CTRL-C in the middle of a multiline block --- Lib/_pyrepl/historical_reader.py | 49 +++++++++++++++++++------------- Lib/_pyrepl/readline.py | 2 +- Lib/_pyrepl/simple_interact.py | 3 +- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index 3e59bb68eb1ffe..f6e14bdffc3352 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -320,23 +320,35 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: return super().get_prompt(lineno, cursor_on_line) def search_next(self, *, forwards: bool) -> None: + """Search history for the current line contents up to the cursor. + + Selects the first item found. If nothing is under the cursor, any next + item in history is selected. + """ pos = self.pos s = self.get_unicode() - i = self.historyi - st = s[:pos] + history_index = self.historyi - match_prefix = True - if i < len(self.history): - len_item = len(self.get_item(i)) - else: - len_item = 0 + # In multiline contexts, we're only interested in the current line. + nl_index = s.rfind('\n', 0, pos) + prefix = s[nl_index + 1:pos] + pos = len(prefix) + + match_prefix = len(prefix) + len_item = 0 + if history_index < len(self.history): + len_item = len(self.get_item(history_index)) if len_item and pos == len_item: match_prefix = False elif not pos: match_prefix = False while 1: - if (forwards and i >= len(self.history) - 1) or (not forwards and i == 0): + if forwards: + out_of_bounds = history_index >= len(self.history) - 1 + else: + out_of_bounds = history_index == 0 + if out_of_bounds: if forwards and not match_prefix: self.pos = 0 self.buffer = [] @@ -345,21 +357,20 @@ def search_next(self, *, forwards: bool) -> None: self.error("not found") return - if forwards: - i += 1 - s = self.get_item(i) - else: - i -= 1 - s = self.get_item(i) + history_index += 1 if forwards else -1 + s = self.get_item(history_index) if not match_prefix: - self.select_item(i) + self.select_item(history_index) return - if s.startswith(st): - self.select_item(i) - self.pos = pos - return + len_acc = 0 + for i, line in enumerate(s.splitlines(keepends=True)): + if line.startswith(prefix): + self.select_item(history_index) + self.pos = pos + len_acc + return + len_acc += len(line) def isearch_next(self) -> None: st = self.isearch_term diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 483eb1039fa062..2a85c5aed08382 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -434,7 +434,7 @@ def read_history_file(self, filename: str = gethistoryfile()) -> None: else: line = self._histline(line) if buffer: - line = "".join(buffer).replace("\r", "") + line + line = self._histline("".join(buffer).replace("\r", "") + line) del buffer[:] if line: history.append(line) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 91aef5e01eb867..3c79cf61d04051 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -163,7 +163,8 @@ def maybe_run_command(statement: str) -> bool: r.isearch_direction = '' r.console.forgetinput() r.pop_input_trans() - r.dirty = True + r.pos = len(r.get_unicode()) + r.dirty = True r.refresh() r.in_bracketed_paste = False console.write("\nKeyboardInterrupt\n")