From ec07099125bfbbb79b7a269b85acae30b574f66e Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 7 May 2026 12:41:22 -0500 Subject: [PATCH] Add TextPool: keyed cache of reusable Text objects Games drawing dynamic text every frame pay a performance cost creating arcade.Text objects repeatedly. TextPool provides a keyed cache that creates Text objects on first use and efficiently updates them on subsequent calls, avoiding reconstruction costs. Co-Authored-By: Claude Opus 4.6 --- arcade/__init__.py | 2 + arcade/text.py | 137 +++++++++++++++++++++++++++++- tests/unit/text/test_text_pool.py | 120 ++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 tests/unit/text/test_text_pool.py diff --git a/arcade/__init__.py b/arcade/__init__.py index 342ed7216d..0abdf7502c 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -257,6 +257,7 @@ def configure_logging(level: int | None = None): load_font, create_text_sprite, Text, + TextPool, ) __all__ = [ @@ -311,6 +312,7 @@ def configure_logging(level: int | None = None): "SpriteSequence", "SpriteSolidColor", "Text", + "TextPool", "Texture", "TextureCacheManager", "SpriteSheet", diff --git a/arcade/text.py b/arcade/text.py index d9bdbd46a3..cfed249338 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -19,7 +19,7 @@ from arcade.types import Color, Point, RGBOrA255 from arcade.types.rect import LRBT, Rect -__all__ = ["load_font", "Text", "create_text_sprite", "draw_text"] +__all__ = ["load_font", "Text", "TextPool", "create_text_sprite", "draw_text"] def load_font(path: str | Path) -> None: @@ -710,6 +710,141 @@ def px_to_em(self, px: float) -> float: return px / (4 / 3) / self.font_size +class TextPool: + """A keyed cache of reusable Text objects. + + Avoids the cost of creating new :py:class:`arcade.Text` objects every + frame for dynamic text that changes position, content, or color + frequently. + + Any keyword arguments passed to the constructor become defaults for + every ``Text`` created by this pool. Per-call keyword arguments + override these defaults. + + Example:: + + pool = arcade.TextPool(font_name="Arial") + + def on_draw(self): + pool.draw("score", f"Score: {self.score}", 10, 580, + color=arcade.color.WHITE, font_size=16) + pool.draw("fps", f"FPS: {arcade.get_fps():.0f}", 10, 560, + color=arcade.color.GRAY, font_size=12) + + Args: + font_name: Default font for all text created by this pool. + **defaults: Default keyword arguments passed to + :py:class:`arcade.Text` on creation (e.g. ``bold``, + ``anchor_x``). + """ + + def __init__(self, font_name: FontNameOrNames = ("calibri", "arial"), **defaults): + self._font_name = font_name + self._defaults = defaults + self._cache: dict[str, Text] = {} + + def draw( + self, + key: str, + text: str, + x: float, + y: float, + color: RGBOrA255 = arcade.color.WHITE, + font_size: float = 12, + **kwargs, + ) -> Text: + """Get or create a cached Text object, update it, and draw it. + + The first call with a given *key* creates the + :py:class:`arcade.Text` object. Subsequent calls update the + existing object's properties and draw it, avoiding + reconstruction costs. + + Args: + key: Unique string identifier for this text slot. + text: The string to display. + x: X position in pixels. + y: Y position in pixels. + color: Text color (any format accepted by arcade). + font_size: Font size in points. + **kwargs: Additional :py:class:`arcade.Text` properties + such as ``bold``, ``anchor_x``, ``rotation``, etc. + + Returns: + The :py:class:`arcade.Text` object, useful for measuring + ``content_width`` / ``content_height`` after drawing. + """ + cached_text = self.get(key, text, x, y, color, font_size, **kwargs) + cached_text.draw() + return cached_text + + def get( + self, + key: str, + text: str, + x: float, + y: float, + color: RGBOrA255 = arcade.color.WHITE, + font_size: float = 12, + **kwargs, + ) -> Text: + """Get or create a cached Text object and update its properties. + + Like :py:meth:`draw` but does **not** draw the text. Useful + when you need to measure the text (e.g. ``content_width``) or + draw it later as part of a batch. + + Args: + key: Unique string identifier for this text slot. + text: The string to display. + x: X position in pixels. + y: Y position in pixels. + color: Text color (any format accepted by arcade). + font_size: Font size in points. + **kwargs: Additional :py:class:`arcade.Text` properties + such as ``bold``, ``anchor_x``, ``rotation``, etc. + + Returns: + The :py:class:`arcade.Text` object. + """ + if key in self._cache: + cached_text = self._cache[key] + with cached_text: + cached_text.text = text + cached_text.x = x + cached_text.y = y + cached_text.color = color + cached_text.font_size = font_size + for attr_name, attr_value in kwargs.items(): + setattr(cached_text, attr_name, attr_value) + return cached_text + + merged_kwargs = {**self._defaults, **kwargs} + new_text = Text( + text, x, y, color, + font_size=font_size, + font_name=self._font_name, + **merged_kwargs, + ) + self._cache[key] = new_text + return new_text + + def clear(self) -> None: + """Remove all cached Text objects from the pool.""" + self._cache.clear() + + def remove(self, key: str) -> None: + """Remove a specific cached Text object by key. + + Args: + key: The identifier of the text slot to remove. + + Raises: + KeyError: If *key* is not in the pool. + """ + del self._cache[key] + + def create_text_sprite( text: str, color: RGBOrA255 = arcade.color.WHITE, diff --git a/tests/unit/text/test_text_pool.py b/tests/unit/text/test_text_pool.py new file mode 100644 index 0000000000..9700fd0b46 --- /dev/null +++ b/tests/unit/text/test_text_pool.py @@ -0,0 +1,120 @@ +import pytest +import arcade + + +def test_draw_creates_and_returns_text(window): + """draw() should create a Text object and return it.""" + pool = arcade.TextPool() + result = pool.draw("score", "Score: 0", 10, 580) + assert isinstance(result, arcade.Text) + assert result.text == "Score: 0" + assert result.x == 10 + assert result.y == 580 + + +def test_same_key_reuses_object(window): + """Calling draw() twice with the same key should return the same Text instance.""" + pool = arcade.TextPool() + first = pool.draw("label", "Hello", 10, 10) + second = pool.draw("label", "World", 20, 20) + assert first is second + assert second.text == "World" + assert second.x == 20 + assert second.y == 20 + + +def test_different_keys_create_different_objects(window): + """Different keys should produce distinct Text instances.""" + pool = arcade.TextPool() + text_a = pool.draw("a", "Alpha", 0, 0) + text_b = pool.draw("b", "Beta", 10, 10) + assert text_a is not text_b + + +def test_get_returns_without_drawing(window): + """get() should return a Text object without calling draw.""" + pool = arcade.TextPool() + result = pool.get("info", "Test", 5, 5) + assert isinstance(result, arcade.Text) + assert result.text == "Test" + + +def test_get_reuses_object(window): + """get() should reuse the same cached object on subsequent calls.""" + pool = arcade.TextPool() + first = pool.get("info", "A", 0, 0) + second = pool.get("info", "B", 1, 1) + assert first is second + assert second.text == "B" + + +def test_clear_removes_all(window): + """clear() should remove all cached Text objects.""" + pool = arcade.TextPool() + pool.get("a", "A", 0, 0) + pool.get("b", "B", 0, 0) + pool.clear() + + new_a = pool.get("a", "A2", 5, 5) + assert new_a.text == "A2" + assert new_a.x == 5 + + +def test_remove_specific_key(window): + """remove() should delete only the specified key.""" + pool = arcade.TextPool() + original = pool.get("target", "X", 0, 0) + pool.get("keep", "Y", 0, 0) + + pool.remove("target") + + recreated = pool.get("target", "X2", 10, 10) + assert recreated is not original + + kept = pool.get("keep", "Y", 0, 0) + assert kept.text == "Y" + + +def test_remove_missing_key_raises(window): + """remove() should raise KeyError for a missing key.""" + pool = arcade.TextPool() + with pytest.raises(KeyError): + pool.remove("nonexistent") + + +def test_pool_defaults_apply(window): + """Constructor defaults should be passed through to created Text objects.""" + pool = arcade.TextPool(font_name="arial", bold=True, anchor_x="center") + result = pool.get("label", "Hello", 0, 0) + assert result.bold is True + assert result.anchor_x == "center" + + +def test_per_call_kwargs_override_defaults(window): + """Per-call kwargs should override constructor defaults.""" + pool = arcade.TextPool(anchor_x="left") + result = pool.get("label", "Hello", 0, 0, anchor_x="right") + assert result.anchor_x == "right" + + +def test_properties_update_on_reuse(window): + """All standard properties should update when reusing a cached object.""" + pool = arcade.TextPool() + pool.get("item", "Old", 0, 0, color=arcade.color.WHITE, font_size=12) + updated = pool.get( + "item", "New", 100, 200, + color=arcade.color.RED, font_size=24, + ) + assert updated.text == "New" + assert updated.x == 100 + assert updated.y == 200 + assert updated.color == arcade.color.RED + assert updated.font_size == 24 + + +def test_content_width_accessible(window): + """content_width should be readable on returned Text objects.""" + pool = arcade.TextPool() + result = pool.get("measure", "Hello World", 0, 0, font_size=16) + assert isinstance(result.content_width, (int, float)) + assert result.content_width > 0