diff --git a/src/main/python/preponderous/graphik/graphik.py b/src/main/python/preponderous/graphik/graphik.py index 94cbc8f..a4730a0 100644 --- a/src/main/python/preponderous/graphik/graphik.py +++ b/src/main/python/preponderous/graphik/graphik.py @@ -22,6 +22,10 @@ def __init__(self, gameDisplay=None): displayHeight = 600 gameDisplay = pygame.display.set_mode((displayWidth, displayHeight)) self.gameDisplay = gameDisplay + # Fonts cached by size, plus the display surface they were built + # against. See _getFont for why both are needed. + self._fonts = {} + self._fontDisplay = None def getGameDisplay(self): return self.gameDisplay @@ -32,8 +36,40 @@ def getVersion(self): def drawRectangle(self, xpos, ypos, width, height, color): pygame.draw.rect(self.gameDisplay, color, [xpos, ypos, width, height]) + def _getFont(self, size): + # Building a Font parses and rasterizes the TrueType file, which costs + # far more than the render() it exists to serve, so keep one per size + # instead of rebuilding it on every frame's drawText. + if not pygame.font.get_init(): + # The constructor only sets up a display, so a consumer can reach + # here having never initialized the font module; bring it up rather + # than failing with a bare "font not initialized". Anything already + # cached belongs to the previous font session (see below). + pygame.font.init() + self._fonts.clear() + + # A Font that outlives a font.quit()/init() cycle points at freed + # SDL_ttf memory and segfaults when used, and pygame offers no way to + # test a Font for validity. Restarting pygame drops the display + # surface, so treat a change of that object as a new session and + # rebuild. (A resize returns the same surface, so this does not + # discard the cache on every set_mode.) + # + # Not covered: a consumer that calls pygame.font.quit() followed by + # pygame.font.init() itself, leaving the display alone -- pygame + # exposes nothing that distinguishes that from an untouched module. + # Build a new Graphik after restarting the font module that way. + display = pygame.display.get_surface() + if display is not self._fontDisplay: + self._fonts.clear() + self._fontDisplay = display + + if size not in self._fonts: + self._fonts[size] = pygame.font.Font('freesansbold.ttf', size) + return self._fonts[size] + def drawText(self, text, xpos, ypos, size, color): - myFont = pygame.font.Font('freesansbold.ttf', size) + myFont = self._getFont(size) textSurface = myFont.render(text, True, color) textRectangle = textSurface.get_rect() textRectangle.center = ((xpos, ypos)) diff --git a/src/test/python/preponderous/graphik/test_graphik.py b/src/test/python/preponderous/graphik/test_graphik.py index b6af6c2..c1ca04a 100644 --- a/src/test/python/preponderous/graphik/test_graphik.py +++ b/src/test/python/preponderous/graphik/test_graphik.py @@ -141,6 +141,89 @@ def test_draw_text_blits_non_background_pixels(): assert changed +def _count_font_constructions(monkeypatch): + # Wrap pygame.font.Font so tests can observe how often it is built. + constructed = [] + realFont = pygame.font.Font + + def counting_font(*args, **kwargs): + constructed.append(args) + return realFont(*args, **kwargs) + + monkeypatch.setattr(pygame.font, "Font", counting_font) + return constructed + + +def test_draw_text_reuses_one_font_across_calls_at_the_same_size(monkeypatch): + graphik = _make_graphik() + constructed = _count_font_constructions(monkeypatch) + + for _ in range(5): + graphik.drawText("A", 5, 5, 12, Graphik.white) + + assert len(constructed) == 1 + + +def test_draw_text_builds_a_separate_font_per_size(monkeypatch): + graphik = _make_graphik() + constructed = _count_font_constructions(monkeypatch) + + graphik.drawText("A", 5, 5, 12, Graphik.white) + graphik.drawText("A", 5, 5, 14, Graphik.white) + graphik.drawText("A", 5, 5, 12, Graphik.white) + + # One font per distinct size, and the repeat of size 12 reuses the first. + assert [args[1] for args in constructed] == [12, 14] + + +def test_draw_text_initializes_font_module_when_only_display_was_initialized(): + # The constructor only sets up a display, so a consumer can reasonably + # reach drawText without ever calling pygame.font.init() themselves. + pygame.display.init() + display = pygame.display.set_mode((20, 20)) + graphik = Graphik(display) + pygame.font.quit() + assert not pygame.font.get_init() + + graphik.drawText("A", 10, 10, 12, Graphik.white) + + assert pygame.font.get_init() + + +def test_draw_text_rebuilds_cached_font_after_the_font_module_shuts_down(monkeypatch): + # A Font built before the font module went down is freed memory, so the + # cache must be dropped rather than reused when drawText restarts it. + graphik = _make_graphik() + graphik.drawText("A", 5, 5, 12, Graphik.white) + + pygame.font.quit() + + constructed = _count_font_constructions(monkeypatch) + graphik.drawText("A", 5, 5, 12, Graphik.white) + + assert len(constructed) == 1 + + +def test_draw_text_drops_cached_font_when_the_display_session_changes(monkeypatch): + # Restarting pygame invalidates the cached font too, but leaves the font + # module initialized, so the check above cannot catch it on its own. + graphik = _make_graphik() + graphik.drawText("A", 5, 5, 12, Graphik.white) + + pygame.quit() + pygame.init() + pygame.display.set_mode((20, 20)) + + constructed = _count_font_constructions(monkeypatch) + # The instance still holds the old display surface, so this fails the same + # loud way it did before fonts were cached -- rather than the stale font + # segfaulting first. + with pytest.raises(pygame.error): + graphik.drawText("A", 5, 5, 12, Graphik.white) + + assert len(constructed) == 1 + + def test_draw_button_draws_box_with_given_color(): graphik = _make_graphik((40, 40)) display = graphik.getGameDisplay()