diff --git a/CHANGELOG.md b/CHANGELOG.md index f1be1f029..9990e3083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased +### Fixes +- Fixed several `arcade.create_text_sprite()` bugs: + - Sprites with the same text shared one image in the texture atlas, so creating a second one (for example in another color or size) changed the first one and drew the second stretched. Each sprite now gets its own image. + - `anchor_x="center"` cut off the left half of the text, and `anchor_x="right"` made an empty sprite. + - The sprite was placed too high. It now covers the same area as an `arcade.Text` with the same arguments at the same position. + - An empty string raised `ValueError`. It now makes a transparent sprite, as documented. + ### New Features - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. diff --git a/arcade/text.py b/arcade/text.py index 161e26743..88b083c6f 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -2,8 +2,10 @@ Drawing text with pyglet label """ +import math from pathlib import Path from typing import Any +from uuid import uuid4 import pyglet from pyglet.enums import Style, Weight @@ -913,12 +915,23 @@ def create_text_sprite( multiline=multiline, ) + # Where the text is with its anchor at (0, 0), as a Text object would be + left = text_object.left + bottom = text_object.bottom + # At least 1 pixel, so an empty string still makes a (transparent) texture size = ( - int(text_object.right - text_object.left), - int(text_object.top - text_object.bottom), + max(1, math.ceil(text_object.right - left)), + max(1, math.ceil(text_object.top - bottom)), ) - text_object.y = -text_object.bottom - texture = arcade.Texture.create_empty(text, size) + + # Draw it into the texture with its bottom left corner at (0, 0) + text_object.x = -left + text_object.y = -bottom + + # Each sprite needs its own image in the atlas. A name based on the text + # would make sprites with the same text, but different colors or sizes, + # share one. + texture = arcade.Texture.create_empty(f"create_text_sprite_{uuid4().hex}", size) if not texture_atlas: texture_atlas = arcade.get_window().ctx.default_atlas @@ -927,10 +940,11 @@ def create_text_sprite( fbo.clear(color=background_color or arcade.color.TRANSPARENT_BLACK) text_object.draw() + # Place the sprite where the Text object was drawn return arcade.Sprite( texture, - center_x=text_object.right - (size[0] / 2), - center_y=text_object.top, + center_x=left + size[0] / 2, + center_y=bottom + size[1] / 2, ) diff --git a/tests/unit/text/test_text_sprite.py b/tests/unit/text/test_text_sprite.py index 01b93f05e..812843f1f 100644 --- a/tests/unit/text/test_text_sprite.py +++ b/tests/unit/text/test_text_sprite.py @@ -8,3 +8,78 @@ def test_create(window, color): assert isinstance(sprite, arcade.Sprite) assert sprite.width == pytest.approx(75, rel=10) assert sprite.height == pytest.approx(20, rel=5) + + +def _ink(image): + """Total alpha of an image, as a measure of how much was drawn""" + return sum(value * count for value, count in enumerate(image.getchannel("A").histogram())) + + +def _sprite_bounds(sprite): + return sprite.left, sprite.right, sprite.bottom, sprite.top + + +def _text_bounds(text): + return text.left, text.right, text.bottom, text.top + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"anchor_x": "center"}, + {"anchor_x": "right"}, + {"font_size": 40}, + {"multiline": True, "width": 120}, + {"multiline": True, "width": 90, "align": "center", "anchor_x": "center"}, + ], +) +def test_position_matches_text(window, kwargs): + """The sprite covers the same area as a Text object at the same position""" + text = "Two lines of text gj" + sprite = arcade.create_text_sprite(text, **kwargs) + text_object = arcade.Text(text, 0, 0, anchor_y="baseline", **kwargs) + assert _sprite_bounds(sprite) == pytest.approx(_text_bounds(text_object), abs=1) + + +@pytest.mark.parametrize("anchor_x", ["left", "center", "right"]) +def test_anchor_draws_all_text(window, anchor_x): + """Every anchor puts all of the text inside the texture""" + atlas = window.ctx.default_atlas + # Read each image right away, so this doesn't depend on the sprites + # having separate images (tested below) + anchored = arcade.create_text_sprite("Hello World", font_size=20, anchor_x=anchor_x) + actual = _ink(atlas.read_texture_image_from_atlas(anchored.texture)) + left = arcade.create_text_sprite("Hello World", font_size=20) + expected = _ink(atlas.read_texture_image_from_atlas(left.texture)) + assert expected > 0 + assert actual == pytest.approx(expected, rel=0.01) + + +def test_same_text_different_style(window): + """Sprites with the same text keep their own images""" + atlas = window.ctx.default_atlas + small_red = arcade.create_text_sprite("Same", color=arcade.color.RED, font_size=20) + red_image = atlas.read_texture_image_from_atlas(small_red.texture) + + big_blue = arcade.create_text_sprite("Same", color=arcade.color.BLUE, font_size=40) + + # Each texture has an atlas region of its own size + for sprite in (small_red, big_blue): + region = atlas.get_texture_region_info(sprite.texture.atlas_name) + assert (region.width, region.height) == sprite.texture.size + assert big_blue.texture.size != small_red.texture.size + + # Creating the second sprite didn't change the first one's image + assert atlas.read_texture_image_from_atlas(small_red.texture).tobytes() == red_image.tobytes() + red, _green, blue, _alpha = red_image.getextrema() + assert red[1] > 200 and blue[1] == 0 + + +def test_empty_text(window): + """An empty string makes a transparent sprite instead of failing""" + sprite = arcade.create_text_sprite("") + assert sprite.texture.width >= 1 + assert sprite.texture.height >= 1 + image = window.ctx.default_atlas.read_texture_image_from_atlas(sprite.texture) + assert _ink(image) == 0