diff --git a/engine/display/backends/null.py b/engine/display/backends/null.py index bf9a16e..392127c 100644 --- a/engine/display/backends/null.py +++ b/engine/display/backends/null.py @@ -33,10 +33,25 @@ class NullDisplay: self._last_buffer = None def show(self, buffer: list[str], border: bool = False) -> None: - from engine.display import get_monitor + from engine.display import get_monitor, render_border + + # Get FPS for border (if available) + fps = 0.0 + frame_time = 0.0 + monitor = get_monitor() + if monitor: + stats = monitor.get_stats() + avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0 + frame_count = stats.get("frame_count", 0) if stats else 0 + if avg_ms and frame_count > 0: + fps = 1000.0 / avg_ms + frame_time = avg_ms + + # Apply border if requested (same as terminal display) + if border: + buffer = render_border(buffer, self.width, self.height, fps, frame_time) self._last_buffer = buffer - monitor = get_monitor() if monitor: t0 = time.perf_counter() chars_in = sum(len(line) for line in buffer) diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py index a2bc4b6..e0d2773 100644 --- a/engine/display/backends/pygame.py +++ b/engine/display/backends/pygame.py @@ -41,6 +41,7 @@ class PygameDisplay: self._quit_requested = False self._last_frame_time = 0.0 self._frame_period = 1.0 / target_fps if target_fps > 0 else 0 + self._glyph_cache = {} def _get_font_path(self) -> str | None: """Get font path for rendering.""" @@ -191,6 +192,8 @@ class PygameDisplay: self._screen.fill((0, 0, 0)) + blit_list = [] + for row_idx, line in enumerate(buffer[: self.height]): if row_idx >= self.height: break @@ -202,15 +205,24 @@ class PygameDisplay: if not text: continue - if bg != (0, 0, 0): - bg_surface = self._font.render(text, True, fg, bg) - self._screen.blit(bg_surface, (x_pos, row_idx * self.cell_height)) - else: - text_surface = self._font.render(text, True, fg) - self._screen.blit(text_surface, (x_pos, row_idx * self.cell_height)) + # Use None as key for no background + bg_key = bg if bg != (0, 0, 0) else None + cache_key = (text, fg, bg_key) + if cache_key not in self._glyph_cache: + # Render and cache + if bg_key is not None: + self._glyph_cache[cache_key] = self._font.render( + text, True, fg, bg_key + ) + else: + self._glyph_cache[cache_key] = self._font.render(text, True, fg) + + surface = self._glyph_cache[cache_key] + blit_list.append((surface, (x_pos, row_idx * self.cell_height))) x_pos += self._font.size(text)[0] + self._screen.blits(blit_list) self._pygame.display.flip() elapsed_ms = (time.perf_counter() - t0) * 1000 diff --git a/tests/test_display.py b/tests/test_display.py index 1ed2b45..a9980ce 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -3,9 +3,11 @@ Tests for engine.display module. """ import sys -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay +import pytest + +from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay, render_border from engine.display.backends.multi import MultiDisplay @@ -133,7 +135,6 @@ class TestTerminalDisplay: The fix adds \\033[H\\033[J (cursor home + erase down) before each frame. """ from io import BytesIO - from unittest.mock import patch display = TerminalDisplay() display.init(80, 24) @@ -157,7 +158,6 @@ class TestTerminalDisplay: Regression test: Ensures each show() call includes the clear sequence. """ from io import BytesIO - from unittest.mock import patch # Use target_fps=0 to disable frame skipping in test display = TerminalDisplay(target_fps=0) @@ -193,6 +193,40 @@ class TestTerminalDisplay: # returns different values each call assert len(set(dims)) == 1, f"Dimensions should be stable, got: {set(dims)}" + def test_show_with_border_uses_render_border(self): + """show with border=True calls render_border with FPS.""" + from unittest.mock import MagicMock + + display = TerminalDisplay() + display.init(80, 24) + + buffer = ["line1", "line2"] + + # Mock get_monitor to provide FPS + mock_monitor = MagicMock() + mock_monitor.get_stats.return_value = { + "pipeline": {"avg_ms": 16.5}, + "frame_count": 100, + } + + # Mock render_border to verify it's called + with ( + patch("engine.display.get_monitor", return_value=mock_monitor), + patch("engine.display.render_border", wraps=render_border) as mock_render, + ): + display.show(buffer, border=True) + + # Verify render_border was called with correct arguments + assert mock_render.called + args, kwargs = mock_render.call_args + # Arguments: buffer, width, height, fps, frame_time (positional) + assert args[0] == buffer + assert args[1] == 80 + assert args[2] == 24 + assert args[3] == pytest.approx(60.6, rel=0.1) # fps = 1000/16.5 + assert args[4] == pytest.approx(16.5, rel=0.1) + assert kwargs == {} # no keyword arguments + class TestNullDisplay: """Tests for NullDisplay class.""" @@ -241,6 +275,92 @@ class TestNullDisplay: assert display._last_buffer == ["second"] +class TestRenderBorder: + """Tests for render_border function.""" + + def test_render_border_adds_corners(self): + """render_border adds corner characters.""" + from engine.display import render_border + + buffer = ["hello", "world"] + result = render_border(buffer, width=10, height=5) + + assert result[0][0] in "┌┎┍" # top-left + assert result[0][-1] in "┐┒┓" # top-right + assert result[-1][0] in "└┚┖" # bottom-left + assert result[-1][-1] in "┘┛┙" # bottom-right + + def test_render_border_dimensions(self): + """render_border output matches requested dimensions.""" + from engine.display import render_border + + buffer = ["line1", "line2", "line3"] + result = render_border(buffer, width=20, height=10) + + # Output should be exactly height lines + assert len(result) == 10 + # Each line should be exactly width characters + for line in result: + assert len(line) == 20 + + def test_render_border_with_fps(self): + """render_border includes FPS in top border when provided.""" + from engine.display import render_border + + buffer = ["test"] + result = render_border(buffer, width=20, height=5, fps=60.0) + + top_line = result[0] + assert "FPS:60" in top_line or "FPS: 60" in top_line + + def test_render_border_with_frame_time(self): + """render_border includes frame time in bottom border when provided.""" + from engine.display import render_border + + buffer = ["test"] + result = render_border(buffer, width=20, height=5, frame_time=16.5) + + bottom_line = result[-1] + assert "16.5ms" in bottom_line + + def test_render_border_crops_content_to_fit(self): + """render_border crops content to fit within borders.""" + from engine.display import render_border + + # Buffer larger than viewport + buffer = ["x" * 100] * 50 + result = render_border(buffer, width=20, height=10) + + # Result shrinks to fit viewport + assert len(result) == 10 + for line in result[1:-1]: # Skip border lines + assert len(line) == 20 + + def test_render_border_preserves_content(self): + """render_border preserves content within borders.""" + from engine.display import render_border + + buffer = ["hello world", "test line"] + result = render_border(buffer, width=20, height=5) + + # Content should appear in the middle rows + content_lines = result[1:-1] + assert any("hello world" in line for line in content_lines) + + def test_render_border_with_small_buffer(self): + """render_border handles buffers smaller than viewport.""" + from engine.display import render_border + + buffer = ["hi"] + result = render_border(buffer, width=20, height=10) + + # Should still produce full viewport with padding + assert len(result) == 10 + # All lines should be full width + for line in result: + assert len(line) == 20 + + class TestMultiDisplay: """Tests for MultiDisplay class."""