diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3e85996124..e49134edde 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1963,7 +1963,9 @@ def _render_chops( chops: list[dict[int, Strip | None]] = [{} for _ in cuts] for y, x, _end in ChopsUpdate._span_cuts(spans, cuts, bounds.y): chops[y - bounds.y][x] = None - remaining = [len(line) for line in chops] + remaining = {row: len(line) for row, line in enumerate(chops) if line} + if not remaining: + return cast("Sequence[Mapping[int, Strip]]", chops) def render_regions(region: Region) -> Iterable[Region]: """Request exposed chop spans, coalesced across adjacent rows. @@ -1977,7 +1979,7 @@ def render_regions(region: Region) -> Iterable[Region]: for y in region.line_range: row = y - bounds.y spans: set[tuple[int, int]] = set() - if remaining[row]: + if row in remaining: start_x = None row_cuts = cuts[row] for index in range(bisect_left(row_cuts, first), bisect_left(row_cuts, last)): @@ -2022,7 +2024,16 @@ def render_regions(region: Region) -> Iterable[Region]: for cut, strip in zip(final_cuts, cut_strips): if cut in chops_line and chops_line[cut] is None: chops_line[cut] = strip - remaining[row] -= 1 + count = remaining[row] - 1 + if count: + remaining[row] = count + else: + del remaining[row] + # These are the original pending chop rows, not widget readiness. + # Once foreground paint has supplied every requested cut, no lower + # widget can contribute. Do not scan its covered exposure again. + if not remaining: + break return cast("Sequence[Mapping[int, Strip]]", chops) def __rich__(self) -> StripRenderable: diff --git a/tests/test_compositor_frame_damage.py b/tests/test_compositor_frame_damage.py index 6e9cddfcc3..dba0f4b48f 100644 --- a/tests/test_compositor_frame_damage.py +++ b/tests/test_compositor_frame_damage.py @@ -4,6 +4,43 @@ from textual.geometry import Size from textual.widgets import Static + +async def test_completed_chops_preserve_partial_and_held_paint(): + from textual.geometry import Region + + class CoveredApp(App): + CSS = """ + Screen { layers: back front; } + #back { layer: back; width: 100%; height: 100%; } + #front { layer: front; width: 100%; height: 100%; } + """ + + def compose(self): + yield Static("background", id="back") + yield Static("[bold]foreground 界[/bold]", id="front") + + app = CoveredApp() + async with app.run_test(size=(24, 8)) as pilot: + await pilot.pause() + compositor = app.screen._compositor + reference = compositor.render_strips() + assert "foreground" in reference[0].text + assert not any("background" in strip.text for strip in reference) + compositor._dirty_regions = {Region(0, 0, 24, 2)} + held = Region(3, 0, 5, 1) + update = compositor.render_partial_update(excluded_regions=(held,)) + assert update is not None + for y, left, right in update.spans: + assert not Region(left, y, right - left, 1).overlaps(held) + for x, strip in update._get_line_chops(y, left, right): + assert strip == reference[y].crop(x, x + strip.cell_length) + assert compositor._dirty_regions == {held} + released = compositor.render_partial_update() + assert released is not None + assert compositor._dirty_regions == set() + assert compositor.render_partial_update() is None + assert compositor.render_strips() == reference + @pytest.mark.parametrize('before,after', [(Size(139,40),Size(139,25)),(Size(80,40),Size(30,12)),(Size(30,12),Size(80,40))]) async def test_partial_damage_uses_current_frame_for_spans_and_chops(before,after): class FrameApp(App):