Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
123 changes: 102 additions & 21 deletions arcade/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
"""
Expand All @@ -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

Expand Down Expand Up @@ -532,21 +593,23 @@ 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

@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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/text/test_text_hidpi.py
Original file line number Diff line number Diff line change
@@ -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
Loading