Skip to content
Open
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
2 changes: 2 additions & 0 deletions arcade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ def configure_logging(level: int | None = None):
load_font,
create_text_sprite,
Text,
TextPool,
)

__all__ = [
Expand Down Expand Up @@ -311,6 +312,7 @@ def configure_logging(level: int | None = None):
"SpriteSequence",
"SpriteSolidColor",
"Text",
"TextPool",
"Texture",
"TextureCacheManager",
"SpriteSheet",
Expand Down
137 changes: 136 additions & 1 deletion arcade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions tests/unit/text/test_text_pool.py
Original file line number Diff line number Diff line change
@@ -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
Loading