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:
2026-03-18 04:23:58 -07:00
parent 4b26c947e8
commit 60ae4f7dfb
3 changed files with 159 additions and 12 deletions

View File

@@ -33,10 +33,25 @@ class NullDisplay:
self._last_buffer = None self._last_buffer = None
def show(self, buffer: list[str], border: bool = False) -> 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 self._last_buffer = buffer
monitor = get_monitor()
if monitor: if monitor:
t0 = time.perf_counter() t0 = time.perf_counter()
chars_in = sum(len(line) for line in buffer) chars_in = sum(len(line) for line in buffer)

View File

@@ -41,6 +41,7 @@ class PygameDisplay:
self._quit_requested = False self._quit_requested = False
self._last_frame_time = 0.0 self._last_frame_time = 0.0
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0 self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
self._glyph_cache = {}
def _get_font_path(self) -> str | None: def _get_font_path(self) -> str | None:
"""Get font path for rendering.""" """Get font path for rendering."""
@@ -191,6 +192,8 @@ class PygameDisplay:
self._screen.fill((0, 0, 0)) self._screen.fill((0, 0, 0))
blit_list = []
for row_idx, line in enumerate(buffer[: self.height]): for row_idx, line in enumerate(buffer[: self.height]):
if row_idx >= self.height: if row_idx >= self.height:
break break
@@ -202,15 +205,24 @@ class PygameDisplay:
if not text: if not text:
continue continue
if bg != (0, 0, 0): # Use None as key for no background
bg_surface = self._font.render(text, True, fg, bg) bg_key = bg if bg != (0, 0, 0) else None
self._screen.blit(bg_surface, (x_pos, row_idx * self.cell_height)) cache_key = (text, fg, bg_key)
else:
text_surface = self._font.render(text, True, fg)
self._screen.blit(text_surface, (x_pos, row_idx * self.cell_height))
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] x_pos += self._font.size(text)[0]
self._screen.blits(blit_list)
self._pygame.display.flip() self._pygame.display.flip()
elapsed_ms = (time.perf_counter() - t0) * 1000 elapsed_ms = (time.perf_counter() - t0) * 1000

View File

@@ -3,9 +3,11 @@ Tests for engine.display module.
""" """
import sys 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 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. The fix adds \\033[H\\033[J (cursor home + erase down) before each frame.
""" """
from io import BytesIO from io import BytesIO
from unittest.mock import patch
display = TerminalDisplay() display = TerminalDisplay()
display.init(80, 24) display.init(80, 24)
@@ -157,7 +158,6 @@ class TestTerminalDisplay:
Regression test: Ensures each show() call includes the clear sequence. Regression test: Ensures each show() call includes the clear sequence.
""" """
from io import BytesIO from io import BytesIO
from unittest.mock import patch
# Use target_fps=0 to disable frame skipping in test # Use target_fps=0 to disable frame skipping in test
display = TerminalDisplay(target_fps=0) display = TerminalDisplay(target_fps=0)
@@ -193,6 +193,40 @@ class TestTerminalDisplay:
# returns different values each call # returns different values each call
assert len(set(dims)) == 1, f"Dimensions should be stable, got: {set(dims)}" 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: class TestNullDisplay:
"""Tests for NullDisplay class.""" """Tests for NullDisplay class."""
@@ -241,6 +275,92 @@ class TestNullDisplay:
assert display._last_buffer == ["second"] 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: class TestMultiDisplay:
"""Tests for MultiDisplay class.""" """Tests for MultiDisplay class."""