From bfd94fe046ddd95d4af0e450e289d4294eee57d7 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 16 Mar 2026 15:39:54 -0700 Subject: [PATCH] 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 --- engine/camera.py | 117 ++++++++++++++++++++++++++++- engine/canvas.py | 146 +++++++++++++++++++++++++++++++++++++ engine/display/__init__.py | 12 +++ 3 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 engine/canvas.py diff --git a/engine/camera.py b/engine/camera.py index 12d95f2..7d55800 100644 --- a/engine/camera.py +++ b/engine/camera.py @@ -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": diff --git a/engine/canvas.py b/engine/canvas.py new file mode 100644 index 0000000..f8d70a1 --- /dev/null +++ b/engine/canvas.py @@ -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 diff --git a/engine/display/__init__.py b/engine/display/__init__.py index 2e1a599..ed72a15 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -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."""