Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 14 additions & 3 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)):
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_compositor_frame_damage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading