Skip to content

Commit ef04db1

Browse files
committed
Render the built-in pager in reserved toolbar mode
The reserved-mode command display suppresses its renderer frames, since it has nothing of its own to draw and a frame would scroll command output off the screen. But the built-in pager borrows that same display and does have a full screen to draw, so suppression left it painting nothing while its keys still worked -- `cat` in the example app ran, showed no output, and still quit on "q". The pager now lifts render suppression while it is on screen and restores it on the way out, so ordinary command output stays suppressed and the pager renders for real. The reserved-mode pager path had no test -- the existing pager tests run with the toolbar off -- so this adds one that drives the pager over a reservation and one for output that fits without paging. Validation: 2599 passed, 6 skipped with coverage; the mutation that drops the un-suppression fails the new test; make check, make test and make docs-test passed.
1 parent 61e7fd9 commit ef04db1

3 files changed

Lines changed: 59 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
- Command output that does not end in a newline (for example a progress line updated with a
1414
carriage return) is no longer erased by a reserved toolbar's redraw. The line in progress is
1515
preserved and the next write continues it.
16+
- The built-in pager (used by `Cmd.ppaged()`) again displays its content in reserved toolbar
17+
mode. It had rendered nothing while still accepting its keys, because the command display's
18+
renderer frames were being suppressed.
1619

1720
- Breaking Changes
1821
- Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default,

‎cmd2/command_toolbar.py‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -395,18 +395,19 @@ def _install_serializers(self) -> bool:
395395
# While the command display owns the terminal it has nothing of its own to draw, so its
396396
# renderer frames are suppressed: emitting one would reserve the usable height and
397397
# scroll command output off the screen. The toolbar is painted independently.
398-
if reserved.bridge is not None:
399-
reserved.bridge.set_render_suppressed(True)
398+
self._set_render_suppressed(True)
400399
return True
401400

402-
def _resume_rendering(self) -> None:
403-
"""Let the renderer emit its frames again, now the command display has given up the terminal.
401+
def _set_render_suppressed(self, suppressed: bool) -> None:
402+
"""Turn the renderer's own frames off while the command display owns the terminal.
404403
405-
The next frames belong to the main prompt, which must render for real.
404+
Off for ordinary command output, which the empty command display must not repaint; on
405+
again for the pager, whose full screen must render, and for the main prompt once the
406+
display has given the terminal back.
406407
"""
407408
bridge = self._reserved_bridge()
408409
if bridge is not None:
409-
bridge.set_render_suppressed(False)
410+
bridge.set_render_suppressed(suppressed)
410411

411412
def _app_exited(self) -> None:
412413
"""Give the terminal back to the streams when the display stops on its own.
@@ -420,7 +421,7 @@ def _app_exited(self) -> None:
420421
# A deliberate pause restores the streams itself, in the right order.
421422
return
422423

423-
self._resume_rendering()
424+
self._set_render_suppressed(False)
424425
with self._lock:
425426
# Leave self._proxy set so that the next _pause() still drains and closes
426427
# it. With the display gone, its worker writes to the terminal directly.
@@ -450,7 +451,7 @@ def _exit(self) -> None:
450451

451452
def _pause(self) -> None:
452453
self._pausing = True
453-
self._resume_rendering()
454+
self._set_render_suppressed(False)
454455
try:
455456
try:
456457
# Hold off other threads while the proxy drains so their output is never
@@ -622,6 +623,10 @@ def page(self, text: str, *, chop: bool) -> None:
622623
def enter() -> None:
623624
nonlocal entered
624625
entered = True
626+
# The pager has a full screen of its own to draw, so the render suppression that
627+
# keeps ordinary command frames from touching the terminal has to come off for the
628+
# duration -- otherwise the pager swaps in its layout and nothing is ever painted.
629+
self._set_render_suppressed(False)
625630
self.app.renderer.erase()
626631
self.app.layout = layout
627632
self.app.key_bindings = pager.bindings
@@ -638,6 +643,9 @@ def leave() -> None:
638643
self.app.layout, self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
639644
self.app.renderer.full_screen = self.app.full_screen
640645
self.app.renderer.request_absolute_cursor_position()
646+
# Back to ordinary command output, whose frames are suppressed again so the toolbar
647+
# stays put. Command finalization and the next prompt lift this in turn.
648+
self._set_render_suppressed(True)
641649
self.app.invalidate()
642650

643651
def close() -> None:

‎tests/test_reserved_terminal.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import threading
66
import time
7+
from concurrent.futures import ThreadPoolExecutor
78
from types import SimpleNamespace
89

910
import pyte
@@ -330,3 +331,42 @@ def test_a_carriage_return_progress_line_ends_on_its_final_value(self, terminal_
330331
harness.app.stdout.flush()
331332
assert terminal.screen.display[0].startswith("Progress: 100%")
332333
assert terminal.screen.display[-1].startswith("STATUS")
334+
335+
336+
class TestPager:
337+
"""The built-in pager renders a full screen of its own, so its frames must not be
338+
suppressed the way an ordinary command's empty frames are."""
339+
340+
def test_the_pager_draws_its_content_over_the_reserved_toolbar(self, terminal_harness) -> None:
341+
harness, terminal = terminal_harness
342+
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
343+
display = harness.app._command_toolbar
344+
body = "\n".join(f"row {index:03d}" for index in range(200))
345+
shown = threading.Event()
346+
347+
def drive() -> None:
348+
# Wait until the pager has painted its first screen, then quit it. Quit either
349+
# way, so a pager that never draws fails the assertion instead of hanging the
350+
# blocking page() call forever.
351+
try:
352+
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
353+
shown.set()
354+
finally:
355+
harness.pipe.send_text("q")
356+
357+
with ThreadPoolExecutor() as executor:
358+
future = executor.submit(drive)
359+
display.page(body, chop=False)
360+
future.result(timeout=5)
361+
362+
assert shown.is_set(), "the pager never drew its content"
363+
# The toolbar is suppressed again for ordinary output once the pager has closed.
364+
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
365+
assert terminal.screen.margins is None
366+
367+
def test_output_that_fits_is_printed_without_a_pager(self, terminal_harness) -> None:
368+
harness, terminal = terminal_harness
369+
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
370+
harness.app._command_toolbar.page("one short line", chop=False)
371+
assert any(row.startswith("one short line") for row in terminal.screen.display)
372+
assert terminal.screen.display[-1].startswith("STATUS")

0 commit comments

Comments
 (0)