diff --git a/CHANGELOG.md b/CHANGELOG.md index 485c6e9fe..e0d392685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ### Fixes - Fixed `DefaultTextureAtlas` raising `KeyError` when a texture was added while the garbage collector freed the last other texture with the same image and transform. The collector could run in the middle of adding the new texture, and the freed texture's finalizer removed the slot the new one was about to share. It showed up in CI on Python 3.14, whose garbage collector runs at different times, when loading a tile map. +- Fixed blocky, uneven text on scaled (HiDPI) displays, such as Windows set to 125% or 150%. Arcade's windows use pyglet's `"stretch"` scaling, so text was rasterized at the window's size and then stretched to fill the larger framebuffer, giving strokes of uneven width. It looked worse after the default font changed to Liberation Sans, which shows the stretching more than Calibri did. `Text`, `draw_text` and GUI text now lay their glyphs out at the framebuffer's resolution and draw them back at the same size, so they're sharp. Nothing changes at a scale of 100%. + - Positions and sizes on `Text`, such as `x`, `content_width` and `left`, are still in window units. The pyglet label in `Text.label` uses framebuffer pixels on a scaled display. + - Text can come out a few percent wider, because the font is hinted for the larger size. + - `create_text_sprite` is unchanged; its texture is the size of the text in window units. - Fixed `DefaultTextureAtlas` losing track of textures that share an image, such as every `SpriteSolidColor` size, which share one white image: - Once the first texture with a given image was freed while another was still in use, resizing the atlas, which happens when it fills up, raised `RuntimeError: Empty set in unique textures` and left the atlas half-resized. - If the last texture's finalizer ran before Python had cleared it from the atlas's records, the atlas kept the texture's name after freeing its slot. Adding another texture with that image then raised `Texture '...' not found in UVData`. This showed up as an occasional CI failure. diff --git a/arcade/text.py b/arcade/text.py index 8a21bdcde..521c76e8e 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -16,6 +16,8 @@ # Pyright can't figure out the dynamic backend imports in pyglet.graphics # right now. Maybe can fix in future Pyglet version from pyglet.graphics import Batch, Group # type: ignore +from pyglet.graphics.state import State +from pyglet.math import Mat4, Vec3 from pyglet.text import LinearGradient import arcade @@ -174,6 +176,42 @@ def _attempt_font_name_resolution(font_name: FontNameOrNames) -> str: return pyglet.font.load(names).name +class _ScaleViewState(State): + """Scale the view matrix while a label draws. + + On a scaled (HiDPI) display, :py:class:`Text` lays its label out at the + framebuffer's resolution, so the glyphs aren't stretched and blurred. + This scales the label back down to window coordinates. + """ + + sets_state = True + unsets_state = True + + def __init__(self, ctx: Any, scale: float) -> None: + self.ctx = ctx + self.scale = scale + self._saved_view: Mat4 | None = None + + def set_state(self, draw_ctx: Any) -> None: + self._saved_view = view = self.ctx.view_matrix + self.ctx.view_matrix = view @ Mat4.from_scale(Vec3(self.scale, self.scale, 1.0)) + + def unset_state(self, draw_ctx: Any) -> None: + if self._saved_view is not None: + self.ctx.view_matrix = self._saved_view + + # Equal states let a batch draw labels with the same scale together + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, _ScaleViewState) + and other.ctx is self.ctx + and other.scale == self.scale + ) + + def __hash__(self) -> int: + return hash((id(self.ctx), self.scale)) + + def _draw_pyglet_label(label: pyglet.text.Label) -> None: """ Helper for drawing pyglet labels with rotation within arcade. @@ -395,6 +433,10 @@ def __init__( self._resolved_font_name: str | None = None # The clock tick this text was last drawn by draw_text, if it was self._draw_text_tick = -1 + # Framebuffer pixels per window unit. On a scaled (HiDPI) display + # the label is laid out at this scale, so its glyphs are drawn at + # the screen's resolution instead of stretched. + self._scale = 1.0 self._initialized = False try: @@ -403,6 +445,10 @@ def __init__( # No window yet, so create the label when it's first used pass + def _render_scale(self, window: Any) -> float: + """How many framebuffer pixels to lay the label out per window unit.""" + return window.get_pixel_ratio() + @property def label(self) -> pyglet.text.Label: """ @@ -426,10 +472,25 @@ def _init_deferred(self): Deferred initialization when lazy loaded """ # NOTE: Give the user a clear error message stating that the window is not created yet - arcade.get_window() + window = arcade.get_window() self._arguments["font_name"] = _attempt_font_name_resolution(self._arguments["font_name"]) # type: ignore - self._label = pyglet.text.Label(**self._arguments) # type: ignore + arguments: dict[str, Any] = self._arguments + self._scale = scale = self._render_scale(window) + if scale != 1: + # Lay the label out in framebuffer pixels, then scale it back + # down to window units while it draws + arguments = dict(arguments) + arguments["x"] = arguments["x"] * scale + arguments["y"] = arguments["y"] * scale + for key in ("width", "height"): + if arguments.get(key): + arguments[key] = round(arguments[key] * scale) + arguments["dpi"] = (arguments.get("dpi") or 96) * scale + group = Group(parent=arguments.get("group")) + group.set_state(_ScaleViewState(window.ctx, 1 / scale)) + arguments["group"] = group + self._label = pyglet.text.Label(**arguments) # type: ignore self._resolved_font_name = self._label.font_name self._initialized = True @@ -532,10 +593,11 @@ def text(self, value: Any): @property def x(self) -> float: """Get or set the x position of the label.""" - return self.label.x + return self.label.x / self._scale @x.setter def x(self, x: float) -> None: + x = x * self._scale if self.label.x == x: return self.label.x = x @@ -543,10 +605,11 @@ def x(self, x: float) -> None: @property def y(self) -> float: """Get or set the y position of the label.""" - return self.label.y + return self.label.y / self._scale @y.setter def y(self, y: float): + y = y * self._scale if self.label.y == y: return self.label.y = y @@ -669,13 +732,15 @@ def width(self) -> int | None: If you are looking for the physical size if the text, see :py:attr:`~arcade.Text.content_width` """ - return self.label.width + width = self.label.width + return None if width is None else round(width / self._scale) @width.setter def width(self, width: int): - if self.label.width == width: + scaled = None if width is None else round(width * self._scale) + if self.label.width == scaled: return - self._layout_label().width = width + self._layout_label().width = scaled @property def height(self) -> int | None: @@ -686,48 +751,50 @@ def height(self) -> int | None: If you are looking for the physical size if the text, see :py:attr:`~arcade.Text.content_height` """ - return self.label.height + height = self.label.height + return None if height is None else round(height / self._scale) @height.setter def height(self, value: int): - if self.label.height == value: + scaled = None if value is None else round(value * self._scale) + if self.label.height == scaled: return - self._layout_label().height = value + self._layout_label().height = scaled @property def size(self): """Get the size of the label.""" - return self.label.width, self.label.height + return self.width, self.height @property def content_width(self) -> int: """Get the pixel width of the text contents.""" - return self.label.content_width + return round(self.label.content_width / self._scale) @property def content_height(self) -> int: """Get the pixel height of the text content.""" - return self.label.content_height + return round(self.label.content_height / self._scale) @property def left(self) -> float: """Pixel location of the left content border.""" - return self.label.left + return self.label.left / self._scale @property def right(self) -> float: """Pixel location of the right content border.""" - return self.label.right + return self.label.right / self._scale @property def top(self) -> float: """Pixel location of the top content border.""" - return self.label.top + return self.label.top / self._scale @property def bottom(self) -> float: """Pixel location of the bottom content border.""" - return self.label.bottom + return self.label.bottom / self._scale @property def rect(self) -> Rect: @@ -744,7 +811,7 @@ def rect(self) -> Rect: @property def content_size(self) -> tuple[int, int]: """Get the pixel width and height of the text contents.""" - return self.label.content_width, self.label.content_height + return self.content_width, self.content_height @property def align(self) -> str: @@ -888,14 +955,16 @@ def position(self) -> Point: This is faster than setting x and y position separately because the underlying geometry only needs to change position once. """ - return self.label.x, self.label.y + label = self.label + return label.x / self._scale, label.y / self._scale @position.setter def position(self, point: Point): # Starting with Pyglet 2.0b2 label positions take a z parameter. x, y, *z = point label = self.label - position = (x, y, z[0] if z else label.z) + scale = self._scale + position = (x * scale, y * scale, z[0] if z else label.z) if label.position == position: return label.position = position @@ -1074,6 +1143,18 @@ def remove(self, key: str) -> None: del self._cache[key] +class _UnscaledText(Text): + """A Text laid out in window units even on a scaled (HiDPI) display. + + :py:func:`create_text_sprite` draws text into a texture the size of the + text in window units, so laying it out at a higher resolution would only + be scaled back down. + """ + + def _render_scale(self, window: Any) -> float: + return 1.0 + + def create_text_sprite( text: str, color: RGBOrA255 | LinearGradient = arcade.color.WHITE, @@ -1123,7 +1204,7 @@ def create_text_sprite( texture_atlas: The texture atlas to use for the newly created texture. The default global atlas will be used if this is None. """ - text_object = Text( + text_object = _UnscaledText( text, x=0, y=0, diff --git a/tests/unit/text/test_text_hidpi.py b/tests/unit/text/test_text_hidpi.py new file mode 100644 index 000000000..973c1daad --- /dev/null +++ b/tests/unit/text/test_text_hidpi.py @@ -0,0 +1,113 @@ +"""On a scaled (HiDPI) display, Text lays out at the screen's resolution. + +The tests run with a pixel ratio of 1, so they pretend the display is scaled +by patching the scale Text lays out at. Patching the window's +get_pixel_ratio would also change the viewport. +""" + +import pyglet +import pytest + +import arcade +from arcade.text import _UnscaledText + +RATIO = 2.0 + + +def pretend_scaled(monkeypatch): + monkeypatch.setattr(arcade.Text, "_render_scale", lambda self, window: RATIO) + # create_text_sprite opts out, like on a real scaled display + monkeypatch.setattr(_UnscaledText, "_render_scale", lambda self, window: 1.0) + + +@pytest.fixture +def scaled(window, monkeypatch): + pretend_scaled(monkeypatch) + + +def text(**kwargs): + return arcade.Text("The quick brown fox", 100, 50, arcade.color.BLACK, 14, **kwargs) + + +def dark_bounds(): + """The left, bottom, right and top of the dark pixels on screen.""" + image = arcade.get_image().convert("L") + box = image.point([255 if value < 128 else 0 for value in range(256)]).getbbox() + assert box, "Nothing was drawn" + left, top, right, bottom = box + height = image.height + return left, height - bottom, right, height - top + + +@pytest.mark.usefixtures("scaled") +def test_glyphs_are_rasterized_at_the_screen_resolution(): + assert text().label.dpi == 96 * RATIO + + +@pytest.mark.usefixtures("scaled") +def test_positions_and_sizes_are_in_window_units(): + plain_scale_text = text() + assert plain_scale_text.x == 100 + assert plain_scale_text.y == 50 + assert plain_scale_text.position == (100, 50) + assert plain_scale_text.left == pytest.approx(100) + + plain_scale_text.position = 20, 30 + assert plain_scale_text.position == (20, 30) + plain_scale_text.x = 40 + assert plain_scale_text.x == 40 + + wrapped = text(width=150, multiline=True) + assert wrapped.width == 150 + wrapped.width = 200 + assert wrapped.width == 200 + + +def test_content_size_matches_unscaled_text(window, monkeypatch): + unscaled = text() + pretend_scaled(monkeypatch) + scaled_text = text() + # Fonts are hinted at each size, so the widths differ a little + assert scaled_text.content_width == pytest.approx(unscaled.content_width, rel=0.1) + assert scaled_text.content_height == pytest.approx(unscaled.content_height, rel=0.1) + + +@pytest.mark.parametrize("use_batch", [False, True], ids=["draw", "batch"]) +def test_drawn_where_unscaled_text_is(window, monkeypatch, use_batch): + window.background_color = arcade.color.WHITE + + window.clear() + text().draw() + expected = dark_bounds() + + pretend_scaled(monkeypatch) + window.clear() + if use_batch: + batch = pyglet.graphics.Batch() + scaled_text = text(batch=batch) + batch.draw() + else: + scaled_text = text() + scaled_text.draw() + actual = dark_bounds() + + # Same place and about the same size, not twice as big + assert actual[0] == pytest.approx(expected[0], abs=3) + assert actual[1] == pytest.approx(expected[1], abs=3) + assert actual[2] - actual[0] == pytest.approx(expected[2] - expected[0], rel=0.1) + assert actual[3] - actual[1] == pytest.approx(expected[3] - expected[1], rel=0.15) + assert scaled_text.label.dpi == 96 * RATIO + + +def test_text_sprites_are_unchanged(window, monkeypatch): + unscaled = arcade.create_text_sprite("The quick brown fox", arcade.color.BLACK, 14) + pretend_scaled(monkeypatch) + scaled_sprite = arcade.create_text_sprite("The quick brown fox", arcade.color.BLACK, 14) + assert scaled_sprite.size == unscaled.size + + +def test_unscaled_display_is_unchanged(window): + assert window.get_pixel_ratio() == 1 + plain = text() + assert plain.label.dpi == 96 + assert plain.label.x == 100