Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1462c0f
Integrate reserved toolbar ownership across Stage 4 handoffs
tleonhardt Sep 12, 2026
c322cde
Keep the toolbar visible while the pager prepares its first frame
tleonhardt Sep 12, 2026
5b4acab
Guard POSIX suspend signal lookup on Windows
tleonhardt Sep 12, 2026
3354add
Add include_py to getting_started.py for ease of testing
tleonhardt Sep 12, 2026
1795cd9
Added raise_exception.py example pyscript for just testing a script t…
tleonhardt Sep 12, 2026
9649d4d
Keep terminal pipelines in the foreground job during suspend and resume
tleonhardt Sep 12, 2026
37645b5
Repaint reserved toolbar after minimum-height reacquisition
tleonhardt Sep 12, 2026
2b78110
Accept bash 5.1+ bracketed-paste output in the job-control test
tleonhardt Sep 12, 2026
c052401
Report from the job-control test's pager with os.write, not print
tleonhardt Sep 12, 2026
b1f80a6
Cover the reserved toolbar's remaining job-control and nested-prompt …
tleonhardt Sep 12, 2026
a785b96
Wait for the whole job to stop before typing at the shell in the job-…
tleonhardt Sep 12, 2026
c16e943
Fix forwarding of process-directed SIGINT to pipelines
tleonhardt Sep 12, 2026
ece4307
Avoid duplicate SIGINT delivery to terminal pipelines
tleonhardt Sep 12, 2026
4a79cb4
Fix POSIX pipeline signal races and speed up terminal tests
tleonhardt Sep 12, 2026
66365f5
Collect coverage from terminal test subprocesses
tleonhardt Sep 12, 2026
004f835
Cover terminal pipeline cleanup and resume paths
tleonhardt Sep 12, 2026
e327df6
Fix terminal input and wrapper job control in pipelines
tleonhardt Sep 14, 2026
b75e18e
Preserve ignored Ctrl-Z for session-led pipelines
tleonhardt Sep 14, 2026
a7a733f
Run shell producers inside terminal pipelines and isolate worker-thre…
tleonhardt Sep 14, 2026
d4566a0
Cover the shell command's fallback paths in terminal pipelines
tleonhardt Sep 14, 2026
0102c44
Keep the terminal lent across an interrupted pipeline write
tleonhardt Sep 14, 2026
7643b9a
Relay pipeline stops to the main thread with a thread-directed signal
tleonhardt Sep 14, 2026
d3b2630
Let the main thread relay a pipeline stop from a blocking write or wait
tleonhardt Sep 14, 2026
589b468
Keep the pipeline descriptor blocking for shell producers
tleonhardt Sep 14, 2026
f34262c
Added stage4_manual.py example for ease of testing
tleonhardt Sep 14, 2026
9f50afb
Stabilize orphaned-session pipeline terminal test
tleonhardt Sep 14, 2026
66513f6
Hold the display thread until a Ctrl-Z stop has taken it
tleonhardt Sep 15, 2026
b163a1e
Assert pipeline isolation without a platform branch in the toolbar test
tleonhardt Sep 15, 2026
416c6da
Cover both outcomes of the wait after sending SIGTSTP
tleonhardt Sep 15, 2026
72ae6e5
Lend the terminal to a pipeline while it starts up
tleonhardt Sep 20, 2026
7f574f5
Let a shell producer keep the terminal after its consumer exits
tleonhardt Sep 20, 2026
dc1a9df
Relay a shell producer's stop once its consumer is gone
tleonhardt Sep 20, 2026
11c3688
Send pager test keys from the application's event loop thread
tleonhardt Sep 20, 2026
4ce7c82
Merge branch 'main' into stage4-handoffs-integration
tleonhardt Sep 22, 2026
b20de83
Merge branch 'reserved_row_toolbar' into stage4-handoffs-integration
tleonhardt Sep 22, 2026
add0368
Start the startup-mode test's pager without site-packages
tleonhardt Sep 24, 2026
ea47295
Fall back from auto mode when prompt-toolkit's version is unknown
tleonhardt Sep 24, 2026
06608b0
Keep the command loop running when the toolbar display will not stop
tleonhardt Sep 24, 2026
a614316
Take the terminal back before retrying a shell command in cmd2's group
tleonhardt Sep 24, 2026
9be7a4d
Block SIGTTOU only while lending the terminal to a pipeline
tleonhardt Sep 24, 2026
2bee1d3
Hold cmd2's startup check open in the pager startup-mode test
tleonhardt Sep 24, 2026
de23939
Leave the application's Ctrl-Z handler as found when the reservation …
tleonhardt Sep 24, 2026
041785f
Take the terminal back only when the last overlapping lend ends
tleonhardt Sep 24, 2026
80ce6a2
Let Ctrl-C end the wait for a toolbar display that will not stop
tleonhardt Sep 24, 2026
d4b1fd3
Reset pipe state even when restoring redirected output fails
tleonhardt Sep 24, 2026
7f6b8dc
Catch Ctrl-C that lands while the stuck-toolbar notice is printing
tleonhardt Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Repaint reserved toolbar after minimum-height reacquisition
  • Loading branch information
tleonhardt committed Sep 12, 2026
commit 37645b59755a8795f7d4412fe85f40515ff6a263
13 changes: 11 additions & 2 deletions cmd2/toolbar_painter.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ def __init__(
self._autowrap_after_paint = autowrap_after_paint
self._last_frame: ToolbarFrame | None = None
self._last_attrs: Mapping[str, Attrs] | None = None
self._last_band: tuple[int, int, int, object] | None = None
self._last_band: tuple[int, int, int, object, int] | None = None
self._pending_error: BaseException | None = None

@property
Expand Down Expand Up @@ -438,7 +438,16 @@ def paint(self, prepared: PreparedFrame) -> bool:
return False

frame = prepared.frame
band = (geometry.physical_rows, geometry.columns, geometry.reserved_rows, geometry.buffer_id)
# Identical coordinates do not imply retained cells. A shrink below the
# reservation floor can erase the band, then growth can reacquire exactly
# the same dimensions and viewport. The new generation needs a full paint.
band = (
geometry.physical_rows,
geometry.columns,
geometry.reserved_rows,
geometry.buffer_id,
geometry.generation,
)
previous = self._last_frame if band == self._last_band else None
previous_attrs = self._last_attrs if previous is not None else None
runs = _changed_runs(previous, previous_attrs, frame, prepared.attrs)
Expand Down
109 changes: 109 additions & 0 deletions tests/test_reserved_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,76 @@ class TestResize:
attached, so the poll on ``output.get_size()`` is the only way a resize arrives there.
"""

@pytest.mark.parametrize("terminal_harness", [2, 3], indirect=True)
@pytest.mark.parametrize("text", ["", "typed"])
def test_minimum_height_reacquisition_through_idle_polling(self, terminal_harness, monkeypatch, text) -> None:
"""Fallback can erase a cached band before growth returns to exactly the same size."""
harness, terminal = terminal_harness
ui = harness.app.main_session.app
ui.terminal_size_polling_interval = 0.01
task = None
polls = 0
errors = []

async def until(predicate):
async with asyncio.timeout(5):
while not predicate(): # noqa: ASYNC110 - observe upstream state without driving a redraw
await asyncio.sleep(0.01)

async def transitions():
try:
if text:
harness.pipe.send_text(text)
await until(lambda: ui.current_buffer.text == text)
for rows, columns in ((3, 80), (2, 80), (3, 80), (4, 40), (24, 100), (3, 80), (2, 80), (3, 80)):
baseline = polls
# Let upstream establish/settle its size baseline before changing it.
await until(lambda baseline=baseline: polls >= baseline + 2)
if harness.size != Size(rows=rows, columns=columns):
resize(harness, terminal, rows, columns)
toolbar = harness.app.reserved_toolbar
await until(lambda toolbar=toolbar, rows=rows: toolbar.is_active == (rows >= 3))
if rows >= 3:
await until(lambda toolbar=toolbar, rows=rows: toolbar.display.geometry.physical_rows == rows)
await until(lambda: terminal.screen.display[-1].startswith("STATUS"))
await until(lambda: any(row.startswith("TEST> " + text) for row in terminal.screen.display))
assert ui.current_buffer.text == text
assert ui.current_buffer.cursor_position == len(text)
except Exception as error: # noqa: BLE001 - report outside prompt-toolkit's event loop
errors.append(error)
finally:
harness.pipe.send_text("\n")

def ready(app):
nonlocal task
if task is None:
task = ui.create_background_task(transitions())

ui.after_render += ready
try:
with harness.app._reserved_toolbar_context():
get_size = ui.output.get_size

def observe_size():
nonlocal polls
try:
current = asyncio.current_task()
except RuntimeError:
current = None
if current is not None and current.get_coro().__name__ == "_poll_output_size":
polls += 1
return get_size()

monkeypatch.setattr(ui.output, "get_size", observe_size)
assert harness.app._read_raw_input("TEST> ", harness.app.main_session) == text
assert task is not None
task.result()
if errors:
raise errors[0]
finally:
ui.after_render -= ready
assert terminal.screen.margins is None

@pytest.mark.parametrize("terminal_harness", [2], indirect=True)
def test_initially_short_prompt_grows_and_accepts_visible_input(self, terminal_harness) -> None:
harness, terminal = terminal_harness
Expand Down Expand Up @@ -330,6 +400,45 @@ def ready(app):
watchdog.join()
ui.after_render -= ready

@pytest.mark.parametrize("terminal_harness", [3], indirect=True)
@pytest.mark.parametrize("partial", ["", "PARTIAL"])
def test_quiet_command_reacquires_the_same_band(self, terminal_harness, monkeypatch, partial) -> None:
"""The command poll restores the whole band without disturbing unfinished output."""
harness, terminal = terminal_harness
harness.app.main_session.app.terminal_size_polling_interval = 0.01
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
toolbar = harness.app.reserved_toolbar
output = harness.app._command_toolbar.app.output
get_size = output.get_size
polls = 0

def observe_size():
nonlocal polls
try:
current = asyncio.current_task()
except RuntimeError:
current = None
if current is not None and current.get_coro().__name__ == "_poll_output_size":
polls += 1
return get_size()

monkeypatch.setattr(output, "get_size", observe_size)
harness.app.stdout.write(partial)
harness.app.stdout.flush()
for rows in (2, 3, 4, 3, 2, 3):
baseline = polls
assert wait_for(lambda baseline=baseline: polls >= baseline + 2)
resize(harness, terminal, rows, 80)
assert wait_for(lambda rows=rows: toolbar.is_active == (rows >= 3))
if rows >= 3:
assert wait_for(lambda rows=rows: toolbar.display.geometry.physical_rows == rows)
assert wait_for(lambda: terminal.screen.display[-1].startswith("STATUS"))
if partial:
assert any(row.startswith(partial) for row in terminal.screen.display)
harness.app.poutput(" COMPLETE")
assert any(row.startswith(partial + " COMPLETE") for row in terminal.screen.display)
assert terminal.screen.margins is None

@pytest.mark.parametrize("terminal_harness", [2], indirect=True)
def test_initially_short_terminal_acquires_during_a_quiet_command(self, terminal_harness, monkeypatch) -> None:
harness, terminal = terminal_harness
Expand Down
17 changes: 17 additions & 0 deletions tests/test_toolbar_painter.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,23 @@ def test_a_geometry_change_forces_a_full_repaint(self) -> None:
assert harness.paint("hi") is True
assert "\x1b[12;1Hhi " in harness.visible()

@pytest.mark.parametrize("prepare_while_inactive", [False, True])
def test_reacquiring_the_same_band_repaints_all_cells(self, prepare_while_inactive: bool) -> None:
"""Returning from fallback must not reuse cells painted before the reservation ended."""
harness = Harness(rows=3)
assert harness.paint("hi") is True
harness.display.resize(2)
assert not harness.display.is_reserved
if prepare_while_inactive:
assert harness.painter.prepare(lambda: "hi") is None
harness.display.resize(3)
harness.clear()
assert harness.paint("hi") is True
assert "\x1b[3;1Hhi " in harness.visible()
harness.clear()
assert harness.paint("hi") is False
assert harness.written() == ""

def test_empty_content_is_painted_rather_than_skipped(self) -> None:
"""An empty toolbar is an intentional visibility change and must reach the band."""
harness = Harness()
Expand Down
Loading