Skip to content

Commit f64b86c

Browse files
committed
Fixed bug where async_alert() overwrites readline's incremental and non-incremental search prompts.
1 parent 16c6d30 commit f64b86c

File tree

5 files changed

+98
-19
lines changed

5 files changed

+98
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
* Bug Fixes
55
* Fixed issue where persistent history file was not saved upon SIGHUP and SIGTERM signals.
66
* Multiline commands are no longer fragmented in up-arrow history.
7+
* Fixed bug where `async_alert()` overwrites readline's incremental and non-incremental search prompts.
8+
* This fix introduces behavior where an updated prompt won't display after an aborted search
9+
until a user presses Enter. See [async_printing.py](https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py)
10+
example for how to handle this case using `Cmd.need_prompt_refresh()`.
711
* Enhancements
812
* Removed dependency on `attrs` and replaced with [dataclasses](https://docs.python.org/3/library/dataclasses.html)
913
* add `allow_clipboard` initialization parameter and attribute to disable ability to

cmd2/ansi.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,9 +1071,9 @@ def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_off
10711071
# Calculate how many terminal lines are taken up by all prompt lines except for the last one.
10721072
# That will be included in the input lines calculations since that is where the cursor is.
10731073
num_prompt_terminal_lines = 0
1074-
for line in prompt_lines[:-1]:
1075-
line_width = style_aware_wcswidth(line)
1076-
num_prompt_terminal_lines += int(line_width / terminal_columns) + 1
1074+
for prompt_line in prompt_lines[:-1]:
1075+
prompt_line_width = style_aware_wcswidth(prompt_line)
1076+
num_prompt_terminal_lines += int(prompt_line_width / terminal_columns) + 1
10771077

10781078
# Now calculate how many terminal lines are take up by the input
10791079
last_prompt_line = prompt_lines[-1]

cmd2/cmd2.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@
130130
from .rl_utils import (
131131
RlType,
132132
rl_escape_prompt,
133+
rl_get_display_prompt,
133134
rl_get_point,
134135
rl_get_prompt,
136+
rl_in_search_mode,
135137
rl_set_prompt,
136138
rl_type,
137139
rl_warning,
@@ -3295,6 +3297,12 @@ def _set_up_cmd2_readline(self) -> _SavedReadlineSettings:
32953297
"""
32963298
readline_settings = _SavedReadlineSettings()
32973299

3300+
if rl_type == RlType.GNU:
3301+
# To calculate line count when printing async_alerts, we rely on commands wider than
3302+
# the terminal to wrap across multiple lines. The default for horizontal-scroll-mode
3303+
# is "off" but a user may have overridden it in their readline initialization file.
3304+
readline.parse_and_bind("set horizontal-scroll-mode off")
3305+
32983306
if self._completion_supported():
32993307
# Set up readline for our tab completion needs
33003308
if rl_type == RlType.GNU:
@@ -5270,14 +5278,34 @@ class TestMyAppCase(Cmd2TestCase):
52705278
# Return a failure error code to support automated transcript-based testing
52715279
self.exit_code = 1
52725280

5281+
def need_prompt_refresh(self, new_prompt: str) -> bool: # pragma: no cover
5282+
"""
5283+
Check if the onscreen prompt needs to be refreshed.
5284+
5285+
There are two cases when the onscreen prompt needs to be refreshed.
5286+
1. self.prompt differs from the new prompt.
5287+
2. readline's prompt differs from the new prompt.
5288+
5289+
This case occurs when async_alert() is called while a user is in search mode (e.g. Ctrl-r).
5290+
To prevent overwriting readline's onscreen search prompt, async_alert() does not update
5291+
readline's saved prompt value. Therefore when a user aborts a search, the old prompt
5292+
is still on screen until they press Enter or another call to async_alert() is made.
5293+
5294+
This function is a convenient way for an async alert thread to check both cases.
5295+
5296+
:param new_prompt: the new prompt string
5297+
:return: True if the onscreen prompt needs to be refreshed, otherwise False.
5298+
"""
5299+
return new_prompt != self.prompt or new_prompt != rl_get_prompt()
5300+
52735301
def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
52745302
"""
52755303
Display an important message to the user while they are at a command line prompt.
52765304
To the user it appears as if an alert message is printed above the prompt and their current input
52775305
text and cursor location is left alone.
52785306
52795307
IMPORTANT: This function will not print an alert unless it can acquire self.terminal_lock to ensure
5280-
a prompt is onscreen. Therefore, it is best to acquire the lock before calling this function
5308+
a prompt is on screen. Therefore, it is best to acquire the lock before calling this function
52815309
to guarantee the alert prints and to avoid raising a RuntimeError.
52825310
52835311
This function is only needed when you need to print an alert while the main thread is blocking
@@ -5309,20 +5337,21 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
53095337
if new_prompt is not None:
53105338
self.prompt = new_prompt
53115339

5312-
# Check if the prompt to display has changed from what's currently displayed
5313-
cur_onscreen_prompt = rl_get_prompt()
5314-
new_onscreen_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
5315-
5316-
if new_onscreen_prompt != cur_onscreen_prompt:
5317-
update_terminal = True
5340+
# We won't change readline's prompt while it is in search mode (e.g. Ctrl-r).
5341+
# Otherwise the new prompt will overwrite the onscreen search prompt.
5342+
if not rl_in_search_mode():
5343+
new_rl_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
5344+
if new_rl_prompt != rl_get_prompt():
5345+
update_terminal = True
5346+
rl_set_prompt(new_rl_prompt)
53185347

53195348
if update_terminal:
53205349
import shutil
53215350

5322-
# Generate the string which will replace the current prompt and input lines with the alert
5351+
# Print a string which replaces the current prompt and input lines with the alert.
53235352
terminal_str = ansi.async_alert_str(
53245353
terminal_columns=shutil.get_terminal_size().columns,
5325-
prompt=cur_onscreen_prompt,
5354+
prompt=rl_get_display_prompt(),
53265355
line=readline.get_line_buffer(),
53275356
cursor_offset=rl_get_point(),
53285357
alert_msg=alert_msg,
@@ -5333,9 +5362,6 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
53335362
elif rl_type == RlType.PYREADLINE:
53345363
readline.rl.mode.console.write(terminal_str)
53355364

5336-
# Update Readline's prompt before we redraw it
5337-
rl_set_prompt(new_onscreen_prompt)
5338-
53395365
# Redraw the prompt and input lines below the alert
53405366
rl_force_redisplay()
53415367

cmd2/rl_utils.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def rl_get_point() -> int: # pragma: no cover
200200

201201

202202
def rl_get_prompt() -> str: # pragma: no cover
203-
"""Gets Readline's current prompt"""
203+
"""Get Readline's prompt"""
204204
if rl_type == RlType.GNU:
205205
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_prompt").value
206206
if encoded_prompt is None:
@@ -221,6 +221,24 @@ def rl_get_prompt() -> str: # pragma: no cover
221221
return rl_unescape_prompt(prompt)
222222

223223

224+
def rl_get_display_prompt() -> str: # pragma: no cover
225+
"""
226+
Get Readline's currently displayed prompt.
227+
228+
In GNU Readline, the displayed prompt sometimes differs from the prompt.
229+
This occurs in functions that use the prompt string as a message area, such as incremental search.
230+
"""
231+
if rl_type == RlType.GNU:
232+
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_display_prompt").value
233+
if encoded_prompt is None:
234+
prompt = ''
235+
else:
236+
prompt = encoded_prompt.decode(encoding='utf-8')
237+
return rl_unescape_prompt(prompt)
238+
else:
239+
return rl_get_prompt()
240+
241+
224242
def rl_set_prompt(prompt: str) -> None: # pragma: no cover
225243
"""
226244
Sets Readline's prompt
@@ -237,7 +255,8 @@ def rl_set_prompt(prompt: str) -> None: # pragma: no cover
237255

238256

239257
def rl_escape_prompt(prompt: str) -> str:
240-
"""Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes
258+
"""
259+
Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes
241260
242261
:param prompt: original prompt
243262
:return: prompt safe to pass to GNU Readline
@@ -276,3 +295,32 @@ def rl_unescape_prompt(prompt: str) -> str:
276295
prompt = prompt.replace(escape_start, "").replace(escape_end, "")
277296

278297
return prompt
298+
299+
300+
def rl_in_search_mode() -> bool:
301+
"""Check if readline is doing either an incremental (e.g. Ctrl-r) or non-incremental (e.g. Esc-p) search"""
302+
if rl_type == RlType.GNU:
303+
# GNU Readline defines constants that we can use to determine if in search mode.
304+
# RL_STATE_ISEARCH 0x0000080
305+
# RL_STATE_NSEARCH 0x0000100
306+
IN_SEARCH_MODE = 0x0000180
307+
308+
readline_state = ctypes.c_int.in_dll(readline_lib, "rl_readline_state").value
309+
return bool(IN_SEARCH_MODE & readline_state)
310+
elif rl_type == RlType.PYREADLINE:
311+
from pyreadline3.modes.emacs import ( # type: ignore[import]
312+
EmacsMode,
313+
)
314+
315+
# These search modes only apply to Emacs mode, which is the default.
316+
if not isinstance(readline.rl.mode, EmacsMode):
317+
return False
318+
319+
# While in search mode, the current keyevent function is set one of the following.
320+
search_funcs = (
321+
readline.rl.mode._process_incremental_search_keyevent,
322+
readline.rl.mode._process_non_incremental_search_keyevent,
323+
)
324+
return readline.rl.mode.process_keyevent_queue[-1] in search_funcs
325+
else:
326+
return False

examples/async_printing.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,9 @@ def _alerter_thread_func(self) -> None:
189189
new_title = "Alerts Printed: {}".format(self._alert_count)
190190
self.set_window_title(new_title)
191191

192-
# No alerts needed to be printed, check if the prompt changed
193-
elif new_prompt != self.prompt:
192+
# There are no alerts to print, but we should still check
193+
# if the onscreen prompt needs to be refreshed.
194+
elif self.need_prompt_refresh(new_prompt):
194195
self.async_update_prompt(new_prompt)
195196

196197
# Don't forget to release the lock

0 commit comments

Comments
 (0)