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

View File

@@ -6,6 +6,8 @@ Provides abstraction for camera motion in different modes:
- Horizontal: left/right movement
- Omni: combination of both
- Floating: sinusoidal/bobbing motion
The camera defines a visible viewport into a larger Canvas.
"""
import math
@@ -21,15 +23,32 @@ class CameraMode(Enum):
FLOATING = auto()
@dataclass
class CameraViewport:
"""Represents the visible viewport."""
x: int
y: int
width: int
height: int
@dataclass
class Camera:
"""Camera for viewport scrolling.
The camera defines a visible viewport into a Canvas.
It can be smaller than the canvas to allow scrolling,
and supports zoom to scale the view.
Attributes:
x: Current horizontal offset (positive = scroll left)
y: Current vertical offset (positive = scroll up)
mode: Current camera mode
speed: Base scroll speed
zoom: Zoom factor (1.0 = 100%, 2.0 = 200% zoom out)
canvas_width: Width of the canvas being viewed
canvas_height: Height of the canvas being viewed
custom_update: Optional custom update function
"""
@@ -37,9 +56,65 @@ class Camera:
y: int = 0
mode: CameraMode = CameraMode.VERTICAL
speed: float = 1.0
zoom: float = 1.0
canvas_width: int = 200 # Larger than viewport for scrolling
canvas_height: int = 200
custom_update: Callable[["Camera", float], None] | None = None
_time: float = field(default=0.0, repr=False)
@property
def w(self) -> int:
"""Shorthand for viewport_width."""
return self.viewport_width
@property
def h(self) -> int:
"""Shorthand for viewport_height."""
return self.viewport_height
@property
def viewport_width(self) -> int:
"""Get the visible viewport width.
This is the canvas width divided by zoom.
"""
return max(1, int(self.canvas_width / self.zoom))
@property
def viewport_height(self) -> int:
"""Get the visible viewport height.
This is the canvas height divided by zoom.
"""
return max(1, int(self.canvas_height / self.zoom))
def get_viewport(self) -> CameraViewport:
"""Get the current viewport bounds.
Returns:
CameraViewport with position and size (clamped to canvas bounds)
"""
vw = self.viewport_width
vh = self.viewport_height
clamped_x = max(0, min(self.x, self.canvas_width - vw))
clamped_y = max(0, min(self.y, self.canvas_height - vh))
return CameraViewport(
x=clamped_x,
y=clamped_y,
width=vw,
height=vh,
)
def set_zoom(self, zoom: float) -> None:
"""Set the zoom factor.
Args:
zoom: Zoom factor (1.0 = 100%, 2.0 = zoomed out 2x, 0.5 = zoomed in 2x)
"""
self.zoom = max(0.1, min(10.0, zoom))
def update(self, dt: float) -> None:
"""Update camera position based on mode.
@@ -61,6 +136,24 @@ class Camera:
elif self.mode == CameraMode.FLOATING:
self._update_floating(dt)
self._clamp_to_bounds()
def _clamp_to_bounds(self) -> None:
"""Clamp camera position to stay within canvas bounds.
Only clamps if the viewport is smaller than the canvas.
If viewport equals canvas (no scrolling needed), allows any position
for backwards compatibility with original behavior.
"""
vw = self.viewport_width
vh = self.viewport_height
# Only clamp if there's room to scroll
if vw < self.canvas_width:
self.x = max(0, min(self.x, self.canvas_width - vw))
if vh < self.canvas_height:
self.y = max(0, min(self.y, self.canvas_height - vh))
def _update_vertical(self, dt: float) -> None:
self.y += int(self.speed * dt * 60)
@@ -82,26 +175,42 @@ class Camera:
self.x = 0
self.y = 0
self._time = 0.0
self.zoom = 1.0
def set_canvas_size(self, width: int, height: int) -> None:
"""Set the canvas size and clamp position if needed.
Args:
width: New canvas width
height: New canvas height
"""
self.canvas_width = width
self.canvas_height = height
self._clamp_to_bounds()
@classmethod
def vertical(cls, speed: float = 1.0) -> "Camera":
"""Create a vertical scrolling camera."""
return cls(mode=CameraMode.VERTICAL, speed=speed)
return cls(mode=CameraMode.VERTICAL, speed=speed, canvas_height=200)
@classmethod
def horizontal(cls, speed: float = 1.0) -> "Camera":
"""Create a horizontal scrolling camera."""
return cls(mode=CameraMode.HORIZONTAL, speed=speed)
return cls(mode=CameraMode.HORIZONTAL, speed=speed, canvas_width=200)
@classmethod
def omni(cls, speed: float = 1.0) -> "Camera":
"""Create an omnidirectional scrolling camera."""
return cls(mode=CameraMode.OMNI, speed=speed)
return cls(
mode=CameraMode.OMNI, speed=speed, canvas_width=200, canvas_height=200
)
@classmethod
def floating(cls, speed: float = 1.0) -> "Camera":
"""Create a floating/bobbing camera."""
return cls(mode=CameraMode.FLOATING, speed=speed)
return cls(
mode=CameraMode.FLOATING, speed=speed, canvas_width=200, canvas_height=200
)
@classmethod
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":

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

View File

@@ -67,6 +67,18 @@ class Display(Protocol):
"""Shutdown display."""
...
def get_dimensions(self) -> tuple[int, int]:
"""Get current terminal dimensions.
Returns:
(width, height) in character cells
This method is called after show() to check if the display
was resized. The main loop should compare this to the current
viewport dimensions and update accordingly.
"""
...
class DisplayRegistry:
"""Registry for display backends with auto-discovery."""