diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebfd3520..df7911706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ### Misc Changes - Sped up `arcade.draw_text()`. It no longer flushes OpenGL after every call (an unchanged call went from 76 to 15 µs), and several lines drawn in the same style each keep their own cached label instead of re-laying out one label every call (5 static lines went from 1.24 ms to 0.08 ms per frame). +- Sped up `arcade.Text` updates. Setting a property to the value it already has no longer lays out the text again (for example `font_size`, `bold`, or `width` went from about 165 µs to under 1 µs), and a `with text:` block only lays out the text if something in it needs a new layout. An unchanged `arcade.TextPool` frame with 5 lines went from 760 µs to 6 µs. ## 4.0.0.dev8 diff --git a/arcade/text.py b/arcade/text.py index 521b7da04..f6cc44f19 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -117,6 +117,13 @@ def _to_text_color(color: RGBOrA255 | LinearGradient) -> Color | LinearGradient: return Color.from_iterable(color) +def _normalize_font_name(font_name: FontNameOrNames) -> FontNameOrNames: + """Make font names comparable, since a list and a tuple are never equal.""" + if isinstance(font_name, str): + return font_name + return tuple(font_name) + + def _to_weight(bold: bool | str) -> str: """Convert a ``bold`` value to a pyglet font weight name.""" if isinstance(bold, Enum): @@ -303,6 +310,15 @@ def __init__( f"but got {width!r}." ) + # Nesting depth of ``with text:`` blocks, and whether a pyglet + # update was begun for a change inside them + self._update_depth = 0 + self._update_begun = False + # The font name as last requested, since the label holds the + # resolved name (e.g. "arial" for ("calibri", "arial")) + self._requested_font_name = _normalize_font_name(font_name) + self._resolved_font_name: str | None = None + self._initialized = False try: self._init_deferred() @@ -337,17 +353,41 @@ def _init_deferred(self): self._arguments["font_name"] = _attempt_font_name_resolution(self._arguments["font_name"]) # type: ignore self._label = pyglet.text.Label(**self._arguments) # type: ignore + self._resolved_font_name = self._label.font_name self._initialized = True def __enter__(self): """ - Update multiple attributes of this text, - using efficient update mechanism of the underlying ``pyglet.Label`` + Update multiple attributes of this text, laying out the text + only once at the end of the block. + + Changes that need a new layout, such as the text, font, or + width, are laid out together when the block ends. Changes that + don't, such as the position, color, or rotation, apply right + away. If nothing that needs a new layout changes, the block + costs nothing. """ - self.label.begin_update() + self._update_depth += 1 + return self def __exit__(self, exc_type, exc_val, exc_tb): - self.label.end_update() + self._update_depth -= 1 + if self._update_depth == 0 and self._update_begun: + self._update_begun = False + self.label.end_update() + + def _layout_label(self) -> pyglet.text.Label: + """ + Get the label, to make a change that needs a new layout. + + Inside ``with text:``, the first such change begins a pyglet + update, so all of them are laid out once when the block ends. + """ + label = self.label + if self._update_depth and not self._update_begun: + label.begin_update() + self._update_begun = True + return label @property def batch(self) -> Batch | None: @@ -374,7 +414,9 @@ def group(self) -> Group | None: @group.setter def group(self, group: Group): - self.label.group = group + if self.label.group is group: + return + self._layout_label().group = group @property def value(self) -> str: @@ -390,7 +432,7 @@ def value(self, value: Any): value = str(value) if self.label.text == value: return - self.label.text = value + self._layout_label().text = value @property def text(self) -> str: @@ -408,7 +450,7 @@ def text(self, value: Any): value = str(value) if self.label.text == value: return - self.label.text = value + self._layout_label().text = value @property def x(self) -> float: @@ -453,10 +495,19 @@ def font_name(self) -> FontNameOrNames: @font_name.setter def font_name(self, font_name: FontNameOrNames) -> None: + font_name = _normalize_font_name(font_name) + label = self.label + # Compare with the requested name, since the label holds the + # resolved one, and check the label wasn't changed directly + if font_name == self._requested_font_name and label.font_name == self._resolved_font_name: + return + label = self._layout_label() if isinstance(font_name, str): - self.label.font_name = font_name + label.font_name = font_name else: - self.label.font_name = list(font_name) + label.font_name = list(font_name) + self._requested_font_name = font_name + self._resolved_font_name = label.font_name @property def font_size(self) -> float: @@ -465,7 +516,9 @@ def font_size(self) -> float: @font_size.setter def font_size(self, font_size: float): - self.label.font_size = font_size + if self.label.font_size == font_size: + return + self._layout_label().font_size = font_size @property def anchor_x(self) -> str: @@ -478,6 +531,8 @@ def anchor_x(self) -> str: @anchor_x.setter def anchor_x(self, anchor_x: str): + if self.label.anchor_x == anchor_x: + return self.label.anchor_x = anchor_x # type: ignore @property @@ -491,6 +546,8 @@ def anchor_y(self) -> str: @anchor_y.setter def anchor_y(self, anchor_y: str): + if self.label.anchor_y == anchor_y: + return self.label.anchor_y = anchor_y # type: ignore @property @@ -500,6 +557,8 @@ def rotation(self) -> float: @rotation.setter def rotation(self, rotation: float): + if self.label.rotation == rotation: + return self.label.rotation = rotation @property @@ -517,7 +576,15 @@ def color(self) -> Color | LinearGradient: @color.setter def color(self, color: RGBOrA255 | LinearGradient): - self.label.color = _to_text_color(color) + color = _to_text_color(color) + label = self.label + old_color = label.color + if old_color == color: + return + # Solid colors are updated in place, but gradients need a new layout + if isinstance(color, LinearGradient) or isinstance(old_color, LinearGradient): + label = self._layout_label() + label.color = color @property def width(self) -> int | None: @@ -532,7 +599,9 @@ def width(self) -> int | None: @width.setter def width(self, width: int): - self.label.width = width + if self.label.width == width: + return + self._layout_label().width = width @property def height(self) -> int | None: @@ -547,7 +616,9 @@ def height(self) -> int | None: @height.setter def height(self, value: int): - self.label.height = value + if self.label.height == value: + return + self._layout_label().height = value @property def size(self): @@ -611,7 +682,9 @@ def align(self) -> str: @align.setter def align(self, align: str): - self.label.set_style("align", align) + if self.label.get_style("align") == align: + return + self._layout_label().set_style("align", align) @property def bold(self) -> bool | str: @@ -631,7 +704,10 @@ def bold(self) -> bool | str: @bold.setter def bold(self, bold: bool | str): - self.label.weight = _to_weight(bold) + weight = _to_weight(bold) + if self.label.weight == weight: + return + self._layout_label().weight = weight @property def italic(self) -> bool | str: @@ -650,9 +726,12 @@ def italic(self) -> bool | str: def italic(self, italic: bool | str): # Set the "style" document style, which pyglet uses to pick the # font. pyglet's own Label.italic sets an "italic" style, which - # doesn't change the font. - label = self.label - label.document.set_style(0, len(label.document.text), {"style": _to_style(italic)}) + # doesn't change the font: https://github.com/pyglet/pyglet/issues/1508 + style = _to_style(italic) + if self.label.document.get_style("style") == style: + return + label = self._layout_label() + label.document.set_style(0, len(label.document.text), {"style": style}) @property def multiline(self) -> bool: @@ -661,7 +740,9 @@ def multiline(self) -> bool: @multiline.setter def multiline(self, multiline: bool): - self.label.multiline = multiline + if self.label.multiline == multiline: + return + self._layout_label().multiline = multiline @property def visible(self) -> bool: @@ -739,11 +820,11 @@ def position(self) -> Point: def position(self, point: Point): # Starting with Pyglet 2.0b2 label positions take a z parameter. x, y, *z = point - - if z: - self.label.position = x, y, z[0] - else: - self.label.position = x, y, self.label.z + label = self.label + position = (x, y, z[0] if z else label.z) + if label.position == position: + return + label.position = position @property def tracking(self) -> float | None: @@ -762,7 +843,9 @@ def tracking(self) -> float | None: @tracking.setter def tracking(self, value: float): - self.label.set_style("kerning", value) + if self.label.get_style("kerning") == value: + return + self._layout_label().set_style("kerning", value) def em_to_px(self, em: float) -> float: """Convert from an em value to a pixel amount. diff --git a/tests/unit/text/test_text_updates.py b/tests/unit/text/test_text_updates.py new file mode 100644 index 000000000..817bb2965 --- /dev/null +++ b/tests/unit/text/test_text_updates.py @@ -0,0 +1,200 @@ +"""Tests that Text only lays out its text again when something changes""" + +import pytest +from pyglet.text import LinearGradient + +import arcade + +FONT = "Liberation Sans" + + +def _render(window, text: arcade.Text): + """Draw a Text into an offscreen framebuffer and return its pixels""" + ctx = window.ctx + fbo = ctx.framebuffer(color_attachments=[ctx.texture((400, 200), components=4)]) + with fbo.activate(): + fbo.clear(color=(0, 0, 0, 0)) + text.draw() + return fbo.read(components=4) + + +def _count_layouts(text: arcade.Text) -> list[int]: + """Count the label's full layouts, which is what makes changes slow""" + label = text.label + count = [0] + update = label._update + + def counting_update(): + # pyglet skips the layout while an update is in progress + if label._update_enabled: + count[0] += 1 + update() + + label._update = counting_update + return count + + +# Each property with its current value, which setting again should not lay out +SAME_VALUES = ( + ("text", "Hello"), + ("value", "Hello"), + ("x", 10), + ("y", 20), + ("position", (10, 20)), + ("font_name", (FONT,)), + ("font_size", 20), + ("color", (255, 255, 255)), + ("bold", False), + ("italic", False), + ("anchor_x", "left"), + ("anchor_y", "baseline"), + ("rotation", 0), + ("align", "left"), + ("width", 300), + ("height", None), + ("multiline", True), + ("tracking", 0), + ("visible", True), +) + + +@pytest.fixture +def text(window) -> arcade.Text: + text = arcade.Text("Hello", 10, 20, font_name=(FONT,), font_size=20, width=300, multiline=True) + text.tracking = 0 + return text + + +@pytest.mark.parametrize(("name", "value"), SAME_VALUES) +def test_same_value_does_not_lay_out(text, name, value): + layouts = _count_layouts(text) + setattr(text, name, value) + assert layouts[0] == 0 + + +def test_same_gradient_does_not_lay_out(text): + gradient = LinearGradient((255, 0, 0, 255), (0, 0, 255, 255)) + text.color = gradient + layouts = _count_layouts(text) + text.color = LinearGradient((255, 0, 0, 255), (0, 0, 255, 255)) + assert layouts[0] == 0 + + +def test_font_name_list_matches_tuple(text): + layouts = _count_layouts(text) + text.font_name = [FONT] + assert layouts[0] == 0 + + +def test_font_name_changed_on_label_is_set_again(text): + """Setting the same font name again works if the label was changed directly""" + text.label.font_name = "Liberation Serif" + text.font_name = (FONT,) + assert text.label.font_name == FONT + + +def test_empty_with_block_does_not_lay_out(text): + layouts = _count_layouts(text) + with text: + pass + with text: + for name, value in SAME_VALUES: + setattr(text, name, value) + assert layouts[0] == 0 + + +def test_cheap_changes_in_with_block_do_not_lay_out(text): + layouts = _count_layouts(text) + with text: + text.position = 50, 60 + text.color = arcade.color.RED + text.rotation = 10 + assert layouts[0] == 0 + assert text.position == (50, 60) + assert text.color == arcade.color.RED + assert text.rotation == 10 + + +def test_with_block_lays_out_once(text): + layouts = _count_layouts(text) + with text: + text.text = "Changed text" + text.font_size = 24 + text.bold = True + text.italic = True + text.width = 350 + text.align = "center" + assert layouts[0] == 1 + + +def test_nested_with_blocks_lay_out_once(text): + layouts = _count_layouts(text) + with text: + text.text = "Changed" + with text: + text.font_size = 24 + assert layouts[0] == 0 + assert layouts[0] == 1 + + +def test_with_block_ends_update_after_exception(text): + with pytest.raises(ZeroDivisionError): + with text: + text.text = "Changed" + raise ZeroDivisionError + assert text.label._update_enabled + layouts = _count_layouts(text) + text.font_size = 30 + assert layouts[0] == 1 + + +def test_changes_outside_with_block_lay_out_right_away(text): + layouts = _count_layouts(text) + text.font_size = 24 + assert layouts[0] == 1 + # pyglet replaces text by deleting it and inserting the new text, + # which lays out twice, but the first layout is of empty text + text.text = "Changed" + assert layouts[0] > 1 + assert text.content_width > 0 + + +def test_with_block_draws_like_new_text(window, text): + with text: + text.anchor_x = "center" + text.color = arcade.color.YELLOW + text.text = "A longer line of changed text" + text.font_size = 16 + text.position = 200, 100 + text.italic = True + text.align = "center" + + expected = arcade.Text( + "A longer line of changed text", + 200, + 100, + color=arcade.color.YELLOW, + font_name=(FONT,), + font_size=16, + width=300, + multiline=True, + anchor_x="center", + italic=True, + align="center", + ) + expected.tracking = 0 + assert _render(window, text) == _render(window, expected) + + +def test_text_pool_unchanged_does_not_lay_out(window): + pool = arcade.TextPool(font_name=FONT) + text = pool.get("score", "Score: 10", 10, 10, arcade.color.WHITE, 16, bold=True) + layouts = _count_layouts(text) + for _ in range(10): + pool.get("score", "Score: 10", 10, 10, arcade.color.WHITE, 16, bold=True) + assert layouts[0] == 0 + + pool.get("score", "Score: 20", 30, 10, arcade.color.RED, 18, bold=True) + assert layouts[0] == 1 + assert text.text == "Score: 20" + assert text.font_size == 18