- Add PartialUpdate dataclass and supports_partial_updates to EffectPlugin - Add dirty region tracking to Canvas (mark_dirty, get_dirty_rows, etc.) - Canvas auto-marks dirty on put_region, put_text, fill - CanvasStage exposes dirty rows via pipeline context - EffectChain creates PartialUpdate and calls process_partial() for optimized effects - HudEffect implements process_partial() to skip processing when rows 0-2 not dirty - This enables effects to skip work when canvas regions haven't changed
187 lines
5.8 KiB
Python
187 lines
5.8 KiB
Python
"""
|
|
Canvas - 2D surface for rendering.
|
|
|
|
The Canvas represents a full rendered surface that can be larger than the display.
|
|
The Camera then defines the visible viewport into this canvas.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass
|
|
class CanvasRegion:
|
|
"""A rectangular region on the canvas."""
|
|
|
|
x: int
|
|
y: int
|
|
width: int
|
|
height: int
|
|
|
|
def is_valid(self) -> bool:
|
|
"""Check if region has positive dimensions."""
|
|
return self.width > 0 and self.height > 0
|
|
|
|
def rows(self) -> set[int]:
|
|
"""Return set of row indices in this region."""
|
|
return set(range(self.y, self.y + self.height))
|
|
|
|
|
|
class Canvas:
|
|
"""2D canvas for rendering content.
|
|
|
|
The canvas is a 2D grid of cells that can hold text content.
|
|
It can be larger than the visible viewport (display).
|
|
|
|
Attributes:
|
|
width: Total width in characters
|
|
height: Total height in characters
|
|
"""
|
|
|
|
def __init__(self, width: int = 80, height: int = 24):
|
|
self.width = width
|
|
self.height = height
|
|
self._grid: list[list[str]] = [
|
|
[" " for _ in range(width)] for _ in range(height)
|
|
]
|
|
self._dirty_regions: list[CanvasRegion] = [] # Track dirty regions
|
|
|
|
def clear(self) -> None:
|
|
"""Clear the entire canvas."""
|
|
self._grid = [[" " for _ in range(self.width)] for _ in range(self.height)]
|
|
self._dirty_regions = [CanvasRegion(0, 0, self.width, self.height)]
|
|
|
|
def mark_dirty(self, x: int, y: int, width: int, height: int) -> None:
|
|
"""Mark a region as dirty (caller declares what they changed)."""
|
|
self._dirty_regions.append(CanvasRegion(x, y, width, height))
|
|
|
|
def get_dirty_regions(self) -> list[CanvasRegion]:
|
|
"""Get all dirty regions and clear the set."""
|
|
regions = self._dirty_regions
|
|
self._dirty_regions = []
|
|
return regions
|
|
|
|
def get_dirty_rows(self) -> set[int]:
|
|
"""Get union of all dirty rows."""
|
|
rows: set[int] = set()
|
|
for region in self._dirty_regions:
|
|
rows.update(region.rows())
|
|
return rows
|
|
|
|
def is_dirty(self) -> bool:
|
|
"""Check if any region is dirty."""
|
|
return len(self._dirty_regions) > 0
|
|
|
|
def get_region(self, x: int, y: int, width: int, height: int) -> list[list[str]]:
|
|
"""Get a rectangular region from the canvas.
|
|
|
|
Args:
|
|
x: Left position
|
|
y: Top position
|
|
width: Region width
|
|
height: Region height
|
|
|
|
Returns:
|
|
2D list of characters (height rows, width columns)
|
|
"""
|
|
region: list[list[str]] = []
|
|
for py in range(y, y + height):
|
|
row: list[str] = []
|
|
for px in range(x, x + width):
|
|
if 0 <= py < self.height and 0 <= px < self.width:
|
|
row.append(self._grid[py][px])
|
|
else:
|
|
row.append(" ")
|
|
region.append(row)
|
|
return region
|
|
|
|
def get_region_flat(self, x: int, y: int, width: int, height: int) -> list[str]:
|
|
"""Get a rectangular region as flat list of lines.
|
|
|
|
Args:
|
|
x: Left position
|
|
y: Top position
|
|
width: Region width
|
|
height: Region height
|
|
|
|
Returns:
|
|
List of strings (one per row)
|
|
"""
|
|
region = self.get_region(x, y, width, height)
|
|
return ["".join(row) for row in region]
|
|
|
|
def put_region(self, x: int, y: int, content: list[list[str]]) -> None:
|
|
"""Put content into a rectangular region on the canvas.
|
|
|
|
Args:
|
|
x: Left position
|
|
y: Top position
|
|
content: 2D list of characters to place
|
|
"""
|
|
height = len(content) if content else 0
|
|
width = len(content[0]) if height > 0 else 0
|
|
|
|
for py, row in enumerate(content):
|
|
for px, char in enumerate(row):
|
|
canvas_x = x + px
|
|
canvas_y = y + py
|
|
if 0 <= canvas_y < self.height and 0 <= canvas_x < self.width:
|
|
self._grid[canvas_y][canvas_x] = char
|
|
|
|
if width > 0 and height > 0:
|
|
self.mark_dirty(x, y, width, height)
|
|
|
|
def put_text(self, x: int, y: int, text: str) -> None:
|
|
"""Put a single line of text at position.
|
|
|
|
Args:
|
|
x: Left position
|
|
y: Row position
|
|
text: Text to place
|
|
"""
|
|
text_len = len(text)
|
|
for i, char in enumerate(text):
|
|
canvas_x = x + i
|
|
if 0 <= canvas_x < self.width and 0 <= y < self.height:
|
|
self._grid[y][canvas_x] = char
|
|
|
|
if text_len > 0:
|
|
self.mark_dirty(x, y, text_len, 1)
|
|
|
|
def fill(self, x: int, y: int, width: int, height: int, char: str = " ") -> None:
|
|
"""Fill a rectangular region with a character.
|
|
|
|
Args:
|
|
x: Left position
|
|
y: Top position
|
|
width: Region width
|
|
height: Region height
|
|
char: Character to fill with
|
|
"""
|
|
for py in range(y, y + height):
|
|
for px in range(x, x + width):
|
|
if 0 <= py < self.height and 0 <= px < self.width:
|
|
self._grid[py][px] = char
|
|
|
|
if width > 0 and height > 0:
|
|
self.mark_dirty(x, y, width, height)
|
|
|
|
def resize(self, width: int, height: int) -> None:
|
|
"""Resize the canvas.
|
|
|
|
Args:
|
|
width: New width
|
|
height: New height
|
|
"""
|
|
if width == self.width and height == self.height:
|
|
return
|
|
|
|
new_grid: list[list[str]] = [[" " for _ in range(width)] for _ in range(height)]
|
|
|
|
for py in range(min(self.height, height)):
|
|
for px in range(min(self.width, width)):
|
|
new_grid[py][px] = self._grid[py][px]
|
|
|
|
self.width = width
|
|
self.height = height
|
|
self._grid = new_grid
|