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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
## Unreleased

### Fixes
- GUI: Fixed a gap between `UIFlatButton`'s border and the edge of the button, half the border's width, which also showed on `UIDropdown` ([#2868](https://github.com/pythonarcade/arcade/issues/2868)).
- GUI: Passing a single style such as `UIFlatButton.UIStyle()` as a widget's `style` raised a confusing `TypeError` about attribute names. It now raises a `TypeError` saying a dict of styles for each state is needed, and how to start from the widget's `DEFAULT_STYLE` ([#2817](https://github.com/pythonarcade/arcade/issues/2817)).
- GUI: Fixed `UITextArea`:
- With `document_mode="ATTRIBUTED"` or `"HTML"`, the widget's font and color replaced every style set in the text. Styles set in the text now win, and the widget's arguments apply to the rest. In HTML mode they also replace the decoder's own defaults, such as Times New Roman ([#2441](https://github.com/pythonarcade/arcade/issues/2441)).
- `bold` and `italic` had no effect, because pyglet 3 reads the `weight` and `style` keys instead.
- The text widgets page of the `gui/2_widgets` example now shows its labels in bold.
- Fixed `arcade.math.rand_vec_spread_deg()` returning vectors in random directions. It passed the angle in degrees to a function that expects radians, so for example the spread emitters in the `particle_systems` example sprayed everywhere ([#2648](https://github.com/pythonarcade/arcade/issues/2648)).
- Fixed reading 2 GB or more from an OpenGL texture, texture array, framebuffer or buffer on Windows, which failed because `ctypes.string_at` takes its size as a 32-bit C long there ([#2836](https://github.com/pythonarcade/arcade/issues/2836)).
- Fixed a player in `PhysicsEnginePlatformer` sliding along when standing across two moving platforms. It moved by both platforms' speeds added together, so it slid until it stood on only one. It now moves with the platform it stands on the most, and isn't moved by a platform it only touches the edge of while standing mostly on something still ([#2782](https://github.com/pythonarcade/arcade/issues/2782)).
Expand Down
12 changes: 6 additions & 6 deletions arcade/examples/gui/2_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@
Arcade GUI provides three types of text widgets:


- {bold True}UILabel{bold False}:
- {weight "bold"}UILabel{weight "normal"}:
A simple text widget that can be used to display text.

- {bold True}UIInputText{bold False}:
- {weight "bold"}UIInputText{weight "normal"}:
A text widget that can be used to get text input from the user.

- {bold True}UITextArea{bold False}:
- {weight "bold"}UITextArea{weight "normal"}:
A text widget that can be used to display text that is too long for a label.


Expand All @@ -101,18 +101,18 @@
A few hints regarding the usage of the text widgets:


{bold True}UILabel{bold False}:
{weight "bold"}UILabel{weight "normal"}:

If you want to display frequently changing text,
setting a background color will improve performance.


{bold True}UIInputText{bold False}:
{weight "bold"}UIInputText{weight "normal"}:

UIInputText dispatches an event on_change, when the text changes.


{bold True}UITextArea{bold False}:
{weight "bold"}UITextArea{weight "normal"}:

While the widget supports scrolling, there is no scrollbar provided yet.
Users might oversee content.
Expand Down
9 changes: 9 additions & 0 deletions arcade/gui/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ class UIStyledWidget(UIWidget, Generic[StyleRef]):
style = DictProperty[str, StyleRef]()

def __init__(self, *, style: dict[str, StyleRef], **kwargs):
if isinstance(style, UIStyleBase):
# A single style would otherwise fail deep inside DictProperty
name = type(self).__name__
raise TypeError(
f"{name} style must be a dict of styles for each state, such as "
'{"normal": style, "hover": ..., "press": ..., "disabled": ...}, '
f"not a single {type(style).__name__}. To change only some states, "
f'start from the defaults: {{**{name}.DEFAULT_STYLE, "normal": style}}'
)
self.style = style
super().__init__(**kwargs)

Expand Down
10 changes: 6 additions & 4 deletions arcade/gui/widgets/buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,13 @@ def do_render(self, surface: Surface):

# render button border (which is not the widgets border)
if border_color and border_width:
# The outline is centered on the rectangle's edge, so inset it by
# half its width to draw it right up to the button's edge
arcade.draw_lbwh_rectangle_outline(
border_width,
border_width,
self.content_width - 2 * border_width,
self.content_height - 2 * border_width,
border_width / 2,
border_width / 2,
self.content_width - border_width,
self.content_height - border_width,
color=border_color,
border_width=border_width,
)
Expand Down
48 changes: 31 additions & 17 deletions arcade/gui/widgets/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pyglet.text import LinearGradient
from pyglet.text.caret import Caret
from pyglet.text.document import AbstractDocument
from pyglet.text.formats.html import HTMLDecoder
from typing_extensions import override

import arcade
Expand All @@ -31,7 +32,7 @@
from arcade.gui.surface import Surface
from arcade.gui.widgets import UIInteractiveWidget, UIWidget
from arcade.gui.widgets.layout import UIAnchorLayout
from arcade.text import FontNameOrNames, _to_text_color
from arcade.text import FontNameOrNames, _to_text_color, _to_weight
from arcade.types import LBWH, RGBA255, Color, RGBOrA255


Expand Down Expand Up @@ -825,6 +826,8 @@ class UITextArea(UIWidget):
PLAIN will decode the text as plain text, ATTRIBUTED and HTML will
decode the text as pyglet documents here
https://pyglet.readthedocs.io/en/latest/programming_guide/text.html
With ATTRIBUTED and HTML, styles set in the text win, and the font,
color, bold and italic arguments apply to the rest of it.
**kwargs: passed to :py:class:`~arcade.gui.UIWidget`.
"""

Expand Down Expand Up @@ -864,25 +867,36 @@ def __init__(
# Measured in pixels per 'click'
self.scroll_speed = scroll_speed if scroll_speed is not None else font_size

# pyglet 3 reads "weight" and "style", not "bold" and "italic"
text_style = dict(
font_name=font_name,
font_size=font_size,
color=Color.from_iterable(text_color),
weight=_to_weight(bold),
style="italic" if italic else "normal",
)

self.doc: AbstractDocument
if document_mode == "PLAIN":
self.doc = pyglet.text.decode_text(text)
elif document_mode == "ATTRIBUTED":
self.doc = pyglet.text.decode_attributed(text)
elif document_mode == "HTML":
self.doc = pyglet.text.decode_html(text)

self.doc.set_style(
0,
len(text),
dict(
font_name=font_name,
font_size=font_size,
color=Color.from_iterable(text_color),
bold=bold,
italic=italic,
),
)
self.doc.set_style(0, len(self.doc.text), text_style)
else:
if document_mode == "ATTRIBUTED":
self.doc = pyglet.text.decode_attributed(text)
elif document_mode == "HTML":
# The decoder's own defaults (such as Times New Roman) would
# otherwise apply wherever the HTML doesn't set a style
class _Decoder(HTMLDecoder):
default_style = {**HTMLDecoder.default_style, **text_style}

self.doc = _Decoder().decode(text)
else:
raise ValueError(f"Unknown document_mode: {document_mode!r}")
# Styles set in the text win. Only fill in the rest.
for attribute, value in text_style.items():
runs = self.doc.get_style_runs(attribute).ranges(0, len(self.doc.text))
for start, end in [(start, end) for start, end, run in runs if run is None]:
self.doc.set_style(start, end, {attribute: value})

self.layout = pyglet.text.layout.IncrementalTextLayout(
self.doc,
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/gui/test_uiflatbutton_border.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""A UIFlatButton's border reaches the edge of the button, with no gap (#2868)."""

import pytest

from arcade.gui import Surface, UIFlatButton

BG = (0, 0, 255, 255)
BORDER = (255, 0, 0, 255)


@pytest.mark.parametrize("border_width", [2, 4])
def test_border_has_no_gap(window, border_width):
style = UIFlatButton.UIStyle(bg=BG, border=BORDER, border_width=border_width)
button = UIFlatButton(x=0, y=0, width=100, height=40, style={"normal": style})
surface = Surface(size=(100, 40))
with surface.activate():
button._do_render(surface, force=True)

# One pixel wide column through the middle of the button, from the left edge
data = surface.fbo.read(components=4, viewport=(0, 20, 100, 1))
row = [tuple(data[i : i + 4]) for i in range(0, len(data), 4)]
assert row[:border_width] == [BORDER] * border_width
assert row[border_width] == BG
assert row[-border_width:] == [BORDER] * border_width
assert row[-border_width - 1] == BG
15 changes: 15 additions & 0 deletions tests/unit/gui/test_uistyle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from dataclasses import dataclass

import pytest

import arcade
from arcade.gui import UIFlatButton
from arcade.gui.style import UIStyleBase
from arcade.types import Color

Expand Down Expand Up @@ -59,3 +62,15 @@ class DefaultStyleSubclass(DefaultStyle):
assert subject.some_color == arcade.color.GREEN
assert subject.some_str == "Arcade"
assert subject.some_other_str == "is awesome!"


def test_single_style_instead_of_dict_gives_clear_error(window):
# A style for each state is needed, so a single style is rejected clearly (#2817)
with pytest.raises(TypeError, match="dict of styles for each state"):
UIFlatButton(style=UIFlatButton.UIStyle())


def test_suggested_fix_for_single_style_works(window):
normal = UIFlatButton.UIStyle(font_size=20)
button = UIFlatButton(style={**UIFlatButton.DEFAULT_STYLE, "normal": normal})
assert button.get_current_style() is normal
55 changes: 55 additions & 0 deletions tests/unit/gui/test_uitextarea_styles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""UITextArea's style arguments, and styles set in ATTRIBUTED and HTML text (#2441)."""

import arcade
from arcade.gui import UITextArea

RED = arcade.types.Color(255, 0, 0, 255)
BLUE = arcade.types.Color(0, 0, 255, 255)


def test_bold_and_italic(window):
area = UITextArea(text="hello", bold=True, italic=True)
assert area.doc.get_style("weight", 0) == "bold"
assert area.doc.get_style("style", 0) == "italic"
font = area.doc.get_font(0)
assert font.weight == "bold"
assert font.style == "italic"


def test_plain_text_uses_the_arguments(window):
area = UITextArea(text="hello", font_size=20, text_color=BLUE)
assert area.doc.get_style("font_size", 4) == 20
assert area.doc.get_style("color", 4) == BLUE


def test_attributed_text_keeps_its_styles(window):
area = UITextArea(
text="{color (255, 0, 0, 255)}red{color None} blue",
document_mode="ATTRIBUTED",
text_color=BLUE,
font_size=20,
)
assert area.doc.text == "red blue"
# Set in the text
assert area.doc.get_style("color", 0) == RED
# Not set in the text, so the arguments apply
assert area.doc.get_style("color", 5) == BLUE
assert area.doc.get_style("font_size", 0) == 20


def test_html_text_keeps_its_styles(window):
area = UITextArea(
text='<b>bold</b> plain <font color="#ff0000">red</font>',
document_mode="HTML",
font_name=("Arial",),
font_size=20,
text_color=BLUE,
)
assert area.doc.get_style("weight", 0) == "bold"
assert area.doc.get_style("weight", 6) == "normal"
# The arguments replace the HTML decoder's own defaults
assert area.doc.get_style("font_name", 6) == ("Arial",)
assert area.doc.get_style("font_size", 6) == 20
assert area.doc.get_style("color", 6) == BLUE
# A color set in the HTML wins
assert tuple(area.doc.get_style("color", 12)) == tuple(RED)
Loading