Skip to content

Commit 39931f8

Browse files
committed
Printing all alerts at once to reduce flicker.
1 parent a8b38b1 commit 39931f8

File tree

2 files changed

+60
-37
lines changed

2 files changed

+60
-37
lines changed

cmd2/cmd2.py

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@
3535
import inspect
3636
import os
3737
import pydoc
38-
import queue
3938
import re
4039
import sys
4140
import tempfile
4241
import threading
4342
import time
4443
from code import InteractiveConsole
45-
from collections import namedtuple
44+
from collections import (
45+
deque,
46+
namedtuple,
47+
)
4648
from collections.abc import (
4749
Callable,
4850
Iterable,
@@ -286,10 +288,10 @@ class AsyncAlert:
286288
"""Contents of an asynchonous alert which display while user is at prompt.
287289
288290
:param msg: an optional message to be printed above the prompt.
289-
:param prompt: an optional string to dynamically replace the active prompt.
291+
:param prompt: an optional string to dynamically replace the current prompt.
290292
291293
:ivar timestamp: monotonic creation time of the alert. If an alert was created
292-
before the active prompt started, the prompt update is ignored
294+
before the current prompt was rendered, the prompt update is ignored
293295
to avoid a stale display but the msg will still be displayed.
294296
"""
295297

@@ -613,10 +615,10 @@ def __init__(
613615
self._command_parsers: _CommandParsers = _CommandParsers(self)
614616

615617
# Members related to printing asychronous alerts
616-
self._alert_queue: queue.Queue[AsyncAlert] = queue.Queue()
618+
self._alert_queue: deque[AsyncAlert] = deque()
617619
self._alert_condition = threading.Condition()
618620
self._alert_allowed = False
619-
self._alert_shutdown_event = threading.Event()
621+
self._alert_shutdown = False
620622
self._alert_thread: threading.Thread | None = None
621623
self._alert_prompt_timestamp: float = 0.0 # Uses time.monotonic()
622624

@@ -3358,35 +3360,57 @@ def read_input(
33583360

33593361
def _process_alerts(self) -> None:
33603362
"""Background worker that processes queued alerts and dynamic prompt updates."""
3361-
while not self._alert_shutdown_event.is_set():
3363+
while True:
33623364
with self._alert_condition:
3363-
# Wait until alerts are allowed and available, or shutdown is signaled.
3365+
# Wait until we have alerts and are allowed to display them, or shutdown is signaled.
33643366
self._alert_condition.wait_for(
3365-
lambda: (not self._alert_queue.empty() and self._alert_allowed) or self._alert_shutdown_event.is_set()
3367+
lambda: (len(self._alert_queue) > 0 and self._alert_allowed) or self._alert_shutdown
33663368
)
33673369

3368-
if self._alert_shutdown_event.is_set():
3370+
# Shutdown immediately even if we have alerts.
3371+
if self._alert_shutdown:
33693372
break
33703373

3371-
# Get the next alert while still holding the condition lock.
3372-
alert = self._alert_queue.get()
3374+
# Hold the condition lock while printing to block command execution. This
3375+
# prevents async alerts from printing once a command starts.
3376+
3377+
# Print all alerts at once to reduce flicker.
3378+
alert_text = "\n".join(alert.msg for alert in self._alert_queue if alert.msg)
3379+
3380+
# Find the latest prompt update among all pending alerts.
3381+
latest_prompt = None
3382+
for alert in reversed(self._alert_queue):
3383+
if (
3384+
alert.prompt is not None
3385+
and alert.prompt != self.prompt
3386+
and alert.timestamp > self._alert_prompt_timestamp
3387+
):
3388+
latest_prompt = alert.prompt
3389+
self._alert_prompt_timestamp = alert.timestamp
3390+
break
3391+
3392+
# Clear the alerts
3393+
self._alert_queue.clear()
3394+
3395+
if alert_text:
3396+
if not self._at_continuation_prompt and latest_prompt is not None:
3397+
# Update prompt now so patch_stdout can redraw it immediately.
3398+
self.prompt = latest_prompt
33733399

3374-
# Only apply prompt changes generated after the active prompt started.
3375-
prompt_updated = False
3376-
if (alert.prompt is not None and
3377-
alert.prompt != self.prompt and
3378-
alert.timestamp > self._alert_prompt_timestamp): # fmt: skip
3379-
self.prompt = alert.prompt
3380-
prompt_updated = True
3400+
# Print the alert messages above the prompt.
3401+
with patch_stdout():
3402+
print_formatted_text(pt_filter_style(alert_text))
33813403

3382-
if alert.msg:
3383-
# Print the message above the active prompt.
3384-
with patch_stdout():
3385-
print_formatted_text(pt_filter_style(alert.msg))
3404+
if self._at_continuation_prompt and latest_prompt is not None:
3405+
# Update state only. The onscreen prompt won't change until the next prompt starts.
3406+
self.prompt = latest_prompt
33863407

3387-
# Refresh UI immediately unless at a continuation prompt.
3388-
if prompt_updated and not self._at_continuation_prompt:
3389-
get_app().invalidate()
3408+
elif latest_prompt is not None:
3409+
self.prompt = latest_prompt
3410+
3411+
# Refresh UI immediately unless at a continuation prompt.
3412+
if not self._at_continuation_prompt:
3413+
get_app().invalidate()
33903414

33913415
def _read_command_line(self, prompt: str) -> str:
33923416
"""Read the next command line from the input stream.
@@ -3409,12 +3433,13 @@ def _pre_prompt() -> None:
34093433
"""Run standard pre-prompt processing and activate the background alerter."""
34103434
self.pre_prompt()
34113435

3412-
# Record when this prompt was started.
3436+
# Record when this prompt was rendered.
34133437
self._alert_prompt_timestamp = time.monotonic()
34143438

34153439
# Start alerter thread if it's not already running.
34163440
if self._alert_thread is None or not self._alert_thread.is_alive():
3417-
self._alert_shutdown_event.clear()
3441+
self._alert_allowed = False
3442+
self._alert_shutdown = False
34183443
self._alert_thread = threading.Thread(target=self._process_alerts, daemon=True)
34193444
self._alert_thread.start()
34203445

@@ -3465,7 +3490,7 @@ def _cmdloop(self) -> None:
34653490
# Shut down the alert thread.
34663491
if self._alert_thread is not None:
34673492
with self._alert_condition:
3468-
self._alert_shutdown_event.set()
3493+
self._alert_shutdown = True
34693494
self._alert_condition.notify_all()
34703495

34713496
# The thread is event-driven and stays suspended until notified.
@@ -5317,15 +5342,15 @@ def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> Non
53175342
add_alert(msg="Done", prompt="> ") # Update both
53185343
53195344
:param msg: an optional message to be printed above the prompt.
5320-
:param prompt: an optional string to dynamically replace the active prompt.
5345+
:param prompt: an optional string to dynamically replace the current prompt.
53215346
53225347
"""
53235348
if msg is None and prompt is None:
53245349
return
53255350

53265351
with self._alert_condition:
53275352
alert = AsyncAlert(msg=msg, prompt=prompt)
5328-
self._alert_queue.put(alert)
5353+
self._alert_queue.append(alert)
53295354
self._alert_condition.notify_all()
53305355

53315356
@staticmethod

tests/test_cmd2.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,7 +1222,7 @@ def test_async_alert(base_app, msg, prompt, is_stale) -> None:
12221222

12231223
with mock.patch('cmd2.cmd2.print_formatted_text') as mock_print:
12241224
base_app.add_alert(msg=msg, prompt=prompt)
1225-
alert = base_app._alert_queue.get()
1225+
alert = base_app._alert_queue[0]
12261226

12271227
# Stale means alert was created before the current prompt.
12281228
if is_stale:
@@ -1232,8 +1232,6 @@ def test_async_alert(base_app, msg, prompt, is_stale) -> None:
12321232
# In the future
12331233
alert.timestamp = time.monotonic() + 99999999
12341234

1235-
base_app._alert_queue.put(alert)
1236-
12371235
with create_pipe_input() as pipe_input:
12381236
base_app.session = PromptSession(
12391237
input=pipe_input,
@@ -1255,17 +1253,17 @@ def test_async_alert(base_app, msg, prompt, is_stale) -> None:
12551253

12561254

12571255
def test_add_alert(base_app) -> None:
1258-
orig_num_alerts = base_app._alert_queue.qsize()
1256+
orig_num_alerts = len(base_app._alert_queue)
12591257

12601258
# Nothing is added when both are None
12611259
base_app.add_alert(msg=None, prompt=None)
1262-
assert base_app._alert_queue.qsize() == orig_num_alerts
1260+
assert len(base_app._alert_queue) == orig_num_alerts
12631261

12641262
# Now test valid alert arguments
12651263
base_app.add_alert(msg="Hello", prompt=None)
12661264
base_app.add_alert(msg="Hello", prompt="prompt> ")
12671265
base_app.add_alert(msg=None, prompt="prompt> ")
1268-
assert base_app._alert_queue.qsize() == orig_num_alerts + 3
1266+
assert len(base_app._alert_queue) == orig_num_alerts + 3
12691267

12701268

12711269
class ShellApp(cmd2.Cmd):

0 commit comments

Comments
 (0)