diff --git a/effects_plugins/hud.py b/effects_plugins/hud.py index 6f014af..dcc5677 100644 --- a/effects_plugins/hud.py +++ b/effects_plugins/hud.py @@ -1,9 +1,35 @@ -from engine.effects.types import EffectConfig, EffectContext, EffectPlugin +from engine.effects.types import ( + EffectConfig, + EffectContext, + EffectPlugin, + PartialUpdate, +) class HudEffect(EffectPlugin): name = "hud" config = EffectConfig(enabled=True, intensity=1.0) + supports_partial_updates = True # Enable partial update optimization + + # Cache last HUD content to detect changes + _last_hud_content: tuple | None = None + + def process_partial( + self, buf: list[str], ctx: EffectContext, partial: PartialUpdate + ) -> list[str]: + # If full buffer requested, process normally + if partial.full_buffer: + return self.process(buf, ctx) + + # If HUD rows (0, 1, 2) aren't dirty, skip processing + if partial.dirty: + hud_rows = {0, 1, 2} + dirty_hud_rows = partial.dirty & hud_rows + if not dirty_hud_rows: + return buf # Nothing for HUD to do + + # Proceed with full processing + return self.process(buf, ctx) def process(self, buf: list[str], ctx: EffectContext) -> list[str]: result = list(buf) diff --git a/engine/canvas.py b/engine/canvas.py index f8d70a1..9341223 100644 --- a/engine/canvas.py +++ b/engine/canvas.py @@ -21,6 +21,10 @@ class CanvasRegion: """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. @@ -39,10 +43,33 @@ class Canvas: 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. @@ -90,6 +117,9 @@ class Canvas: 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 @@ -97,6 +127,9 @@ class Canvas: 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. @@ -105,11 +138,15 @@ class Canvas: 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. @@ -125,6 +162,9 @@ class Canvas: 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. diff --git a/engine/effects/chain.py b/engine/effects/chain.py index c687266..bb20587 100644 --- a/engine/effects/chain.py +++ b/engine/effects/chain.py @@ -2,7 +2,7 @@ import time from engine.effects.performance import PerformanceMonitor, get_monitor from engine.effects.registry import EffectRegistry -from engine.effects.types import EffectContext +from engine.effects.types import EffectContext, PartialUpdate class EffectChain: @@ -51,6 +51,18 @@ class EffectChain: frame_number = ctx.frame_number monitor.start_frame(frame_number) + # Get dirty regions from canvas via context (set by CanvasStage) + dirty_rows = ctx.get_state("canvas.dirty_rows") + + # Create PartialUpdate for effects that support it + full_buffer = dirty_rows is None or len(dirty_rows) == 0 + partial = PartialUpdate( + rows=None, + cols=None, + dirty=dirty_rows, + full_buffer=full_buffer, + ) + frame_start = time.perf_counter() result = list(buf) for name in self._order: @@ -59,7 +71,11 @@ class EffectChain: chars_in = sum(len(line) for line in result) effect_start = time.perf_counter() try: - result = plugin.process(result, ctx) + # Use process_partial if supported, otherwise fall back to process + if getattr(plugin, "supports_partial_updates", False): + result = plugin.process_partial(result, ctx, partial) + else: + result = plugin.process(result, ctx) except Exception: plugin.config.enabled = False elapsed = time.perf_counter() - effect_start diff --git a/engine/effects/types.py b/engine/effects/types.py index 128d0bc..4486a5f 100644 --- a/engine/effects/types.py +++ b/engine/effects/types.py @@ -23,6 +23,25 @@ from dataclasses import dataclass, field from typing import Any +@dataclass +class PartialUpdate: + """Represents a partial buffer update for optimized rendering. + + Instead of processing the full buffer every frame, effects that support + partial updates can process only changed regions. + + Attributes: + rows: Row indices that changed (None = all rows) + cols: Column range that changed (None = full width) + dirty: Set of dirty row indices + """ + + rows: tuple[int, int] | None = None # (start, end) inclusive + cols: tuple[int, int] | None = None # (start, end) inclusive + dirty: set[int] | None = None # Set of dirty row indices + full_buffer: bool = True # If True, process entire buffer + + @dataclass class EffectContext: terminal_width: int @@ -101,6 +120,7 @@ class EffectPlugin(ABC): name: str config: EffectConfig param_bindings: dict[str, dict[str, str | float]] = {} + supports_partial_updates: bool = False # Override in subclasses for optimization @abstractmethod def process(self, buf: list[str], ctx: EffectContext) -> list[str]: @@ -115,6 +135,25 @@ class EffectPlugin(ABC): """ ... + def process_partial( + self, buf: list[str], ctx: EffectContext, partial: PartialUpdate + ) -> list[str]: + """Process a partial buffer for optimized rendering. + + Override this in subclasses that support partial updates for performance. + Default implementation falls back to full buffer processing. + + Args: + buf: List of lines to process + ctx: Effect context with terminal state + partial: PartialUpdate indicating which regions changed + + Returns: + Processed buffer (may be same object or new list) + """ + # Default: fall back to full processing + return self.process(buf, ctx) + @abstractmethod def configure(self, config: EffectConfig) -> None: """Configure the effect with new settings. diff --git a/engine/pipeline/adapters.py b/engine/pipeline/adapters.py index 388dde7..b69d74a 100644 --- a/engine/pipeline/adapters.py +++ b/engine/pipeline/adapters.py @@ -530,6 +530,14 @@ class CanvasStage(Stage): self._canvas = Canvas(width=self._width, height=self._height) ctx.set("canvas", self._canvas) + + # Get dirty regions from canvas and expose via context + # Effects can access via ctx.get_state("canvas.dirty_rows") + if self._canvas.is_dirty(): + dirty_rows = self._canvas.get_dirty_rows() + ctx.set_state("canvas.dirty_rows", dirty_rows) + ctx.set_state("canvas.dirty_regions", self._canvas.get_dirty_regions()) + return data def get_canvas(self):