Skip to content

Commit ec07099

Browse files
Paul V Cravenclaude
andcommitted
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 <noreply@anthropic.com>
1 parent d962be8 commit ec07099

3 files changed

Lines changed: 258 additions & 1 deletion

File tree

arcade/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ def configure_logging(level: int | None = None):
257257
load_font,
258258
create_text_sprite,
259259
Text,
260+
TextPool,
260261
)
261262

262263
__all__ = [
@@ -311,6 +312,7 @@ def configure_logging(level: int | None = None):
311312
"SpriteSequence",
312313
"SpriteSolidColor",
313314
"Text",
315+
"TextPool",
314316
"Texture",
315317
"TextureCacheManager",
316318
"SpriteSheet",

arcade/text.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from arcade.types import Color, Point, RGBOrA255
2020
from arcade.types.rect import LRBT, Rect
2121

22-
__all__ = ["load_font", "Text", "create_text_sprite", "draw_text"]
22+
__all__ = ["load_font", "Text", "TextPool", "create_text_sprite", "draw_text"]
2323

2424

2525
def load_font(path: str | Path) -> None:
@@ -710,6 +710,141 @@ def px_to_em(self, px: float) -> float:
710710
return px / (4 / 3) / self.font_size
711711

712712

713+
class TextPool:
714+
"""A keyed cache of reusable Text objects.
715+
716+
Avoids the cost of creating new :py:class:`arcade.Text` objects every
717+
frame for dynamic text that changes position, content, or color
718+
frequently.
719+
720+
Any keyword arguments passed to the constructor become defaults for
721+
every ``Text`` created by this pool. Per-call keyword arguments
722+
override these defaults.
723+
724+
Example::
725+
726+
pool = arcade.TextPool(font_name="Arial")
727+
728+
def on_draw(self):
729+
pool.draw("score", f"Score: {self.score}", 10, 580,
730+
color=arcade.color.WHITE, font_size=16)
731+
pool.draw("fps", f"FPS: {arcade.get_fps():.0f}", 10, 560,
732+
color=arcade.color.GRAY, font_size=12)
733+
734+
Args:
735+
font_name: Default font for all text created by this pool.
736+
**defaults: Default keyword arguments passed to
737+
:py:class:`arcade.Text` on creation (e.g. ``bold``,
738+
``anchor_x``).
739+
"""
740+
741+
def __init__(self, font_name: FontNameOrNames = ("calibri", "arial"), **defaults):
742+
self._font_name = font_name
743+
self._defaults = defaults
744+
self._cache: dict[str, Text] = {}
745+
746+
def draw(
747+
self,
748+
key: str,
749+
text: str,
750+
x: float,
751+
y: float,
752+
color: RGBOrA255 = arcade.color.WHITE,
753+
font_size: float = 12,
754+
**kwargs,
755+
) -> Text:
756+
"""Get or create a cached Text object, update it, and draw it.
757+
758+
The first call with a given *key* creates the
759+
:py:class:`arcade.Text` object. Subsequent calls update the
760+
existing object's properties and draw it, avoiding
761+
reconstruction costs.
762+
763+
Args:
764+
key: Unique string identifier for this text slot.
765+
text: The string to display.
766+
x: X position in pixels.
767+
y: Y position in pixels.
768+
color: Text color (any format accepted by arcade).
769+
font_size: Font size in points.
770+
**kwargs: Additional :py:class:`arcade.Text` properties
771+
such as ``bold``, ``anchor_x``, ``rotation``, etc.
772+
773+
Returns:
774+
The :py:class:`arcade.Text` object, useful for measuring
775+
``content_width`` / ``content_height`` after drawing.
776+
"""
777+
cached_text = self.get(key, text, x, y, color, font_size, **kwargs)
778+
cached_text.draw()
779+
return cached_text
780+
781+
def get(
782+
self,
783+
key: str,
784+
text: str,
785+
x: float,
786+
y: float,
787+
color: RGBOrA255 = arcade.color.WHITE,
788+
font_size: float = 12,
789+
**kwargs,
790+
) -> Text:
791+
"""Get or create a cached Text object and update its properties.
792+
793+
Like :py:meth:`draw` but does **not** draw the text. Useful
794+
when you need to measure the text (e.g. ``content_width``) or
795+
draw it later as part of a batch.
796+
797+
Args:
798+
key: Unique string identifier for this text slot.
799+
text: The string to display.
800+
x: X position in pixels.
801+
y: Y position in pixels.
802+
color: Text color (any format accepted by arcade).
803+
font_size: Font size in points.
804+
**kwargs: Additional :py:class:`arcade.Text` properties
805+
such as ``bold``, ``anchor_x``, ``rotation``, etc.
806+
807+
Returns:
808+
The :py:class:`arcade.Text` object.
809+
"""
810+
if key in self._cache:
811+
cached_text = self._cache[key]
812+
with cached_text:
813+
cached_text.text = text
814+
cached_text.x = x
815+
cached_text.y = y
816+
cached_text.color = color
817+
cached_text.font_size = font_size
818+
for attr_name, attr_value in kwargs.items():
819+
setattr(cached_text, attr_name, attr_value)
820+
return cached_text
821+
822+
merged_kwargs = {**self._defaults, **kwargs}
823+
new_text = Text(
824+
text, x, y, color,
825+
font_size=font_size,
826+
font_name=self._font_name,
827+
**merged_kwargs,
828+
)
829+
self._cache[key] = new_text
830+
return new_text
831+
832+
def clear(self) -> None:
833+
"""Remove all cached Text objects from the pool."""
834+
self._cache.clear()
835+
836+
def remove(self, key: str) -> None:
837+
"""Remove a specific cached Text object by key.
838+
839+
Args:
840+
key: The identifier of the text slot to remove.
841+
842+
Raises:
843+
KeyError: If *key* is not in the pool.
844+
"""
845+
del self._cache[key]
846+
847+
713848
def create_text_sprite(
714849
text: str,
715850
color: RGBOrA255 = arcade.color.WHITE,

tests/unit/text/test_text_pool.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import pytest
2+
import arcade
3+
4+
5+
def test_draw_creates_and_returns_text(window):
6+
"""draw() should create a Text object and return it."""
7+
pool = arcade.TextPool()
8+
result = pool.draw("score", "Score: 0", 10, 580)
9+
assert isinstance(result, arcade.Text)
10+
assert result.text == "Score: 0"
11+
assert result.x == 10
12+
assert result.y == 580
13+
14+
15+
def test_same_key_reuses_object(window):
16+
"""Calling draw() twice with the same key should return the same Text instance."""
17+
pool = arcade.TextPool()
18+
first = pool.draw("label", "Hello", 10, 10)
19+
second = pool.draw("label", "World", 20, 20)
20+
assert first is second
21+
assert second.text == "World"
22+
assert second.x == 20
23+
assert second.y == 20
24+
25+
26+
def test_different_keys_create_different_objects(window):
27+
"""Different keys should produce distinct Text instances."""
28+
pool = arcade.TextPool()
29+
text_a = pool.draw("a", "Alpha", 0, 0)
30+
text_b = pool.draw("b", "Beta", 10, 10)
31+
assert text_a is not text_b
32+
33+
34+
def test_get_returns_without_drawing(window):
35+
"""get() should return a Text object without calling draw."""
36+
pool = arcade.TextPool()
37+
result = pool.get("info", "Test", 5, 5)
38+
assert isinstance(result, arcade.Text)
39+
assert result.text == "Test"
40+
41+
42+
def test_get_reuses_object(window):
43+
"""get() should reuse the same cached object on subsequent calls."""
44+
pool = arcade.TextPool()
45+
first = pool.get("info", "A", 0, 0)
46+
second = pool.get("info", "B", 1, 1)
47+
assert first is second
48+
assert second.text == "B"
49+
50+
51+
def test_clear_removes_all(window):
52+
"""clear() should remove all cached Text objects."""
53+
pool = arcade.TextPool()
54+
pool.get("a", "A", 0, 0)
55+
pool.get("b", "B", 0, 0)
56+
pool.clear()
57+
58+
new_a = pool.get("a", "A2", 5, 5)
59+
assert new_a.text == "A2"
60+
assert new_a.x == 5
61+
62+
63+
def test_remove_specific_key(window):
64+
"""remove() should delete only the specified key."""
65+
pool = arcade.TextPool()
66+
original = pool.get("target", "X", 0, 0)
67+
pool.get("keep", "Y", 0, 0)
68+
69+
pool.remove("target")
70+
71+
recreated = pool.get("target", "X2", 10, 10)
72+
assert recreated is not original
73+
74+
kept = pool.get("keep", "Y", 0, 0)
75+
assert kept.text == "Y"
76+
77+
78+
def test_remove_missing_key_raises(window):
79+
"""remove() should raise KeyError for a missing key."""
80+
pool = arcade.TextPool()
81+
with pytest.raises(KeyError):
82+
pool.remove("nonexistent")
83+
84+
85+
def test_pool_defaults_apply(window):
86+
"""Constructor defaults should be passed through to created Text objects."""
87+
pool = arcade.TextPool(font_name="arial", bold=True, anchor_x="center")
88+
result = pool.get("label", "Hello", 0, 0)
89+
assert result.bold is True
90+
assert result.anchor_x == "center"
91+
92+
93+
def test_per_call_kwargs_override_defaults(window):
94+
"""Per-call kwargs should override constructor defaults."""
95+
pool = arcade.TextPool(anchor_x="left")
96+
result = pool.get("label", "Hello", 0, 0, anchor_x="right")
97+
assert result.anchor_x == "right"
98+
99+
100+
def test_properties_update_on_reuse(window):
101+
"""All standard properties should update when reusing a cached object."""
102+
pool = arcade.TextPool()
103+
pool.get("item", "Old", 0, 0, color=arcade.color.WHITE, font_size=12)
104+
updated = pool.get(
105+
"item", "New", 100, 200,
106+
color=arcade.color.RED, font_size=24,
107+
)
108+
assert updated.text == "New"
109+
assert updated.x == 100
110+
assert updated.y == 200
111+
assert updated.color == arcade.color.RED
112+
assert updated.font_size == 24
113+
114+
115+
def test_content_width_accessible(window):
116+
"""content_width should be readable on returned Text objects."""
117+
pool = arcade.TextPool()
118+
result = pool.get("measure", "Hello World", 0, 0, font_size=16)
119+
assert isinstance(result.content_width, (int, float))
120+
assert result.content_width > 0

0 commit comments

Comments
 (0)