diff --git a/CHANGELOG.md b/CHANGELOG.md index 06840d059..59dfa6b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 00706d1f7..4fe0b97cf 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -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. @@ -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. diff --git a/arcade/gui/style.py b/arcade/gui/style.py index 674894193..8ed0a641e 100644 --- a/arcade/gui/style.py +++ b/arcade/gui/style.py @@ -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) diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 0d7530bee..06cf6d29a 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -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, ) diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index da8561495..080cd1591 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -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 @@ -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 @@ -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`. """ @@ -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, diff --git a/tests/unit/gui/test_uiflatbutton_border.py b/tests/unit/gui/test_uiflatbutton_border.py new file mode 100644 index 000000000..1ed7d5209 --- /dev/null +++ b/tests/unit/gui/test_uiflatbutton_border.py @@ -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 diff --git a/tests/unit/gui/test_uistyle.py b/tests/unit/gui/test_uistyle.py index 6af4162c8..2ab4879d9 100644 --- a/tests/unit/gui/test_uistyle.py +++ b/tests/unit/gui/test_uistyle.py @@ -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 @@ -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 diff --git a/tests/unit/gui/test_uitextarea_styles.py b/tests/unit/gui/test_uitextarea_styles.py new file mode 100644 index 000000000..a717db729 --- /dev/null +++ b/tests/unit/gui/test_uitextarea_styles.py @@ -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='bold plain red', + 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)