feat(pygame): add glyph caching for performance improvement
- Add _glyph_cache dict to PygameDisplay.__init__ - Cache font.render() results per (char, fg, bg) combination - Use blits() for batch rendering instead of individual blit calls - Add TestRenderBorder tests (8 new tests) for border rendering - Update NullDisplay.show() to support border=True for consistency - Add test_show_with_border_uses_render_border for TerminalDisplay Closes #28
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user