Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/main/python/preponderous/graphik/graphik.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
83 changes: 83 additions & 0 deletions src/test/python/preponderous/graphik/test_graphik.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading