Skip to content

Commit 3062aaa

Browse files
authored
Fixed bug where async_alert() overwrites readline's incremental and non-incremental search prompts. (#1330)
1 parent 16c6d30 commit 3062aaa

File tree

6 files changed

+129
-52
lines changed

6 files changed

+129
-52
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()` and `Cmd.async_refresh_prompt()`.
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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,7 +1058,7 @@ def async_alert_str(*, terminal_columns: int, prompt: str, line: str, cursor_off
10581058
"""Calculate the desired string, including ANSI escape codes, for displaying an asynchronous alert message.
10591059
10601060
:param terminal_columns: terminal width (number of columns)
1061-
:param prompt: prompt that is displayed on the current line
1061+
:param prompt: current onscreen prompt
10621062
:param line: current contents of the Readline line buffer
10631063
:param cursor_offset: the offset of the current cursor position within line
10641064
:param alert_msg: the message to display to the user
@@ -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: 57 additions & 33 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,
@@ -353,6 +355,7 @@ def __init__(
353355
self.hidden_commands = ['eof', '_relative_run_script']
354356

355357
# Initialize history
358+
self.persistent_history_file = ''
356359
self._persistent_history_length = persistent_history_length
357360
self._initialize_history(persistent_history_file)
358361

@@ -3295,6 +3298,12 @@ def _set_up_cmd2_readline(self) -> _SavedReadlineSettings:
32953298
"""
32963299
readline_settings = _SavedReadlineSettings()
32973300

3301+
if rl_type == RlType.GNU:
3302+
# To calculate line count when printing async_alerts, we rely on commands wider than
3303+
# the terminal to wrap across multiple lines. The default for horizontal-scroll-mode
3304+
# is "off" but a user may have overridden it in their readline initialization file.
3305+
readline.parse_and_bind("set horizontal-scroll-mode off")
3306+
32983307
if self._completion_supported():
32993308
# Set up readline for our tab completion needs
33003309
if rl_type == RlType.GNU:
@@ -5273,16 +5282,16 @@ class TestMyAppCase(Cmd2TestCase):
52735282
def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
52745283
"""
52755284
Display an important message to the user while they are at a command line prompt.
5276-
To the user it appears as if an alert message is printed above the prompt and their current input
5277-
text and cursor location is left alone.
5285+
To the user it appears as if an alert message is printed above the prompt and their
5286+
current input text and cursor location is left alone.
52785287
5279-
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
5281-
to guarantee the alert prints and to avoid raising a RuntimeError.
5288+
This function needs to acquire self.terminal_lock to ensure a prompt is on screen.
5289+
Therefore, it is best to acquire the lock before calling this function to avoid
5290+
raising a RuntimeError.
52825291
5283-
This function is only needed when you need to print an alert while the main thread is blocking
5284-
at the prompt. Therefore, this should never be called from the main thread. Doing so will
5285-
raise a RuntimeError.
5292+
This function is only needed when you need to print an alert or update the prompt while the
5293+
main thread is blocking at the prompt. Therefore, this should never be called from the main
5294+
thread. Doing so will raise a RuntimeError.
52865295
52875296
:param alert_msg: the message to display to the user
52885297
:param new_prompt: If you also want to change the prompt that is displayed, then include it here.
@@ -5309,20 +5318,18 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
53095318
if new_prompt is not None:
53105319
self.prompt = new_prompt
53115320

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:
5321+
# Check if the onscreen prompt needs to be refreshed to match self.prompt.
5322+
if self.need_prompt_refresh():
53175323
update_terminal = True
5324+
rl_set_prompt(self.prompt)
53185325

53195326
if update_terminal:
53205327
import shutil
53215328

5322-
# Generate the string which will replace the current prompt and input lines with the alert
5329+
# Print a string which replaces the onscreen prompt and input lines with the alert.
53235330
terminal_str = ansi.async_alert_str(
53245331
terminal_columns=shutil.get_terminal_size().columns,
5325-
prompt=cur_onscreen_prompt,
5332+
prompt=rl_get_display_prompt(),
53265333
line=readline.get_line_buffer(),
53275334
cursor_offset=rl_get_point(),
53285335
alert_msg=alert_msg,
@@ -5333,9 +5340,6 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
53335340
elif rl_type == RlType.PYREADLINE:
53345341
readline.rl.mode.console.write(terminal_str)
53355342

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

@@ -5346,30 +5350,50 @@ def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:
53465350

53475351
def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
53485352
"""
5349-
Update the command line prompt while the user is still typing at it. This is good for alerting the user to
5350-
system changes dynamically in between commands. For instance you could alter the color of the prompt to
5351-
indicate a system status or increase a counter to report an event. If you do alter the actual text of the
5352-
prompt, it is best to keep the prompt the same width as what's on screen. Otherwise the user's input text will
5353-
be shifted and the update will not be seamless.
5353+
Update the command line prompt while the user is still typing at it.
53545354
5355-
IMPORTANT: This function will not update the prompt unless it can acquire self.terminal_lock to ensure
5356-
a prompt is onscreen. Therefore, it is best to acquire the lock before calling this function
5357-
to guarantee the prompt changes and to avoid raising a RuntimeError.
5355+
This is good for alerting the user to system changes dynamically in between commands.
5356+
For instance you could alter the color of the prompt to indicate a system status or increase a
5357+
counter to report an event. If you do alter the actual text of the prompt, it is best to keep
5358+
the prompt the same width as what's on screen. Otherwise the user's input text will be shifted
5359+
and the update will not be seamless.
53585360
5359-
This function is only needed when you need to update the prompt while the main thread is blocking
5360-
at the prompt. Therefore, this should never be called from the main thread. Doing so will
5361-
raise a RuntimeError.
5362-
5363-
If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5364-
not change. However, self.prompt will still be updated and display immediately after the multiline
5365-
line command completes.
5361+
If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5362+
not change. However, self.prompt will still be updated and display immediately after the multiline
5363+
line command completes.
53665364
53675365
:param new_prompt: what to change the prompt to
53685366
:raises RuntimeError: if called from the main thread.
53695367
:raises RuntimeError: if called while another thread holds `terminal_lock`
53705368
"""
53715369
self.async_alert('', new_prompt)
53725370

5371+
def async_refresh_prompt(self) -> None: # pragma: no cover
5372+
"""
5373+
Refresh the oncreen prompt to match self.prompt.
5374+
5375+
One case where the onscreen prompt and self.prompt can get out of sync is
5376+
when async_alert() is called while a user is in search mode (e.g. Ctrl-r).
5377+
To prevent overwriting readline's onscreen search prompt, self.prompt is updated
5378+
but readline's saved prompt isn't.
5379+
5380+
Therefore when a user aborts a search, the old prompt is still on screen until they
5381+
press Enter or this method is called. Call need_prompt_refresh() in an async print
5382+
thread to know when a refresh is needed.
5383+
5384+
:raises RuntimeError: if called from the main thread.
5385+
:raises RuntimeError: if called while another thread holds `terminal_lock`
5386+
"""
5387+
self.async_alert('')
5388+
5389+
def need_prompt_refresh(self) -> bool: # pragma: no cover
5390+
"""Check whether the onscreen prompt needs to be asynchronously refreshed to match self.prompt."""
5391+
if not (vt100_support and self.use_rawinput):
5392+
return False
5393+
5394+
# Don't overwrite a readline search prompt or a continuation prompt.
5395+
return not rl_in_search_mode() and not self._at_continuation_prompt and self.prompt != rl_get_prompt()
5396+
53735397
@staticmethod
53745398
def set_window_title(title: str) -> None: # pragma: no cover
53755399
"""

cmd2/rl_utils.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,6 @@ def enable_win_vt100(handle: HANDLE) -> bool:
106106
############################################################################################################
107107
# pyreadline3 is incomplete in terms of the Python readline API. Add the missing functions we need.
108108
############################################################################################################
109-
# readline.redisplay()
110-
try:
111-
getattr(readline, 'redisplay')
112-
except AttributeError:
113-
readline.redisplay = readline.rl.mode._update_line
114-
115109
# readline.remove_history_item()
116110
try:
117111
getattr(readline, 'remove_history_item')
@@ -200,7 +194,7 @@ def rl_get_point() -> int: # pragma: no cover
200194

201195

202196
def rl_get_prompt() -> str: # pragma: no cover
203-
"""Gets Readline's current prompt"""
197+
"""Get Readline's prompt"""
204198
if rl_type == RlType.GNU:
205199
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_prompt").value
206200
if encoded_prompt is None:
@@ -221,6 +215,24 @@ def rl_get_prompt() -> str: # pragma: no cover
221215
return rl_unescape_prompt(prompt)
222216

223217

218+
def rl_get_display_prompt() -> str: # pragma: no cover
219+
"""
220+
Get Readline's currently displayed prompt.
221+
222+
In GNU Readline, the displayed prompt sometimes differs from the prompt.
223+
This occurs in functions that use the prompt string as a message area, such as incremental search.
224+
"""
225+
if rl_type == RlType.GNU:
226+
encoded_prompt = ctypes.c_char_p.in_dll(readline_lib, "rl_display_prompt").value
227+
if encoded_prompt is None:
228+
prompt = ''
229+
else:
230+
prompt = encoded_prompt.decode(encoding='utf-8')
231+
return rl_unescape_prompt(prompt)
232+
else:
233+
return rl_get_prompt()
234+
235+
224236
def rl_set_prompt(prompt: str) -> None: # pragma: no cover
225237
"""
226238
Sets Readline's prompt
@@ -237,7 +249,8 @@ def rl_set_prompt(prompt: str) -> None: # pragma: no cover
237249

238250

239251
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
252+
"""
253+
Overcome bug in GNU Readline in relation to calculation of prompt length in presence of ANSI escape codes
241254
242255
:param prompt: original prompt
243256
:return: prompt safe to pass to GNU Readline
@@ -276,3 +289,32 @@ def rl_unescape_prompt(prompt: str) -> str:
276289
prompt = prompt.replace(escape_start, "").replace(escape_end, "")
277290

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

docs/features/prompt.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ PythonScripting_ for an example of dynamically updating the prompt.
3939
Asynchronous Feedback
4040
---------------------
4141

42-
``cmd2`` provides two functions to provide asynchronous feedback to the user
42+
``cmd2`` provides these functions to provide asynchronous feedback to the user
4343
without interfering with the command line. This means the feedback is provided
4444
to the user when they are still entering text at the prompt. To use this
4545
functionality, the application must be running in a terminal that supports
@@ -52,6 +52,12 @@ all support these.
5252
.. automethod:: cmd2.Cmd.async_update_prompt
5353
:noindex:
5454

55+
.. automethod:: cmd2.Cmd.async_refresh_prompt
56+
:noindex:
57+
58+
.. automethod:: cmd2.Cmd.need_prompt_refresh
59+
:noindex:
60+
5561
``cmd2`` also provides a function to change the title of the terminal window.
5662
This feature requires the application be running in a terminal that supports
5763
VT100 control characters. Linux, Mac, and Windows 10 and greater all support
@@ -64,5 +70,3 @@ The easiest way to understand these functions is to see the AsyncPrinting_
6470
example for a demonstration.
6571

6672
.. _AsyncPrinting: https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py
67-
68-

examples/async_printing.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,8 @@ def _alerter_thread_func(self) -> None:
173173
self._next_alert_time = 0
174174

175175
while not self._stop_event.is_set():
176-
# Always acquire terminal_lock before printing alerts or updating the prompt
177-
# To keep the app responsive, do not block on this call
176+
# Always acquire terminal_lock before printing alerts or updating the prompt.
177+
# To keep the app responsive, do not block on this call.
178178
if self.terminal_lock.acquire(blocking=False):
179179
# Get any alerts that need to be printed
180180
alert_str = self._generate_alert_str()
@@ -189,10 +189,13 @@ 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+
# Otherwise check if the prompt needs to be updated or refreshed
193+
elif self.prompt != new_prompt:
194194
self.async_update_prompt(new_prompt)
195195

196+
elif self.need_prompt_refresh():
197+
self.async_refresh_prompt()
198+
196199
# Don't forget to release the lock
197200
self.terminal_lock.release()
198201

0 commit comments

Comments
 (0)