feat(pipeline): add Canvas and FontStage for rendering

- Add Canvas class for 2D surface management
- Add CanvasStage for pipeline integration
- Add FontStage as Transform for font rendering
- Update Camera with x, y, w, h, zoom and guardrails
- Add get_dimensions() to Display protocol
This commit is contained in:
2026-03-16 15:39:54 -07:00
parent 76126bdaac
commit bfd94fe046
3 changed files with 271 additions and 4 deletions

146
engine/canvas.py Normal file
View File

@@ -0,0 +1,146 @@
"""
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
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)
]
def clear(self) -> None:
"""Clear the entire canvas."""
self._grid = [[" " for _ in range(self.width)] for _ in range(self.height)]
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
"""
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
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
"""
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
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
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