Skip to content

Commit 98e62d5

Browse files
committed
Move displaced output out of the band when a resize shrinks onto it
A terminal that shrinks can leave the command's cursor, and the line it is writing, on a row the new toolbar band takes. Skipping recovery during a command kept that cursor where it was, so the band was painted over the unfinished line and the next write landed inside the toolbar row. reconfigure() now treats a size change as the transition it is: it resets to full-screen margins, clears a grown terminal's stale band row, and makes room for the new band -- indexing at the bottom scrolls the displaced output up into the usable region and moves the cursor with it, column intact -- before installing the region. The band is then painted over rows that hold nothing the command still needs. The emulated terminal used by these tests gains a shrink that keeps the cursor's row on screen, as xterm and VTE do; pyte's own clips inside the scroll margins and drops that row, which is not what a real terminal shows. The regression test writes past the point the new band will take, resizes, and checks the line, its cursor column, the toolbar row and the absence of output in the band. Validation: 2605 passed, 6 skipped with coverage, twice; the reserved modules at 100% line coverage; removing the room-making step fails the new test. Harness acceptance and dynamic gates PASS at 12, 24 and 40 rows, 23/23 observer controls. make check, make test and make docs-test passed.
1 parent 974290b commit 98e62d5

4 files changed

Lines changed: 67 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
wrapping to the old width. A stale toolbar is no longer left behind when the terminal grows.
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, its shutdown, or a
15-
terminal resize. The line in progress is preserved and the next write continues it.
15+
terminal resize. The line in progress is preserved and the next write continues it. When
16+
shrinking leaves that line in the rows the toolbar takes, the line and its cursor are moved up
17+
into the usable region before the toolbar is painted.
1618
- Reserved rendering is no longer left suppressed, and the main prompt invisible, when a
1719
terminal that shrank below the two-row minimum during a command grows back afterward.
1820
- The built-in pager (used by `Cmd.ppaged()`) again displays its content in reserved toolbar

cmd2/terminal_display.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -465,13 +465,23 @@ def reconfigure(self) -> bool:
465465
# The guest may have scrolled to the physical bottom, just as the shell
466466
# that launched us may have done before the initial acquisition.
467467
self._terminal.make_room_for_region(geometry)
468-
elif geometry.physical_rows > previous.physical_rows:
469-
# The terminal grew, so the rows the old band occupied are now inside the screen
470-
# and still hold its stale text. They never scrolled -- the band sits outside the
471-
# scroll region -- so those rows hold nothing but the old toolbar and are safe to
472-
# clear before the new region is installed. A terminal that shrank instead pushed
473-
# the old band off the bottom, so there is nothing left to clear.
474-
self._terminal.erase_rows(previous.usable_rows + 1, previous.physical_rows)
468+
elif geometry.physical_size != previous.physical_size:
469+
# A resize. The terminal may have reset its margins or kept the old ones; either
470+
# way the old region no longer describes the screen, so start from full-screen
471+
# margins -- which is also what making room needs, so that an index at the bottom
472+
# scrolls the whole screen rather than an obsolete region.
473+
self._terminal.release_region()
474+
if geometry.physical_rows > previous.physical_rows:
475+
# The terminal grew, so the rows the old band occupied are now inside the
476+
# screen and still hold its stale text. They never scrolled -- the band sits
477+
# outside the scroll region -- so they hold nothing but the old toolbar and
478+
# are safe to clear before the new region is installed.
479+
self._terminal.erase_rows(previous.usable_rows + 1, previous.physical_rows)
480+
# A terminal that shrank may have left the cursor, and the output on its row, in
481+
# the rows the new band will take. Making room scrolls that output up into the
482+
# usable region and moves the cursor with it, column intact, before the band is
483+
# installed over those rows and painted.
484+
self._terminal.make_room_for_region(geometry)
475485
self._terminal.install_region(geometry)
476486
self._geometry = geometry
477487
if self._adapter is None:

tests/test_reserved_terminal.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,23 @@ def restore_cursor(self) -> None:
3434
finally:
3535
self.margins = margins
3636

37+
def resize(self, lines: int | None = None, columns: int | None = None) -> None:
38+
"""Shrink by clipping the bottom while the cursor stays on screen.
39+
40+
pyte clips from the top, inside the scroll margins, which drops the cursor's own
41+
line. xterm and VTE keep the cursor's line visible: with the cursor above the new
42+
bottom they clip the rows below it, which is the case these tests exercise. The
43+
band's old row goes with those rows, as it does on a real terminal.
44+
"""
45+
lines = lines or self.lines
46+
if lines < self.lines and self.cursor.y < lines:
47+
for y in range(lines, self.lines):
48+
self.buffer.pop(y, None)
49+
self.lines = lines
50+
self.dirty.update(range(lines))
51+
self.set_margins()
52+
super().resize(lines=lines, columns=columns)
53+
3754

3855
class EmulatedTerminal(io.StringIO):
3956
"""Parse output and answer CPR where the cursor actually is when the query arrives."""
@@ -336,6 +353,32 @@ def test_partial_output_survives_a_resize(self, terminal_harness) -> None:
336353
assert terminal.screen.display[0].startswith("PARTIALEND")
337354
assert terminal.screen.display[-1].startswith("STATUS")
338355

356+
def test_partial_output_on_a_row_the_shrunk_band_takes_moves_up_with_its_cursor(self, terminal_harness) -> None:
357+
"""Shrinking can leave the cursor's row inside the new band. The output there and the
358+
cursor move up into the usable region, column intact, before the band is painted."""
359+
harness, terminal = terminal_harness
360+
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
361+
ui = harness.app._command_toolbar.app
362+
harness.app.stdout.write("out\n" * 11 + "PARTIAL")
363+
harness.app.stdout.flush()
364+
assert (terminal.screen.cursor.x, terminal.screen.cursor.y + 1) == (len("PARTIAL"), 12)
365+
resize(harness, terminal, 12, 80)
366+
ui.loop.call_soon_threadsafe(ui._on_resize)
367+
assert wait_for(lambda: terminal.screen.margins == pyte.screens.Margins(0, 10))
368+
assert wait_for(lambda: terminal.screen.display[-1].startswith("STATUS"))
369+
# Row 12 is the band now; the line and its cursor were scrolled up to row 11.
370+
assert (terminal.screen.cursor.x, terminal.screen.cursor.y + 1) == (len("PARTIAL"), 11)
371+
assert terminal.screen.display[10].startswith("PARTIAL")
372+
harness.app.stdout.write("END\n")
373+
harness.app.stdout.flush()
374+
# The newline at the bottom of the usable region scrolls it, so the finished line
375+
# sits one row up; it must be complete, and nothing may have landed in the band.
376+
usable = terminal.screen.display[:-1]
377+
assert any(row.startswith("PARTIALEND") for row in usable)
378+
assert terminal.screen.display[-1].startswith("STATUS")
379+
assert "END" not in terminal.screen.display[-1]
380+
assert sum(row.startswith("STATUS") for row in terminal.screen.display) == 1
381+
339382
def test_partial_output_survives_the_command_display_shutdown(self, terminal_harness) -> None:
340383
"""Leaving the command context stops the empty display, whose shutdown must not erase
341384
the command output still on the line."""

tests/test_terminal_display.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,10 @@ def test_reconfigure_installs_the_region_for_the_new_height(self) -> None:
298298
screen.rows = 40
299299
stream.truncate(0), stream.seek(0)
300300
assert display.reconfigure()
301-
# Growing brings the old band's row back inside the screen, so its stale toolbar is
302-
# erased before the new region is installed.
303-
assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[2K\x1b8\x1b7\x1b[1;39r\x1b8"
301+
# A resize starts from full-screen margins, erases the old band's row -- growing has
302+
# brought it back inside the screen with its stale toolbar -- makes room for the new
303+
# band, and installs the new region.
304+
assert stream.getvalue() == "\x1b7\x1b[r\x1b8\x1b7\x1b[24;1H\x1b[2K\x1b8\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8"
304305

305306
def test_a_width_change_alone_is_still_a_new_generation(self) -> None:
306307
"""Toolbar height is measured against the width, so a rewrap can change the reservation."""

0 commit comments

Comments
 (0)