Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restructured widget initialization order #2942

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions changes/2942.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The initialization process for widgets has been internally restructured to avoid unnecessary style reapplications.
1 change: 1 addition & 0 deletions changes/2942.removal.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Widgets now create and return their implementations via a ``_create()`` method. A user-created custom widget that inherits from an existing Toga widget and uses its same implementation will require no changes; any user-created widgets that need to specify their own implementation should do so in ``_create()`` and return it. Existing user code inheriting from Widget that assigns its implementation before calling ``super().__init__()`` will continue to function, but give a RuntimeWarning; unfortunately, this change breaks any existing code that doesn't create its implementation until afterward. Such usage will now raise an exception.
2 changes: 0 additions & 2 deletions cocoa/src/toga_cocoa/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ class Widget:
def __init__(self, interface):
super().__init__()
self.interface = interface
self.interface._impl = self
self._container = None
self.constraints = None
self.native = None
self.create()
self.interface.style.reapply()

@abstractmethod
def create(self): ...
Expand Down
1 change: 0 additions & 1 deletion cocoa/src/toga_cocoa/widgets/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ def create(self):
self._icon = None

self.native.buttonType = NSMomentaryPushInButton
self._set_button_style()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was getting called when Button's implementation is created, but it presupposes the existence of the button's style — which isn't there until Button calls super.__init__(). It also gets called when the button's font, bounds, or icon are set, and commenting it out here doesn't affect any testbed tests, so it seems it's still getting sufficiently applied. (I'm betting there are a few spots like this in the other implementation layers, though.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the reordering I just did, this line no longer causes an issue. However... since leaving it out doesn't have any observable effect and the tests all pass, just leave it out I guess?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_set_button_style() will be invoked by set_bounds, which will be invoked at least once after styles have been established, so I think you're right that the call in the constructor is a no-op (or, at least, not required). It may not have been in the past, if styles weren't being consistently re-applied after a font change, height change, or adding an icon.


self.native.target = self.native
self.native.action = SEL("onPress:")
Expand Down
22 changes: 20 additions & 2 deletions core/src/toga/style/applicator.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
from __future__ import annotations

import warnings
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from toga.widgets.base import Widget

# Make sure deprecation warnings are shown by default
warnings.filterwarnings("default", category=DeprecationWarning)


class TogaApplicator:
"""Apply styles to a Toga widget."""

def __init__(self, widget: Widget):
self.widget = widget
def __init__(self, widget: None = None):
if widget is not None:
warnings.warn(
"Widget parameter is deprecated. Applicator will be given a reference "
"to its widget when it is assigned as that widget's applicator.",
DeprecationWarning,
stacklevel=2,
)

@property
def widget(self) -> Widget:
"""The widget to which this applicator is assigned.
Syntactic sugar over the node attribute set by Travertino.
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be worth marking this as deprecated as well - if any code is using it, that code should be notified of the potential problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my first thought too, but — does it do any harm? I actually reverted my initial change of the existing uses of applicator.widget; my thinking being that in the context of Toga, a node should always be a widget, so "widget" is a more specific (and for anyone reading Toga code, more obvious) name for it. Does that track?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - I think that makes sense. Good call.

return self.node

def refresh(self) -> None:
# print("RE-EVALUATE LAYOUT", self.widget)
Expand Down
7 changes: 4 additions & 3 deletions core/src/toga/widgets/activityindicator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Literal
from typing import Any, Literal

from .base import StyleT, Widget

Expand All @@ -22,11 +22,12 @@ def __init__(
"""
super().__init__(id=id, style=style)

self._impl = self.factory.ActivityIndicator(interface=self)

if running:
self.start()

def _create(self) -> Any:
return self.factory.ActivityIndicator(interface=self)

@property
def enabled(self) -> Literal[True]:
"""Is the widget currently enabled? i.e., can the user interact with the widget?
Expand Down
63 changes: 58 additions & 5 deletions core/src/toga/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from builtins import id as identifier
from typing import TYPE_CHECKING, Any, TypeVar
from warnings import warn

from travertino.declaration import BaseStyle
from travertino.node import Node
Expand Down Expand Up @@ -33,18 +34,70 @@ def __init__(
:param style: A style object. If no style is provided, a default style
will be applied to the widget.
"""
super().__init__(
style=style if style else Pack(),
applicator=TogaApplicator(self),
)
super().__init__(style=style if style is not None else Pack())

self._id = str(id if id else identifier(self))
self._window: Window | None = None
self._app: App | None = None
self._impl: Any = None

# Get factory and assign implementation
self.factory = get_platform_factory()

###########################################
# Backwards compatibility for Toga <= 0.4.8
###########################################

# Just in case we're working with a third-party widget created before
# the _create() mechanism was added, which has already defined its
# implementation. We still want to call _create(), to issue the warning and
# inform users about where they should be creating the implementation, but if
# there already is one, we don't want to do the assignment and thus replace it
# with None.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hadn't thought of doing this - nice touch!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither did I! This is why tests are so handy.


impl = self._create()

if not hasattr(self, "_impl"):
self._impl = impl

#############################
# End backwards compatibility
#############################

self.applicator = TogaApplicator()

##############################################
# Backwards compatibility for Travertino 0.3.0
##############################################

# The below if block will execute when using Travertino 0.3.0. For future
# versions of Travertino, these assignments (and the reapply) will already have
# been handled "automatically" by assigning the applicator above; in that case,
# we want to avoid doing a second, redundant style reapplication.

# This whole section can be removed as soon as there's a newer version of
# Travertino to set as Toga's minimum requirement.

if not hasattr(self.applicator, "node"): # pragma: no cover
self.applicator.node = self
self.style._applicator = self.applicator
self.style.reapply()

#############################
# End backwards compatibility
#############################

def _create(self) -> Any:
"""Create a platform-specific implementation of this widget.

A subclass of Widget should redefine this method to return its implementation.
"""
warn(
"Widgets should create and return their implementation in ._create(). This "
"will be an exception in a future version.",
RuntimeWarning,
stacklevel=2,
)

def __repr__(self) -> str:
return f"<{self.__class__.__name__}:0x{identifier(self):x}>"

Expand Down
6 changes: 3 additions & 3 deletions core/src/toga/widgets/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ def __init__(
"""
super().__init__(id=id, style=style)

# Create a platform specific implementation of a Box
self._impl = self.factory.Box(interface=self)

# Children need to be added *after* the impl has been created.
self._children: list[Widget] = []
if children is not None:
self.add(*children)

def _create(self):
return self.factory.Box(interface=self)

@property
def enabled(self) -> bool:
"""Is the widget currently enabled? i.e., can the user interact with the widget?
Expand Down
6 changes: 3 additions & 3 deletions core/src/toga/widgets/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,6 @@ def __init__(
"""
super().__init__(id=id, style=style)

# Create a platform specific implementation of a Button
self._impl = self.factory.Button(interface=self)

# Set a dummy handler before installing the actual on_press, because we do not
# want on_press triggered by the initial value being set
self.on_press = None
Expand All @@ -63,6 +60,9 @@ def __init__(
self.on_press = on_press
self.enabled = enabled

def _create(self) -> Any:
return self.factory.Button(interface=self)

@property
def text(self) -> str:
"""The text displayed on the button.
Expand Down
7 changes: 3 additions & 4 deletions core/src/toga/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1239,14 +1239,10 @@ def __init__(
:param on_alt_release: Initial :any:`on_alt_release` handler.
:param on_alt_drag: Initial :any:`on_alt_drag` handler.
"""

super().__init__(id=id, style=style)

self._context = Context(canvas=self)

# Create a platform specific implementation of Canvas
self._impl = self.factory.Canvas(interface=self)

# Set all the properties
self.on_resize = on_resize
self.on_press = on_press
Expand All @@ -1257,6 +1253,9 @@ def __init__(
self.on_alt_release = on_alt_release
self.on_alt_drag = on_alt_drag

def _create(self) -> Any:
return self.factory.Canvas(interface=self)

@property
def enabled(self) -> Literal[True]:
"""Is the widget currently enabled? i.e., can the user interact with the widget?
Expand Down
6 changes: 3 additions & 3 deletions core/src/toga/widgets/dateinput.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ def __init__(
"""
super().__init__(id=id, style=style)

# Create a platform specific implementation of a DateInput
self._impl = self.factory.DateInput(interface=self)

self.on_change = None
self.min = min
self.max = max

self.value = value
self.on_change = on_change

def _create(self) -> Any:
return self.factory.DateInput(interface=self)

@property
def value(self) -> datetime.date:
"""The currently selected date. A value of ``None`` will be converted into
Expand Down
25 changes: 13 additions & 12 deletions core/src/toga/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ def __init__(
:param on_refresh: Initial :any:`on_refresh` handler.
:param on_delete: **DEPRECATED**; use ``on_primary_action``.
"""
# Prime the attributes and handlers that need to exist when the widget is
# created.
self._accessors = accessors
self._missing_value = missing_value
self._primary_action = primary_action
self._secondary_action = secondary_action
self.on_select = None

self._data: SourceT | ListSource = None

super().__init__(id=id, style=style)

######################################################################
Expand All @@ -104,24 +114,15 @@ def __init__(
# End backwards compatibility.
######################################################################

# Prime the attributes and handlers that need to exist when the
# widget is created.
self._accessors = accessors
self._missing_value = missing_value
self._primary_action = primary_action
self._secondary_action = secondary_action
self.on_select = None

self._data: SourceT | ListSource = None

self._impl = self.factory.DetailedList(interface=self)

self.data = data
self.on_primary_action = on_primary_action
self.on_secondary_action = on_secondary_action
self.on_refresh = on_refresh
self.on_select = on_select

def _create(self) -> Any:
return self.factory.DetailedList(interface=self)

@property
def enabled(self) -> Literal[True]:
"""Is the widget currently enabled? i.e., can the user interact with the widget?
Expand Down
7 changes: 4 additions & 3 deletions core/src/toga/widgets/divider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Literal
from typing import Any, Literal

from toga.constants import Direction

Expand Down Expand Up @@ -29,10 +29,11 @@ def __init__(
"""
super().__init__(id=id, style=style)

# Create a platform specific implementation of a Divider
self._impl = self.factory.Divider(interface=self)
self.direction = direction

def _create(self) -> Any:
return self.factory.Divider(interface=self)

@property
def enabled(self) -> Literal[True]:
"""Is the widget currently enabled? i.e., can the user interact with the widget?
Expand Down
10 changes: 7 additions & 3 deletions core/src/toga/widgets/imageview.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal

from travertino.size import at_least

Expand Down Expand Up @@ -83,12 +83,16 @@ def __init__(
:param style: A style object. If no style is provided, a default style will be
applied to the widget.
"""
super().__init__(id=id, style=style)
# Prime the image attribute
self._image = None
self._impl = self.factory.ImageView(interface=self)

super().__init__(id=id, style=style)

self.image = image

def _create(self) -> Any:
return self.factory.ImageView(interface=self)

@property
def enabled(self) -> Literal[True]:
"""Is the widget currently enabled? i.e., can the user interact with the widget?
Expand Down
8 changes: 5 additions & 3 deletions core/src/toga/widgets/label.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import Any

from .base import StyleT, Widget


Expand All @@ -19,11 +21,11 @@ def __init__(
"""
super().__init__(id=id, style=style)

# Create a platform specific implementation of a Label
self._impl = self.factory.Label(interface=self)

self.text = text

def _create(self) -> Any:
return self.factory.Label(interface=self)

def focus(self) -> None:
"""No-op; Label cannot accept input focus."""
pass
Expand Down
5 changes: 3 additions & 2 deletions core/src/toga/widgets/mapview.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,6 @@ def __init__(
"""
super().__init__(id=id, style=style)

self._impl: Any = self.factory.MapView(interface=self)

self._pins = MapPinSet(self, pins)

if location:
Expand All @@ -169,6 +167,9 @@ def __init__(

self.on_select = on_select

def _create(self) -> Any:
return self.factory.MapView(interface=self)

@property
def location(self) -> toga.LatLng:
"""The latitude/longitude where the map is centered.
Expand Down
Loading