diff --git a/android/src/toga_android/libs/android/graphics.py b/android/src/toga_android/libs/android/graphics.py index 4b99c4b034..b7be31cc7e 100644 --- a/android/src/toga_android/libs/android/graphics.py +++ b/android/src/toga_android/libs/android/graphics.py @@ -13,3 +13,5 @@ PorterDuffColorFilter = JavaClass("android/graphics/PorterDuffColorFilter") Rect = JavaClass("android/graphics/Rect") Typeface = JavaClass("android/graphics/Typeface") + +LineBreaker = JavaClass("android/graphics/text/LineBreaker") diff --git a/android/src/toga_android/libs/android/os.py b/android/src/toga_android/libs/android/os.py new file mode 100644 index 0000000000..25f4a762b5 --- /dev/null +++ b/android/src/toga_android/libs/android/os.py @@ -0,0 +1,3 @@ +from rubicon.java import JavaClass + +Build = JavaClass("android/os/Build") diff --git a/android/src/toga_android/widgets/label.py b/android/src/toga_android/widgets/label.py index 48c86d2956..60d523bb23 100644 --- a/android/src/toga_android/widgets/label.py +++ b/android/src/toga_android/widgets/label.py @@ -1,7 +1,10 @@ from travertino.size import at_least +from toga.constants import JUSTIFY from toga_android.colors import native_color +from ..libs.android.graphics import LineBreaker +from ..libs.android.os import Build from ..libs.android.view import Gravity, View__MeasureSpec from ..libs.android.widget import TextView from .base import Widget, align @@ -28,6 +31,9 @@ def create(self): self.native = TextView(self._native_activity) self.cache_textview_defaults() + def get_text(self): + return self.native.getText() + def set_text(self, value): self.native.setText(value) @@ -54,12 +60,15 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) def set_alignment(self, value): - # Refuse to set alignment if create() has not been called. - if self.native is None: - return # Refuse to set alignment if widget has no container. # On Android, calling setGravity() when the widget has no LayoutParams # results in a NullPointerException. if not self.native.getLayoutParams(): return + + # Justified text wasn't added until Android O (SDK 26) + if value == JUSTIFY and Build.VERSION.SDK_INT >= Build.VERSION_CODES.O: + self.native.setJustificationMode(LineBreaker.JUSTIFICATION_MODE_INTER_WORD) + else: + self.native.setJustificationMode(LineBreaker.JUSTIFICATION_MODE_NONE) self.native.setGravity(Gravity.CENTER_VERTICAL | align(value)) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 64a7cd17b2..f0bfab91fa 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -1,7 +1,6 @@ import asyncio from java import dynamic_proxy -from pytest import skip from android.view import ViewTreeObserver from toga.fonts import SYSTEM @@ -48,8 +47,8 @@ def assert_container(self, container): else: raise AssertionError(f"cannot find {self.native} in {container_native}") - def assert_alignment_equivalent(self, actual, expected): - assert actual == expected + def assert_alignment(self, expected): + assert self.alignment == expected def assert_font_family(self, expected): actual = self.font.family @@ -71,18 +70,6 @@ async def redraw(self): def enabled(self): return self.native.isEnabled() - @property - def background_color(self): - skip("not implemented: background_color") - - @property - def color(self): - skip("not implemented: color") - - @property - def hidden(self): - skip("not implemented: hidden") - @property def width(self): # Return the value in DP diff --git a/android/tests_backend/widgets/label.py b/android/tests_backend/widgets/label.py index ef31e56954..1598da5c77 100644 --- a/android/tests_backend/widgets/label.py +++ b/android/tests_backend/widgets/label.py @@ -1,8 +1,7 @@ from java import jclass -from pytest import skip from .base import SimpleProbe -from .properties import toga_color, toga_font +from .properties import toga_alignment, toga_color, toga_font class LabelProbe(SimpleProbe): @@ -30,4 +29,6 @@ def font(self): @property def alignment(self): - skip("Alignment probe not implemented") + return toga_alignment( + self.native.getGravity(), self.native.getJustificationMode() + ) diff --git a/android/tests_backend/widgets/properties.py b/android/tests_backend/widgets/properties.py index d7137f75bc..ae497c3fab 100644 --- a/android/tests_backend/widgets/properties.py +++ b/android/tests_backend/widgets/properties.py @@ -2,8 +2,11 @@ from travertino.fonts import Font from android.graphics import Color, Typeface +from android.graphics.text import LineBreaker from android.util import TypedValue +from android.view import Gravity from toga.colors import TRANSPARENT, rgba +from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT from toga.fonts import ( BOLD, ITALIC, @@ -62,3 +65,20 @@ def toga_font(typeface, size, resources): variant=NORMAL, weight=BOLD if typeface.isBold() else NORMAL, ) + + +def toga_alignment(gravity, justification_mode): + horizontal_gravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK + if ( + justification_mode == LineBreaker.JUSTIFICATION_MODE_INTER_WORD + and horizontal_gravity == Gravity.LEFT + ): + return JUSTIFY + elif justification_mode == LineBreaker.JUSTIFICATION_MODE_NONE: + return { + Gravity.LEFT: LEFT, + Gravity.RIGHT: RIGHT, + Gravity.CENTER_HORIZONTAL: CENTER, + }[horizontal_gravity] + else: + raise ValueError(f"unknown combination: {gravity=}, {justification_mode=}") diff --git a/changes/1501.bugfix.rst b/changes/1501.bugfix.rst new file mode 100644 index 0000000000..b9c429e10d --- /dev/null +++ b/changes/1501.bugfix.rst @@ -0,0 +1 @@ +iOS now supports newlines in Labels. diff --git a/changes/1799.feature.rst b/changes/1799.feature.rst new file mode 100644 index 0000000000..9747d91a74 --- /dev/null +++ b/changes/1799.feature.rst @@ -0,0 +1 @@ +The Label widget now has 100% test coverage, and complete API documentation. diff --git a/cocoa/src/toga_cocoa/widgets/label.py b/cocoa/src/toga_cocoa/widgets/label.py index 746a13afe2..a0e0461e67 100644 --- a/cocoa/src/toga_cocoa/widgets/label.py +++ b/cocoa/src/toga_cocoa/widgets/label.py @@ -26,8 +26,11 @@ def set_color(self, value): def set_font(self, font): self.native.font = font._impl.native + def get_text(self): + return str(self.native.stringValue) + def set_text(self, value): - self.native.stringValue = self.interface._text + self.native.stringValue = value def rehint(self): # Width & height of a label is known and fixed. diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index b1a83c4462..bad58265cf 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -17,8 +17,8 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") - def assert_alignment_equivalent(self, actual, expected): - assert actual == expected + def assert_alignment(self, expected): + assert self.alignment == expected def assert_font_family(self, expected): assert self.font.family == { diff --git a/core/src/toga/widgets/label.py b/core/src/toga/widgets/label.py index 1b038472dc..77f86dc95a 100644 --- a/core/src/toga/widgets/label.py +++ b/core/src/toga/widgets/label.py @@ -1,5 +1,3 @@ -import warnings - from .base import Widget @@ -9,7 +7,6 @@ def __init__( text, id=None, style=None, - factory=None, # DEPRECATED! ): """A text label. @@ -19,20 +16,9 @@ def __init__( :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. - :param factory: *Deprecated* """ super().__init__(id=id, style=style) - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - # Create a platform specific implementation of a Label self._impl = self.factory.Label(interface=self) @@ -40,14 +26,21 @@ def __init__( @property def text(self): - """The text displayed by the label.""" - return self._text + """The text displayed by the label. + + ``None``, and the Unicode codepoint U+200B (ZERO WIDTH SPACE), will be + interpreted and returned as an empty string. Any other object will be + converted to a string using ``str()``. + + """ + return self._impl.get_text() @text.setter def text(self, value): - if value is None: - self._text = "" + if value is None or value == "\u200B": + text = "" else: - self._text = str(value) - self._impl.set_text(value) + text = str(value) + + self._impl.set_text(text) self.refresh() diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 514f018a6c..16422502fa 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -118,12 +118,6 @@ def test_image_view_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_label_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Label("Test", factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_multiline_text_input_created(self): with self.assertWarns(DeprecationWarning): widget = toga.MultilineTextInput(factory=self.factory) diff --git a/core/tests/widgets/test_label.py b/core/tests/widgets/test_label.py index a6fd7404cc..81e3eedf02 100644 --- a/core/tests/widgets/test_label.py +++ b/core/tests/widgets/test_label.py @@ -1,27 +1,46 @@ +import pytest + import toga -from toga_dummy.utils import TestCase +from toga_dummy.utils import ( + EventLog, + assert_action_performed, + attribute_value, +) + + +@pytest.fixture +def label(): + return toga.Label("Test Label") -class LabelTests(TestCase): - def setUp(self): - super().setUp() +def test_label_created(label): + "A label can be created." + # Round trip the impl/interface + assert label._impl.interface == label + assert_action_performed(label, "create Label") - self.text = "test text" - self.label = toga.Label(self.text) +@pytest.mark.parametrize( + "value, expected", + [ + ("New Text", "New Text"), + (12345, "12345"), + (None, ""), + ("\u200B", ""), + ("Contains\nsome\nnewlines", "Contains\nsome\nnewlines"), + ], +) +def test_update_label_text(label, value, expected): + assert label.text == "Test Label" - def test_widget_created(self): - self.assertEqual(self.label._impl.interface, self.label) - self.assertActionPerformed(self.label, "create Label") + # Clear the event log + EventLog.reset() - def test_update_label_text(self): - new_text = "updated text" - self.label.text = new_text - self.assertEqual(self.label.text, new_text) - self.assertValueSet(self.label, "text", new_text) - self.assertActionPerformed(self.label, "refresh") + label.text = value + assert label.text == expected - self.label.text = None - self.assertEqual(self.label.text, "") + # test backend has the right value + assert attribute_value(label, "text") == expected - self.assertValueSet(self.label, "text", "") + # A rehint was performed + assert_action_performed(label, "refresh") diff --git a/docs/reference/api/widgets/button.rst b/docs/reference/api/widgets/button.rst index 4baead68d6..bae40326a3 100644 --- a/docs/reference/api/widgets/button.rst +++ b/docs/reference/api/widgets/button.rst @@ -27,7 +27,7 @@ A button has a text label. A handler can be associated with button press events. # handle event pass - button = toga.Button('Click me', on_press=my_callback) + button = toga.Button("Click me", on_press=my_callback) Notes ----- diff --git a/docs/reference/api/widgets/label.rst b/docs/reference/api/widgets/label.rst index f82283251b..e2dfcbb66d 100644 --- a/docs/reference/api/widgets/label.rst +++ b/docs/reference/api/widgets/label.rst @@ -20,7 +20,7 @@ Usage import toga - label = toga.Label('Hello world') + label = toga.Label("Hello world") Notes ----- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index d0987ffc59..677cb6e794 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -9,7 +9,7 @@ DatePicker,General Widget,:class:`~toga.widgets.datepicker.DatePicker`,An input DetailedList,General Widget,:class:`~toga.widgets.detailedlist.DetailedList`,A list of complex content,|b|,|b|,,|b|,|b|, Divider,General Widget,:class:`~toga.widgets.divider.Divider`,A horizontal or vertical line,|b|,|b|,|b|,,, ImageView,General Widget,:class:`~toga.widgets.imageview.ImageView`,Image Viewer,|b|,|b|,|b|,|b|,|b|, -Label,General Widget,:class:`~toga.widgets.label.Label`,Text label,|b|,|b|,|b|,|b|,|b|,|b| +Label,General Widget,:class:`~toga.widgets.label.Label`,Text label,|y|,|y|,|y|,|y|,|y|,|b| MultilineTextInput,General Widget,:class:`~toga.widgets.multilinetextinput.MultilineTextInput`,Multi-line Text Input field,|b|,|b|,|b|,|b|,|b|, NumberInput,General Widget,:class:`~toga.widgets.numberinput.NumberInput`,Number Input field,|b|,|b|,|b|,|b|,|b|, PasswordInput,General Widget,:class:`~toga.widgets.passwordinput.PasswordInput`,A text input that hides it’s input,|b|,|b|,|b|,|b|,|b|, diff --git a/dummy/src/toga_dummy/widgets/label.py b/dummy/src/toga_dummy/widgets/label.py index 9fb28f1972..1c654b249e 100644 --- a/dummy/src/toga_dummy/widgets/label.py +++ b/dummy/src/toga_dummy/widgets/label.py @@ -8,5 +8,8 @@ def create(self): def set_alignment(self, value): self._set_value("alignment", value) + def get_text(self): + return self._get_value("text") + def set_text(self, value): - self._set_value("text", self.interface._text) + self._set_value("text", value) diff --git a/gtk/src/toga_gtk/fonts.py b/gtk/src/toga_gtk/fonts.py index 15efc890d3..62f006f7db 100644 --- a/gtk/src/toga_gtk/fonts.py +++ b/gtk/src/toga_gtk/fonts.py @@ -1,4 +1,11 @@ -from toga.constants import BOLD, ITALIC, OBLIQUE, SMALL_CAPS, SYSTEM +from toga.constants import ( + BOLD, + ITALIC, + OBLIQUE, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) from .libs import Pango @@ -27,8 +34,9 @@ def __init__(self, interface): font.set_family(family) - # Set font size - font.set_size(self.interface.size * Pango.SCALE) + # If this is a non-default font size, set the font size + if self.interface.size != SYSTEM_DEFAULT_FONT_SIZE: + font.set_size(self.interface.size * Pango.SCALE) # Set font style if self.interface.style == ITALIC: diff --git a/gtk/src/toga_gtk/widgets/button.py b/gtk/src/toga_gtk/widgets/button.py index 7571ad3879..557d84536a 100644 --- a/gtk/src/toga_gtk/widgets/button.py +++ b/gtk/src/toga_gtk/widgets/button.py @@ -13,7 +13,6 @@ def create(self): self.native.get_style_context().add_class("toga") self.native.interface = self.interface - self.native.connect("show", lambda event: self.refresh()) self.native.connect("clicked", self.gtk_on_press) def get_text(self): diff --git a/gtk/src/toga_gtk/widgets/label.py b/gtk/src/toga_gtk/widgets/label.py index f4fb69a614..d70ae4f80a 100644 --- a/gtk/src/toga_gtk/widgets/label.py +++ b/gtk/src/toga_gtk/widgets/label.py @@ -9,12 +9,9 @@ def create(self): self.native = Gtk.Label() self.native.set_name(f"toga-{self.interface.id}") self.native.get_style_context().add_class("toga") - - self.native.set_line_wrap(False) - self.native.interface = self.interface - self.native.connect("show", lambda event: self.refresh()) + self.native.set_line_wrap(False) def set_alignment(self, value): xalign, justify = gtk_alignment(value) @@ -24,10 +21,11 @@ def set_alignment(self, value): justify ) # Aligns multiple lines relative to each other. + def get_text(self): + return self.native.get_text() + def set_text(self, value): - # FIXME after setting the label the label jumps to the top left - # corner and only jumps back at its place after resizing the window. - self.native.set_text(self.interface._text) + self.native.set_text(value) def rehint(self): # print("REHINT", self, diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 9fdaad89c6..f9004af7e6 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,7 +1,5 @@ import asyncio -from pytest import skip - from toga_gtk.libs import Gtk from .properties import toga_color, toga_font @@ -26,8 +24,8 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") - def assert_alignment_equivalent(self, actual, expected): - assert actual == expected + def assert_alignment(self, expected): + assert self.alignment == expected def assert_font_family(self, expected): assert self.font.family == expected @@ -42,14 +40,6 @@ async def redraw(self): if self.widget.app.run_slow: await asyncio.sleep(1) - @property - def enabled(self): - skip("enabled probe not implemented") - - @property - def hidden(self): - skip("hidden probe not implemented") - @property def width(self): return self.native.get_allocation().width diff --git a/gtk/tests_backend/widgets/label.py b/gtk/tests_backend/widgets/label.py index 0f4df67dcd..5965ef3705 100644 --- a/gtk/tests_backend/widgets/label.py +++ b/gtk/tests_backend/widgets/label.py @@ -1,8 +1,7 @@ -from pytest import skip - from toga_gtk.libs import Gtk from .base import SimpleProbe +from .properties import toga_alignment class LabelProbe(SimpleProbe): @@ -14,4 +13,8 @@ def text(self): @property def alignment(self): - skip("alignment probe not implemented") + return toga_alignment( + self.native.get_xalign(), + self.native.get_yalign(), + self.native.get_justify(), + ) diff --git a/gtk/tests_backend/widgets/properties.py b/gtk/tests_backend/widgets/properties.py index 003150c704..a66ae0d742 100644 --- a/gtk/tests_backend/widgets/properties.py +++ b/gtk/tests_backend/widgets/properties.py @@ -1,3 +1,4 @@ +import pytest from travertino.fonts import Font from toga.colors import TRANSPARENT, rgba @@ -33,10 +34,16 @@ def toga_font(font): ) -def toga_alignment(alignment): - return { - (0.0, Gtk.Justification.LEFT): LEFT, - (1.0, Gtk.Justification.RIGHT): RIGHT, - (0.5, Gtk.Justification.CENTER): CENTER, - (0.0, Gtk.Justification.FILL): JUSTIFY, - }[alignment] +def toga_alignment(xalign, yalign, justify): + if yalign != 0.5: + pytest.fail("Y-alignment should be 0.5") + + try: + return { + (0.0, Gtk.Justification.LEFT): LEFT, + (1.0, Gtk.Justification.RIGHT): RIGHT, + (0.5, Gtk.Justification.CENTER): CENTER, + (0.0, Gtk.Justification.FILL): JUSTIFY, + }[(xalign, justify)] + except KeyError: + pytest.fail(f"Can't interpret GTK x alignment {xalign} with justify {justify}") diff --git a/iOS/src/toga_iOS/widgets/label.py b/iOS/src/toga_iOS/widgets/label.py index ac260c1c23..ad4674bfba 100644 --- a/iOS/src/toga_iOS/widgets/label.py +++ b/iOS/src/toga_iOS/widgets/label.py @@ -1,23 +1,25 @@ +from math import ceil + from rubicon.objc import CGSize from travertino.size import at_least from toga.colors import TRANSPARENT from toga_iOS.colors import native_color -from toga_iOS.libs import NSLineBreakByWordWrapping, NSTextAlignment, UILabel +from toga_iOS.libs import NSLineBreakByClipping, NSTextAlignment, UILabel from toga_iOS.widgets.base import Widget class Label(Widget): def create(self): self.native = UILabel.new() - self.native.lineBreakMode = NSLineBreakByWordWrapping + # We shouldn't ever word wrap; if faced with that option, clip. + self.native.lineBreakMode = NSLineBreakByClipping # Add the layout constraints self.add_constraints() def set_alignment(self, value): - if value: - self.native.textAlignment = NSTextAlignment(value) + self.native.textAlignment = NSTextAlignment(value) def set_color(self, value): self.native.textColor = native_color(value) @@ -31,12 +33,32 @@ def set_background_color(self, color): def set_font(self, font): self.native.font = font._impl.native + def get_text(self): + value = str(self.native.text) + if value == "\u200B": + return "" + return value + def set_text(self, value): - self.native.text = self.interface.text + if value == "": + value = "\u200B" + self.native.text = value + # Tell the text layout algorithm how many lines are allowed + self.native.numberOfLines = len(self.interface.text.split("\n")) def rehint(self): - # Width & height of a label is known and fixed. - # print("REHINT label", self, self.native.fittingSize().width, self.native.fittingSize().height) + # iOS text layout is an interplay between the layout constraints and the + # text layout algorithm. If the layout constraints fix the width, this + # can cause the text layout algorithm to try word wrapping to make text + # fit. To avoid this, temporarily relax the width and height constraint + # on the widget to "effectively infinite" values; they will be + # re-applied as part of the application of the newly hinted layout. + if self.constraints: + if self.constraints.width_constraint: + self.constraints.width_constraint.constant = 100000 + if self.constraints.height_constraint: + self.constraints.height_constraint.constant = 100000 fitting_size = self.native.systemLayoutSizeFittingSize(CGSize(0, 0)) - self.interface.intrinsic.width = at_least(fitting_size.width) - self.interface.intrinsic.height = fitting_size.height + # print(f"REHINT label {self} {self.get_text()!r} {fitting_size.width} {fitting_size.height}") + self.interface.intrinsic.width = at_least(ceil(fitting_size.width)) + self.interface.intrinsic.height = ceil(fitting_size.height) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 3f1afabca3..db2ce267a1 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -45,8 +45,8 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") - def assert_alignment_equivalent(self, actual, expected): - assert actual == expected + def assert_alignment(self, expected): + assert self.alignment == expected def assert_font_family(self, expected): assert self.font.family == { diff --git a/iOS/tests_backend/widgets/label.py b/iOS/tests_backend/widgets/label.py index 2614e80f84..e2d870bbdc 100644 --- a/iOS/tests_backend/widgets/label.py +++ b/iOS/tests_backend/widgets/label.py @@ -10,7 +10,10 @@ class LabelProbe(SimpleProbe): @property def text(self): - return str(self.native.text) + value = str(self.native.text) + if value == "\u200B": + return "" + return value @property def color(self): diff --git a/testbed/tests/data.py b/testbed/tests/data.py index de1dcbbb12..0af222dfd1 100644 --- a/testbed/tests/data.py +++ b/testbed/tests/data.py @@ -1,6 +1,19 @@ from toga.colors import rgba -TEXTS = ["", " ", "a", "ab", "abc", "hello world", "hello\nworld", "你好, wørłd!"] +# The text examples must both increase and decrease in size between examples to +# ensure that reducing the size of a label doesn't prevent future labels from +# increasing in size. +TEXTS = [ + "example", + "", + "a", + " ", + "ab", + "abc", + "hello world", + "hello\nworld", + "你好, wørłd!", +] COLORS = [ diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index ed49b368e4..ca0d967170 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -1,5 +1,6 @@ from toga.colors import RED, TRANSPARENT, color as named_color from toga.fonts import BOLD, FANTASY, ITALIC, NORMAL, SERIF, SYSTEM +from toga.style.pack import COLUMN from ..assertions import assert_color from ..data import COLORS, TEXTS @@ -144,3 +145,42 @@ async def test_background_color_transparent(widget, probe): widget.style.background_color = TRANSPARENT await probe.redraw() assert_color(probe.background_color, TRANSPARENT) + + +async def test_flex_horizontal_widget_size(widget, probe): + "Check that a widget that is flexible in the horizontal axis resizes as expected" + # Container is initially a non-flex row box. + # Initial widget size is small (but non-zero), based on content size. + await probe.redraw() + assert 10 <= probe.width <= 150, f"Width ({probe.width}) not in range (10, 150)" + assert 10 <= probe.height <= 50, f"Height ({probe.height}) not in range (10, 50)" + + # Make the widget flexible; it will expand to fill horizontal space + widget.style.flex = 1 + + # widget has expanded width, but has the same height. + await probe.redraw() + assert probe.width > 350 + assert probe.height <= 50 + + # Make the container a flexible column box + # This will make the height the flexible axis + widget.parent.style.direction = COLUMN + + # Widget is still the width of the screen + # and the height hasn't changed + await probe.redraw() + assert probe.width > 350 + assert probe.height <= 50 + + # Set an explicit height and width + widget.style.width = 300 + widget.style.height = 200 + + # Widget is approximately the requested size + # (Definitely less than the window size) + await probe.redraw() + assert 290 <= probe.width <= 330, f"Width ({probe.width}) not in range (290, 330)" + assert ( + 190 <= probe.height <= 230 + ), f"Height ({probe.height}) not in range (190, 230)" diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index 2e7cf83d80..a7b60361a7 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -4,7 +4,6 @@ import toga from toga.colors import TRANSPARENT -from toga.style.pack import COLUMN from ..assertions import assert_color from ..data import TEXTS @@ -13,6 +12,7 @@ test_background_color_reset, test_color, test_color_reset, + test_flex_horizontal_widget_size, test_font, test_font_attrs, test_text_width_change, @@ -56,42 +56,3 @@ async def test_background_color_transparent(widget, probe): widget.style.background_color = TRANSPARENT await probe.redraw() assert_color(probe.background_color, None) - - -async def test_button_size(widget, probe): - "Check that the button resizes" - # Container is initially a non-flex row box. - # Initial button size is small (but non-zero), based on content size. - await probe.redraw() - assert 10 <= probe.width <= 150, f"Width ({probe.width}) not in range (10, 150)" - assert 10 <= probe.height <= 50, f"Height ({probe.height}) not in range (10, 50)" - - # Make the button flexible; it will expand to fill horizontal space - widget.style.flex = 1 - - # Button has expanded width, but has the same height. - await probe.redraw() - assert probe.width > 350 - assert probe.height <= 50 - - # Make the container a flexible column box - # This will make the height the flexible axis - widget.parent.style.direction = COLUMN - - # Button is still the width of the screen - # and the height hasn't changed - await probe.redraw() - assert probe.width > 350 - assert probe.height <= 50 - - # Set an explicit height and width - widget.style.width = 300 - widget.style.height = 200 - - # Button is approximately the requested size - # (Definitely less than the window size) - await probe.redraw() - assert 290 <= probe.width <= 330, f"Width ({probe.width}) not in range (290, 330)" - assert ( - 190 <= probe.height <= 230 - ), f"Height ({probe.height}) not in range (190, 230)" diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py index 781288eee2..7d43b21ae5 100644 --- a/testbed/tests/widgets/test_label.py +++ b/testbed/tests/widgets/test_label.py @@ -1,7 +1,6 @@ -from pytest import approx, fixture, mark +from pytest import approx, fixture import toga -from toga.platform import current_platform from toga.style.pack import CENTER, COLUMN, JUSTIFY, LEFT, LTR, RIGHT, RTL from .properties import ( # noqa: F401 @@ -10,6 +9,7 @@ test_background_color_transparent, test_color, test_color_reset, + test_flex_horizontal_widget_size, test_font, test_font_attrs, test_text, @@ -19,60 +19,69 @@ @fixture async def widget(): - return toga.Label("hello") + return toga.Label("hello, this is a label") -test_text_width_change = mark.skipif( - current_platform in {"linux"}, - reason="resizes not applying correctly", -)(test_text_width_change) - - -# TODO: a `width` test, for any widget whose width depends on its text. -@mark.skip("changing text does not trigger a refresh (#1289)") async def test_multiline(widget, probe): + """If the label contains multiline text, it resizes vertically""" + def make_lines(n): - return "\n".join(f"line{i}" for i in range(n)) + return "\n".join(f"This is line {i}" for i in range(n)) widget.text = make_lines(1) - # TODO: Android at least will need an `await` after each text change, to give the - # native layout a chance to update. + await probe.redraw() line_height = probe.height + # Label should have a signficant width. + assert probe.width > 50 + + # Empty text should not cause the widget to collapse. + widget.text = "" + await probe.redraw() + assert probe.height == line_height + # Label should have almost 0 width + assert probe.width < 10 + widget.text = make_lines(2) + await probe.redraw() assert probe.height == approx(line_height * 2, rel=0.1) line_spacing = probe.height - (line_height * 2) - for n in range(3, 10): + for n in range(3, 6): widget.text = make_lines(n) + await probe.redraw() + # Label height should reflect the number of lines assert probe.height == approx( (line_height * n) + (line_spacing * (n - 1)), rel=0.1, ) + # Label should have a signficant width. + assert probe.width > 50 async def test_alignment(widget, probe): + """Labels honor alignment settings""" # Initial alignment is LEFT, initial direction is LTR widget.parent.style.direction = COLUMN await probe.redraw() - assert probe.alignment == LEFT + probe.assert_alignment(LEFT) for alignment in [RIGHT, CENTER, JUSTIFY]: widget.style.text_align = alignment await probe.redraw() - probe.assert_alignment_equivalent(probe.alignment, alignment) + probe.assert_alignment(alignment) # Clearing the alignment reverts to default alignment of LEFT del widget.style.text_align await probe.redraw() - assert probe.alignment == LEFT + probe.assert_alignment(LEFT) # If text direction is RTL, default alignment is RIGHT widget.style.text_direction = RTL await probe.redraw() - assert probe.alignment == RIGHT + probe.assert_alignment(RIGHT) # If text direction is expliclty LTR, default alignment is LEFT widget.style.text_direction = LTR await probe.redraw() - assert probe.alignment == LEFT + probe.assert_alignment(LEFT) diff --git a/web/src/toga_web/widgets/label.py b/web/src/toga_web/widgets/label.py index 75d2ad9b74..1f81d0b333 100644 --- a/web/src/toga_web/widgets/label.py +++ b/web/src/toga_web/widgets/label.py @@ -5,6 +5,9 @@ class Label(Widget): def create(self): self.native = self._create_native_widget("span") + def get_text(self): + return self.native.innerHTML + def set_text(self, value): self.native.innerHTML = value diff --git a/winforms/src/toga_winforms/widgets/label.py b/winforms/src/toga_winforms/widgets/label.py index 85aafee14b..0dd98d7f1e 100644 --- a/winforms/src/toga_winforms/widgets/label.py +++ b/winforms/src/toga_winforms/widgets/label.py @@ -13,8 +13,11 @@ def create(self): def set_alignment(self, value): self.native.TextAlign = TextAlignment(value) + def get_text(self): + return self.native.Text + def set_text(self, value): - self.native.Text = self.interface._text + self.native.Text = value def set_font(self, font): self.native.Font = font._impl.native diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 84e254c57c..176f7195ce 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -24,8 +24,9 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") - def assert_alignment_equivalent(self, actual, expected): + def assert_alignment(self, expected): # Winforms doesn't have a "Justified" alignment; it falls back to LEFT + actual = self.alignment if expected == JUSTIFY: assert actual == LEFT else: