diff --git a/cocoa/pyproject.toml b/cocoa/pyproject.toml index 8e0fdcf9ac..777d23af25 100644 --- a/cocoa/pyproject.toml +++ b/cocoa/pyproject.toml @@ -104,6 +104,9 @@ TimeInput = "toga_cocoa.widgets.timeinput:TimeInput" Tree = "toga_cocoa.widgets.tree:Tree" WebView = "toga_cocoa.widgets.webview:WebView" +# Scaffolds +Scaffold = "toga_cocoa.scaffolds.base:Scaffold" + # Windows MainWindow = "toga_cocoa.window:MainWindow" Window = "toga_cocoa.window:Window" diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index d855cbbbbe..5cea104354 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -8,6 +8,7 @@ NSLayoutConstraint, NSLayoutRelationGreaterThanOrEqual, NSView, + NSViewController, ) ####################################################################################### @@ -131,3 +132,37 @@ def min_height(self): @min_height.setter def min_height(self, height): self._min_height_constraint.constant = height + + +class ControlledContainer(Container): + def __init__( + self, + min_width=100, + min_height=100, + layout_native=None, + on_refresh=None, + ): + """A container for layouts, wrapped in an NSViewController. + + Creates and enforces minimum size constraints on the container widget. + In addition, wrapped in NSViewController to facilitate attachment to various + other things like an NSSplitViewItem. + + :param min_width: The minimum width to enforce on the container + :param min_height: The minimum height to enforce on the container + :param layout_native: The native widget that should be used to provide size + hints to the layout. By default, this will usually be the container widget + itself; however, for widgets like ScrollContainer where the layout needs to + be computed based on a different size to what will be rendered, the source + of the size can be different. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. + """ + super().__init__( + min_width=min_width, + min_height=min_height, + layout_native=layout_native, + on_refresh=on_refresh, + ) + self.controller = NSViewController.alloc().init() + self.controller.view = self.native diff --git a/cocoa/src/toga_cocoa/libs/appkit.py b/cocoa/src/toga_cocoa/libs/appkit.py index ad63de7130..ea8c035140 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -750,6 +750,10 @@ def NSTextAlignment(alignment): NSBezelBorder = 2 NSGrooveBorder = 3 +###################################################################### +# NSViewController.h +NSViewController = ObjCClass("NSViewController") + ###################################################################### # NSWindow.h NSWindow = ObjCClass("NSWindow") @@ -839,3 +843,8 @@ class NSDatePickerStyle(IntEnum): # NSDatePicker.h NSDatePicker = ObjCClass("NSDatePicker") + +###################################################################### +# NSKeyValueBinding.h + +NSTitleBinding = objc_const(appkit, "NSTitleBinding") diff --git a/cocoa/src/toga_cocoa/scaffolds/__init__.py b/cocoa/src/toga_cocoa/scaffolds/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py new file mode 100644 index 0000000000..46c7f132bd --- /dev/null +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -0,0 +1,33 @@ +from toga_cocoa.container import ControlledContainer + + +class Scaffold: + def __init__(self, interface): + self.interface = interface + self.container = ControlledContainer(on_refresh=self.content_refreshed) + # Expose the root controller for the window to embed + self.root_controller = self.container.controller + + @property + def current_container(self): + return self.container + + def set_content(self, widget): + self.container.content = widget + + @property + def title(self): + return self.container.controller.title + + @title.setter + def title(self, value): + self.container.controller.title = value + + def refresh(self): + if self.container.content: + self.container.content.interface.refresh() + + def content_refreshed(self, container): + # Apply the minimum size. This will autoresize the window if needed. + self.container.min_width = self.interface.content.layout.min_width + self.container.min_height = self.interface.content.layout.min_height diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index 6a2316d2a0..a0f3027d46 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -190,16 +190,19 @@ def set_background_color(self, color): self.native_input.bezeled = True self.native_input.backgroundColor = native_color(color) + @property def has_focus(self): # When the NSTextField gets focus, a field editor is created, and that editor # has the original widget as the delegate. The first responder is the # Field Editor. - return isinstance(self.native.window.firstResponder, NSTextView) and ( - self.native.window.firstResponder.delegate == self.native_input + return ( + self.native.window is not None + and isinstance(self.native.window.firstResponder, NSTextView) + and (self.native.window.firstResponder.delegate == self.native_input) ) def focus(self): - if not self.has_focus(): + if self.interface.window and not self.has_focus: self.interface.window._impl.native.makeFirstResponder(self.native_input) def get_readonly(self): @@ -224,7 +227,13 @@ def set_max_value(self, value): self.native_stepper.maxValue = float(value) def set_text_align(self, value): + if self.has_focus: + # Drop focus if we're currently focussed, or else alignment setting + # will not work properly with Cocoa + self.interface.window._impl.native.makeFirstResponder(None) self.native_input.alignment = NSTextAlignment(value) + # Refocus after we're done. + self.focus() def set_font(self, font): self.native_input.font = font._impl.native diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py index 0ce459f547..a972808274 100644 --- a/cocoa/src/toga_cocoa/widgets/textinput.py +++ b/cocoa/src/toga_cocoa/widgets/textinput.py @@ -185,6 +185,10 @@ def set_placeholder(self, value): self.native.cell.placeholderString = value def set_text_align(self, value): + if self.has_focus: + # Drop focus if we're currently focussed, or else alignment setting + # will not work properly with Cocoa + self.interface.window._impl.native.makeFirstResponder(None) self.native.alignment = NSTextAlignment(value) # The alert label should be on the trailing edge if value == RIGHT: diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 0f44e4a85b..51d7297d4b 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -12,7 +12,6 @@ from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position -from toga_cocoa.container import Container from toga_cocoa.libs import ( NSBackingStoreBuffered, NSImage, @@ -20,6 +19,7 @@ NSMutableDictionary, NSNumber, NSScreen, + NSTitleBinding, NSToolbar, NSToolbarItem, NSWindow, @@ -135,7 +135,7 @@ def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover # can't ever be invoked - but we need to provide an implementation. allowed = NSMutableArray.alloc().init() for item in self.interface.toolbar: - allowed.addObject_(toolbar_identifier(item)) + allowed.addObject(toolbar_identifier(item)) return allowed @objc_method @@ -151,8 +151,8 @@ def toolbarDefaultItemIdentifiers_(self, toolbar): and item.group != prev_group and not isinstance(item, Separator) ): - default.addObject_(toolbar_identifier(prev_group)) - default.addObject_(toolbar_identifier(item)) + default.addObject(toolbar_identifier(prev_group)) + default.addObject(toolbar_identifier(item)) prev_group = item.group return default @@ -167,7 +167,7 @@ def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( """Create the requested toolbar button.""" try: item = self.impl._toolbar_items[str(identifier)] - native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) + native = NSToolbarItem.alloc().initWithItemIdentifier(identifier) native.setLabel(item.text) native.setPaletteLabel(item.text) if item.tooltip: @@ -208,7 +208,7 @@ def onToolbarButtonPress_(self, obj) -> None: class Window: - def __init__(self, interface, title, position, size): + def __init__(self, interface, position, size): self.interface = interface self.interface._impl = self @@ -230,6 +230,12 @@ def __init__(self, interface, title, position, size): backing=NSBackingStoreBuffered, defer=False, ) + self.native.bind( + NSTitleBinding, + toObject=self.native, + withKeyPath="contentViewController.title", + options=None, + ) self.native.interface = self.interface self.native.impl = self @@ -242,29 +248,20 @@ def __init__(self, interface, title, position, size): # Pending Window state transition variable: self._pending_state_transition = None - self.set_title(title) self.set_size(size) self.set_position(position if position is not None else _initial_position()) self.native.delegate = self.native - self.container = Container(on_refresh=self.content_refreshed) - self.native.contentView = self.container.native - - # Ensure that the container renders it's background in the same color as the - # window. - self.native.wantsLayer = True - self.container.native.backgroundColor = self.native.backgroundColor - ###################################################################### # Window properties ###################################################################### def get_title(self): - return str(self.native.title) + return str(self._scaffold.title) def set_title(self, title): - self.native.title = title + self._scaffold.title = title ###################################################################### # Window lifecycle @@ -287,26 +284,23 @@ def show(self): # Window content and resources ###################################################################### - def content_refreshed(self, container): - min_width = self.interface.content.layout.min_width - min_height = self.interface.content.layout.min_height - - # If the minimum layout is bigger than the current window, - # increase the size of the window. + def set_scaffold(self, scaffold): + restore_presentation = False + if self.get_window_state() == WindowState.PRESENTATION: + restore_presentation = True + self.set_window_state(WindowState.NORMAL) frame = self.native.frame - if frame.size.width < min_width and frame.size.height < min_height: - self.set_size((min_width, min_height)) - elif frame.size.width < min_width: - self.set_size((min_width, frame.size.height)) - elif frame.size.height < min_height: - self.set_size((frame.size.width, min_height)) - - self.container.min_width = min_width - self.container.min_height = min_height - - def set_content(self, widget): + # Get the current title and sync it up with the new scaffold. + # This check is required as the initial scaffold set will not have + # a previous scaffold to grab title from. + if hasattr(self, "_scaffold"): + scaffold.title = self.get_title() + self._scaffold = scaffold # Set the content of the window's container - self.container.content = widget + self.native.contentViewController = scaffold.root_controller + self.native.setFrame(frame, display=True, animate=False) + if restore_presentation: + self.set_window_state(WindowState.PRESENTATION) ###################################################################### # Window size @@ -314,7 +308,7 @@ def set_content(self, widget): def get_size(self) -> Size: if self.interface.state == WindowState.PRESENTATION: - native_frame = self.container.native.frame + native_frame = self._scaffold.current_container.controller.view.frame else: native_frame = self.native.frame return Size(int(native_frame.size.width), int(native_frame.size.height)) @@ -384,7 +378,12 @@ def get_visible(self): def get_window_state(self, in_progress_state=False): if in_progress_state and self._pending_state_transition: return self._pending_state_transition - if self.container.native.isInFullScreenMode(): + # Set scaffold will call get_window_state and back then during init there + # may not be any scaffold yet so we need to check the first condition + if ( + hasattr(self, "_scaffold") + and self._scaffold.current_container.controller.view.isInFullScreenMode() + ): return WindowState.PRESENTATION elif self.native.styleMask & NSWindowStyleMask.FullScreen: return WindowState.FULLSCREEN @@ -469,22 +468,25 @@ def _apply_state(self, target_state): # window._impl.native.contentView is window._impl.container.native. # Hence, we need to go fullscreen on window._impl.container.native # instead. - self.container.native.enterFullScreenMode( + self._scaffold.current_container.controller.view.enterFullScreenMode( self.interface.screen._impl.native, withOptions=opts ) - # Going presentation mode causes the window content to be re-homed in a - # NSFullScreenWindow; Teach the new parent window about its Toga + # Going presentation mode causes the window content to be re-homed in + # a NSFullScreenWindow; Teach the new parent window about its Toga # representations. - self.container.native.window._impl = self - self.container.native.window.interface = self.interface + self._scaffold.current_container.controller.view.window._impl = self + self._scaffold.current_container.controller.view.window.interface = ( + self.interface + ) # Manually trigger the resize event as the original NSWindow's size # remains unchanged, hence the windowDidResize_ would not be notified # when the window goes into presentation mode. self.interface.on_resize() self.interface.content.refresh() - # No need to check for other pending states, since this is fully applied + # No need to check for other pending states, since this is fully + # applied # at this point. self._pending_state_transition = None @@ -503,7 +505,9 @@ def _apply_state(self, target_state): opts.setObject( NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" ) - self.container.native.exitFullScreenModeWithOptions(opts) + self._scaffold.current_container.controller.view.exitFullScreenModeWithOptions( + opts + ) # Manually trigger the resize event as the original NSWindow's size # remains unchanged, hence the windowDidResize_ would not be notified # when the window goes out of the presentation mode. @@ -520,11 +524,12 @@ def _apply_state(self, target_state): ###################################################################### def get_image_data(self): - bitmap = self.container.native.bitmapImageRepForCachingDisplayInRect( - self.container.native.bounds + container = self._scaffold.current_container + bitmap = container.native.bitmapImageRepForCachingDisplayInRect( + container.native.bounds ) - self.container.native.cacheDisplayInRect( - self.container.native.bounds, toBitmapImageRep=bitmap + container.native.cacheDisplayInRect( + container.native.bounds, toBitmapImageRep=bitmap ) # Get a reference to the CGImage from the bitmap @@ -539,8 +544,8 @@ def get_image_data(self): class MainWindow(Window): - def __init__(self, interface, title, position, size): - super().__init__(interface, title, position, size) + def __init__(self, interface, position, size): + super().__init__(interface, position, size) # By default, no toolbar self._toolbar_items = {} diff --git a/cocoa/tests_backend/scaffolds/__init__.py b/cocoa/tests_backend/scaffolds/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cocoa/tests_backend/scaffolds/base.py b/cocoa/tests_backend/scaffolds/base.py new file mode 100644 index 0000000000..4f1ae5e067 --- /dev/null +++ b/cocoa/tests_backend/scaffolds/base.py @@ -0,0 +1,24 @@ +from ..probe import BaseProbe + + +class ScaffoldProbe(BaseProbe): + def __init__(self, scaffold): + super().__init__() + self.window = scaffold.window + self.scaffold = scaffold + self.impl = scaffold._impl + self.container = self.impl.container + + def assert_container_layout(self): + pass + + async def wait_for_layout(self): + # No assertion here by default + await self.redraw(message="Waiting for scaffold layout to complete") + + @property + def content_size(self): + return ( + self.container.native.frame.size.width, + self.container.native.frame.size.height, + ) diff --git a/core/src/toga/scaffolds/base.py b/core/src/toga/scaffolds/base.py index 6b379f0748..430444c244 100644 --- a/core/src/toga/scaffolds/base.py +++ b/core/src/toga/scaffolds/base.py @@ -62,6 +62,17 @@ def content(self) -> Any | None: @content.setter def content(self, value: Widget | None): + # Detach the widget from any parent it is on. This also has the + # effect of removing it from any scaffolds it's indirectly part of. + if value and value.parent: + value.parent.remove(value) + # Now detach from scaffold. This removes the widget from scaffolds + # we're directly part of (since the indirect case would've had a parent) + # which is handled in the previous if which removes the parent, so directly + # setting the root scaffold's content to None is safe here. + if value and value.scaffold: + value.scaffold.content = None + if self._content is not None: # Clear the old content's window, app, and scaffold self._content.window = None diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 3eb979b293..9bc13f1047 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -270,7 +270,6 @@ def __init__( self.factory = get_factory() self._impl = getattr(self.factory, self._WINDOW_CLASS)( interface=self, - title=title if title else self._default_title, position=None if position is None else Position(*position), size=Size(*size), ) @@ -279,6 +278,10 @@ def __init__( App.app.windows.add(self) self.content = content + # On some backends, title is managed by scaffolds; to simplify code in backends, + # scaffolds are assumed to exist, so we need to make sure a scaffold is created + # before title is set. + self.title = title # Set up the event handlers on the interface (overrides the no-op defaults # installed above with the user-supplied handlers). diff --git a/core/tests/scaffolds/__init__.py b/core/tests/scaffolds/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/tests/scaffolds/conftest.py b/core/tests/scaffolds/conftest.py new file mode 100644 index 0000000000..252dbf2808 --- /dev/null +++ b/core/tests/scaffolds/conftest.py @@ -0,0 +1,8 @@ +import pytest + +import toga + + +@pytest.fixture +def window(app): + return toga.Window() diff --git a/core/tests/scaffolds/test_base.py b/core/tests/scaffolds/test_base.py new file mode 100644 index 0000000000..24f2aa24b4 --- /dev/null +++ b/core/tests/scaffolds/test_base.py @@ -0,0 +1,151 @@ +import toga +from toga_dummy.utils import ( + assert_action_performed, + assert_action_performed_with, +) + + +def test_init_without_content(window, app): + """A scaffold can be initialized without content and be used for a window.""" + scaffold = toga.Scaffold() + window.content = scaffold + + assert_action_performed_with(window, "set scaffold", scaffold=scaffold._impl) + assert_action_performed_with(scaffold, "set content", widget=None) + assert_action_performed(scaffold, "refresh") + assert window.content == scaffold + assert window.scaffold == scaffold + assert scaffold.app == app + assert scaffold.window == window + assert scaffold.content is None + + # Add content and it'll work. + content2 = toga.Box() + scaffold.content = content2 + assert content2.scaffold == scaffold + assert_action_performed(scaffold, "refresh") + assert_action_performed(content2, "refresh") + assert window.content == scaffold + assert scaffold.content == content2 + assert content2.window == window + assert content2.app == app + + +def test_init_with_content(window, app): + """A scaffold can be initialized with content and be used for a window.""" + content1 = toga.Box() + scaffold = toga.Scaffold(content1) + window.content = scaffold + + assert_action_performed_with(window, "set scaffold", scaffold=scaffold._impl) + assert_action_performed_with(scaffold, "set content", widget=content1._impl) + assert_action_performed(scaffold, "refresh") + assert_action_performed(content1, "refresh") + assert window.content == scaffold + assert window.scaffold == scaffold + assert scaffold.app == app + assert scaffold.window == window + assert scaffold.content == content1 + assert content1.window == window + assert content1.app == app + assert content1.scaffold == scaffold + + # Change content + content2 = toga.Box() + scaffold.content = content2 + assert content1.scaffold is None + assert content2.scaffold == scaffold + assert_action_performed(scaffold, "refresh") + assert_action_performed(content2, "refresh") + assert window.content == scaffold + assert scaffold.content == content2 + assert content1.window is None + assert content1.app is None + assert content2.window == window + assert content2.app == app + + # Detach content + scaffold.content = None + assert content1.scaffold is None + assert content2.scaffold is None + assert_action_performed(scaffold, "refresh") + assert window.content == scaffold + assert scaffold.content is None + assert content1.window is None + assert content1.app is None + assert content2.window is None + assert content2.app is None + + # Attach content, detach scaffold; scaffold should preserve + # content + scaffold.content = content1 + assert content1.scaffold is scaffold + window.content = None + assert window.content is None + assert scaffold.content == content1 + assert content1.scaffold is scaffold + assert scaffold.window is None + assert scaffold.app is None + assert content1.window is None + assert content1.app is None + + +def test_content_movement(window, app): + """Content may be moved from one scaffold to another scaffold.""" + scaffold1 = toga.Scaffold() + scaffold2 = toga.Scaffold() + + window.content = scaffold1 + + content1 = toga.Box() + content2 = toga.Box() + + scaffold1.content = content1 + scaffold2.content = content2 + + assert content1.scaffold == scaffold1 + assert content2.scaffold == scaffold2 + + # Move scaffold2's content to scaffold1 + scaffold1.content = scaffold2.content + + assert scaffold1.content == content2 + assert scaffold2.content is None + + assert content1.scaffold is None + assert content2.scaffold == scaffold1 + + assert content1.window is None + assert content1.app is None + assert content2.window == window + assert content2.app == app + + +def test_content_implicit_orphan(window, app): + """Implicitly moving a child out of scaffold content does not affect root + content.""" + scaffold1 = toga.Scaffold() + scaffold2 = toga.Scaffold() + + window.content = scaffold1 + + content1 = toga.Box() + child = toga.Box() + content1.add(child) + + scaffold1.content = content1 + + assert content1.scaffold == scaffold1 + assert child.scaffold == scaffold1 + + scaffold2.content = child + + assert scaffold1.content == content1 + assert scaffold2.content == child + + assert content1.scaffold == scaffold1 + assert child.scaffold == scaffold2 + + assert child not in content1.children + assert content1.window == window + assert content1.app == app diff --git a/core/tests/window/test_window.py b/core/tests/window/test_window.py index f370265ab3..4b28b24a81 100644 --- a/core/tests/window/test_window.py +++ b/core/tests/window/test_window.py @@ -71,13 +71,13 @@ def test_window_handler_attrs_initialized_before_impl(app, event_name, monkeypat # none of them raise. real_init = dummy_window.Window.__init__ - def fire_callbacks_during_init(self, interface, title, position, size): + def fire_callbacks_during_init(self, interface, position, size): # Mimic Cocoa / .NET Framework: dispatch every relevant handler # before the platform constructor returns. With the fix in place, # each call invokes a wrapped no-op; without it, AttributeError # bubbles out and __init__ aborts. getattr(interface, event_name)() - real_init(self, interface, title, position, size) + real_init(self, interface, position, size) monkeypatch.setattr(dummy_window.Window, "__init__", fire_callbacks_during_init) @@ -260,71 +260,6 @@ def test_change_content(window, app): assert scaffold3.app == app -def test_scaffold_content(window, app): - """An explicitly initialized scaffold may be used as content for a window.""" - scaffold = toga.Scaffold() - window.content = scaffold - - assert window.content == scaffold - assert window.scaffold == scaffold - assert scaffold.app == app - assert scaffold.window == window - assert scaffold.content is None - assert_action_performed_with(window, "set scaffold", scaffold=scaffold._impl) - assert_action_performed_with(scaffold, "set content", widget=None) - assert_action_performed(scaffold, "refresh") - - # Attach content - content1 = toga.Box() - scaffold.content = content1 - assert content1.scaffold == scaffold - assert_action_performed(scaffold, "refresh") - assert_action_performed(content1, "refresh") - assert window.content == scaffold - assert scaffold.content == content1 - assert content1.window == window - assert content1.app == app - - # Attach new content - content2 = toga.Box() - scaffold.content = content2 - assert content1.scaffold is None - assert content2.scaffold == scaffold - assert_action_performed(scaffold, "refresh") - assert_action_performed(content2, "refresh") - assert window.content == scaffold - assert scaffold.content == content2 - assert content1.window is None - assert content1.app is None - assert content2.window == window - assert content2.app == app - - # Detach content - scaffold.content = None - assert content1.scaffold is None - assert content2.scaffold is None - assert_action_performed(scaffold, "refresh") - assert window.content == scaffold - assert scaffold.content is None - assert content1.window is None - assert content1.app is None - assert content2.window is None - assert content2.app is None - - # Attach content, detach scaffold; scaffold should preserve - # content - scaffold.content = content1 - assert content1.scaffold is scaffold - window.content = None - assert window.content is None - assert scaffold.content == content1 - assert content1.scaffold is scaffold - assert scaffold.window is None - assert scaffold.app is None - assert content1.window is None - assert content1.app is None - - def test_set_position(window): """The position of the window can be set.""" window.position = (123, 456) diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 251470c477..8273119995 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -11,7 +11,7 @@ class Window(LoggedObject): - def __init__(self, interface, title, position, size): + def __init__(self, interface, position, size): super().__init__() self._action(f"create {self.__class__.__name__}") self.interface = interface @@ -19,7 +19,6 @@ def __init__(self, interface, title, position, size): # Currently, there is not a scaffold. self.scaffold = None - self.set_title(title) self.set_position(position if position is not None else _initial_position()) # We cannot store the following values on the EventLog, since they would be diff --git a/examples/layout/layout/app.py b/examples/layout/layout/app.py index bedd46a874..481e51a385 100644 --- a/examples/layout/layout/app.py +++ b/examples/layout/layout/app.py @@ -55,7 +55,11 @@ def startup(self): for _ in range(3): self.add_label() - self.main_window = toga.MainWindow() + # This tiny window size is used to test that forcing a smaller window size than + # minimum size will not succeed, i.e. the window will display at minimum size. + # (100, 100) is used as macOS appears to have trouble when we force a tinier + # size. + self.main_window = toga.MainWindow(size=(100, 100)) self.main_window.content = self.box self.main_window.show() diff --git a/iOS/pyproject.toml b/iOS/pyproject.toml index d96a6c5bf3..0d819dbe71 100644 --- a/iOS/pyproject.toml +++ b/iOS/pyproject.toml @@ -77,6 +77,9 @@ MenuStatusIcon = "toga_iOS.statusicons:MenuStatusIcon" SimpleStatusIcon = "toga_iOS.statusicons:SimpleStatusIcon" StatusIconSet = "toga_iOS.statusicons:StatusIconSet" +# Scaffold +Scaffold = "toga_iOS.scaffolds.base:Scaffold" + # Widgets ActivityIndicator = "toga_iOS.widgets.activityindicator:ActivityIndicator" Box = "toga_iOS.widgets.box:Box" diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index a87d20dfaf..751bbc1289 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -1,3 +1,7 @@ +import contextlib +from functools import partial +from weakref import ref + from toga_iOS.libs import ( NSLayoutAttributeBottom, NSLayoutAttributeLeft, @@ -8,6 +12,19 @@ ) +# Destructors may not be called reliably in testing. +def _remove_constraints( + container_ref, constraints_created, constraint_refs +): # pragma: no cover + if container_ref(): + container = container_ref() + if container.native and constraints_created: + for constraint_ref in constraint_refs: + if constraint_ref(): + constraint = constraint_ref() + container.native.removeConstraint(constraint) + + class Constraints: def __init__(self, widget): """A wrapper object storing the constraints required to position a widget at a @@ -31,7 +48,23 @@ def __init__(self, widget): # Deletion isn't an event we can programmatically invoke; deletion # of constraints can take several iterations before it occurs. def __del__(self): # pragma: nocover - self._remove_constraints() + # If this gets called on the other threads than hilarity ensues. + # So we delegate cleanup to another non-self-bound function and + # use Weakrefs for everything. + with contextlib.suppress(Exception): + self.widget.interface.app.loop.call_soon_threadsafe( + partial( + _remove_constraints, + ref(self.container), + self.constraints_created, + [ + ref(self.width_constraint), + ref(self.height_constraint), + ref(self.left_constraint), + ref(self.top_constraint), + ], + ) + ) def _remove_constraints(self): if self.container: diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index 585e970b65..21cb7a87c7 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -1,7 +1,6 @@ from rubicon.objc import objc_method, objc_property, send_super from .libs import ( - UINavigationController, UIView, UIViewAutoresizing, UIViewController, @@ -163,98 +162,3 @@ def __init__( # Set the controller's view to be the root content widget self.controller.view = self.native - - -class RootContainer(Container): - def __init__( - self, - content=None, - layout_native=None, - on_refresh=None, - on_native_layout=None, - ): - """A bare content container. - - This is a container that *doesn't* include a navigation/title bar at the top. - - :param content: The widget impl that is the container's initial content. - :param layout_native: The native widget that should be used to provide - size hints to the layout. This will usually be the container widget - itself; however, for widgets like ScrollContainer where the layout - needs to be computed based on a different size to what will be - rendered, the source of the size can be different. - :param on_refresh: The callback to be notified when this container's layout is - refreshed. - :param on_native_layout: The callback to be notified when the container's native - widget has finished laying out, i.e. when native values such as size - *may* have changed. - """ - super().__init__( - content=content, - layout_native=layout_native, - on_refresh=on_refresh, - on_native_layout=on_native_layout, - ) - - # Construct a UIViewController to hold the root content - self.controller = UIViewController.alloc().init() - - # Set the controller's view to be the root content widget - self.controller.view = self.native - - @property - def title(self): # pragma: no cover - return self._title - - @title.setter - def title(self, value): - self._title = value - - -class NavigationContainer(Container): - def __init__( - self, - content=None, - layout_native=None, - on_refresh=None, - on_native_layout=None, - ): - """A top level container that provides a navigation/title bar. - - :param content: The widget impl that is the container's initial content. - :param layout_native: The native widget that should be used to provide - size hints to the layout. This will usually be the container widget - itself; however, for widgets like ScrollContainer where the layout - needs to be computed based on a different size to what will be - rendered, the source of the size can be different. - :param on_refresh: The callback to be notified when this container's layout is - refreshed. - :param on_native_layout: The callback to be notified when the container's native - widget has finished laying out, i.e. when native values such as size - *may* have changed. - """ - super().__init__( - content=content, - layout_native=layout_native, - on_refresh=on_refresh, - on_native_layout=on_native_layout, - ) - - # Construct a NavigationController that provides a navigation bar, and - # is able to maintain a stack of navigable content. This is initialized - # with a root UIViewController that is the actual content - self.content_controller = UIViewController.alloc().init() - self.controller = UINavigationController.alloc().initWithRootViewController( - self.content_controller - ) - - # Set the controller's view to be the root content widget - self.content_controller.view = self.native - - @property - def title(self): - return self.controller.topViewController.title - - @title.setter - def title(self, value): - self.controller.topViewController.title = value diff --git a/iOS/src/toga_iOS/scaffolds/base.py b/iOS/src/toga_iOS/scaffolds/base.py new file mode 100644 index 0000000000..b2c9d7c245 --- /dev/null +++ b/iOS/src/toga_iOS/scaffolds/base.py @@ -0,0 +1,108 @@ +from toga_iOS.container import ControlledContainer +from toga_iOS.libs import UINavigationController + + +class Scaffold: + def __init__(self, interface): + self.interface = interface + self.last_refreshed_size = (0, 0) + self._navigation_bar_hidden = False + self.container = ControlledContainer( + on_refresh=self.content_refreshed, on_native_layout=self.on_native_layout + ) + self.nav_controller = UINavigationController.alloc().initWithRootViewController( + self.container.controller + ) + + @property + def navigation_bar_hidden(self): + return self._navigation_bar_hidden + + @property + def current_container(self): + return self.container + + @navigation_bar_hidden.setter + def navigation_bar_hidden(self, hidden): + self._navigation_bar_hidden = hidden + self.nav_controller.setNavigationBarHidden(hidden, animated=True) + + def set_content(self, widget): + self.container.content = widget + + @property + def title(self): + return self.container.controller.title + + @title.setter + def title(self, value): + self.container.controller.title = value + + def refresh(self): + if self.container.content: + self.container.content.interface.refresh() + + def content_refreshed(self, container): + min_width = self.interface.content.layout.min_width + min_height = self.interface.content.layout.min_height + + # An initial layout uses (0, 0); in this case nothing is even being + # shown on screen so we can ignore that safely. + # Else, If the minimum layout is bigger than the current window, log a + # warning + if (container.width, container.height) != (0, 0) and ( + container.width < min_width or container.height < min_height + ): + print( + f"Warning: Window content {(min_width, min_height)} " + f"exceeds available space " + f"{(container.width, container.height)}" + ) + + @property + def window(self): + return self.interface.window._impl if self.interface.window else None + + def notify_resize(self, container): + if (container.width, container.height) != self.last_refreshed_size: + self.last_refreshed_size = (container.width, container.height) + self.window.interface.on_resize() + self.refresh() + + def on_native_layout(self, container): + # If the navigation bar is hidden, then we must query for the size + # of the status bar to use as our inset. + # The testbed will not instantiate a simple app so no-cover the first + # branch + if self.navigation_bar_hidden: # pragma: no cover + # When status bar heights change, a relayout of the window will + # be triggered by the native layer, which is how we can catch this + # and use this value correctly here. + if self.window: + # Do this because of line length... + status_bar_manager = self.window.native.windowScene.statusBarManager + status_bar_height = status_bar_manager.statusBarFrame.size.height + # On iPadOS, the status bar height may not always be in the + # window of the application. This can be detected by seeing if + # the status bar height is influencing the safe area insets of + # the container, as iPadOS window corners are smaller than the + # top status bar. + if container.native.safeAreaInsets.top >= status_bar_height: + container.top_inset = status_bar_height + else: + container.top_inset = 0 + else: + container.top_inset = 0 + else: + # Instead of manually computing the geometry at the top, + # this check is used because iOS's algorithms to place the + # navigation bar at an appropriate height appears to be + # a mystery... also, when the navigation bar metrics change, + # a layout appears to be triggered in the innner subview, + # and that's how we can catch it. + container.top_inset = ( + self.nav_controller.navigationBar.frame.origin.y + + self.nav_controller.navigationBar.frame.size.height + ) + + self.notify_resize(container) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 8db59c108b..73b7517aa4 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -8,7 +8,6 @@ from toga.constants import WindowState from toga.types import Position, Size -from toga_iOS.container import NavigationContainer, RootContainer from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( NSData, @@ -25,22 +24,12 @@ class Window: - def __init__(self, interface, title, position, size): + def __init__(self, interface, position, size): self.interface = interface self.interface._impl = self self.native = UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds) - # Set up a container for the window's content - self.create_container() - self.last_refreshed_size = (0, 0) - - # Set the size of the content to the size of the window - self.container.native.frame = self.native.bounds - - # Set the window's root controller to be the container's controller - self.native.rootViewController = self.container.controller - # Set the background color of the root content. try: # systemBackgroundColor() was introduced in iOS 13 @@ -49,24 +38,17 @@ def __init__(self, interface, title, position, size): except AttributeError: # pragma: no cover self.native.backgroundColor = UIColor.whiteColor - self.set_title(title) - - def create_container(self): - # RootContainer provides a titlebar for the window. - self.container = RootContainer( - on_refresh=self.content_refreshed, - on_native_layout=self.content_native_layout, - ) + self._navigation_bar_hidden = True ###################################################################### # Window properties ###################################################################### def get_title(self): - return str(self.container.title) + return str(self.scaffold.title) def set_title(self, title): - self.container.title = title + self.scaffold.title = title ###################################################################### # Window lifecycle @@ -88,46 +70,15 @@ def show(self): # Window content and resources ###################################################################### - def content_refreshed(self, container): - min_width = self.interface.content.layout.min_width - min_height = self.interface.content.layout.min_height - - # If the minimum layout is bigger than the current window, log a warning - if self.container.width < min_width or self.container.height < min_height: - print( - f"Warning: Window content {(min_width, min_height)} " - f"exceeds available space " - f"{(self.container.width, self.container.height)}" - ) - - def notify_resize(self, container): - if (container.width, container.height) != self.last_refreshed_size: - self.last_refreshed_size = (container.width, container.height) - self.interface.on_resize() - self.interface.content.refresh() - - # The testbed won't instantiate a simple app, so we can't test this - # handler - def content_native_layout(self, container): # pragma: no cover - # When status bar heights change, a relayout of the window will - # be triggered by the native layer, which is how we can catch this - # and use this value correctly here. - status_bar_height = ( - self.native.windowScene.statusBarManager.statusBarFrame.size.height - ) - # On iPadOS, the status bar height may not always be in the - # window of the application. This can be detected by seeing if - # the status bar height is influencing the safe area insets of - # the container, as iPadOS window corners are smaller than the - # top status bar. - if container.native.safeAreaInsets.top >= status_bar_height: - container.top_inset = status_bar_height - else: - container.top_inset = 0 - self.notify_resize(container) - - def set_content(self, widget): - self.container.content = widget + def set_scaffold(self, scaffold): + # Get the current title and sync it up with the new scaffold. + # This check is required as the initial scaffold set will not have + # a previous scaffold to grab title from. + if hasattr(self, "scaffold"): + scaffold.title = self.get_title() + self.scaffold = scaffold + self.native.rootViewController = self.scaffold.nav_controller + self.scaffold.navigation_bar_hidden = self._navigation_bar_hidden ###################################################################### # Window size @@ -221,14 +172,15 @@ def get_image_data(self): # along the way. # # I need a drink. + container = self.scaffold.current_container renderer = UIGraphicsImageRenderer.alloc().initWithSize( - self.container.native.bounds.size + container.native.bounds.size ) def render(context): - self.container.native.drawViewHierarchyInRect( - self.container.native.bounds, afterScreenUpdates=True + container.native.drawViewHierarchyInRect( + container.native.bounds, afterScreenUpdates=True ) # Render the full image @@ -238,11 +190,11 @@ def render(context): # Get the size of the actual content (offsetting for the header) # in raw coordinates. - container_bounds = self.container.content.native.bounds + container_bounds = container.content.native.bounds image_bounds = NSRect( NSPoint( - self.container.left_inset * UIScreen.mainScreen.scale, - self.container.top_inset * UIScreen.mainScreen.scale, + container.left_inset * UIScreen.mainScreen.scale, + container.top_inset * UIScreen.mainScreen.scale, ), NSSize( container_bounds.size.width * UIScreen.mainScreen.scale, @@ -261,26 +213,10 @@ def render(context): class MainWindow(Window): - def create_container(self): - # NavigationContainer provides a titlebar for the window. - self.container = NavigationContainer( - on_refresh=self.content_refreshed, - on_native_layout=self.content_native_layout, - ) - - def content_native_layout(self, container): - # Instead of manually computing the geometry at the top, - # this check is used because iOS's algorithms to place the - # navigation bar at an appropriate height appears to be - # a mystery... also, when the navigation bar metrics change, - # a layout appears to be triggered in the innner subview, - # and that's how we can catch it. - container.top_inset = ( - container.controller.navigationBar.frame.origin.y - + container.controller.navigationBar.frame.size.height - ) - self.notify_resize(container) - def create_toolbar(self): # No toolbar handling at present pass + + def __init__(self, interface, position, size): + super().__init__(interface, position, size) + self._navigation_bar_hidden = False diff --git a/iOS/tests_backend/hardware/camera.py b/iOS/tests_backend/hardware/camera.py index 8b1ad40b6c..d7df9f5ac2 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -133,7 +133,7 @@ def reject_permission(self): self._mock_permissions[str(AVMediaTypeVideo)] = 0 async def wait_for_camera(self, device_count=0): - await self.redraw("Camera view displayed", delay=0.5) + await self.redraw("Camera view displayed", delay=2) @property def shutter_enabled(self): @@ -156,7 +156,7 @@ async def press_shutter_button(self, photo): }, ) - await self.redraw("Photo taken", delay=0.5) + await self.redraw("Photo taken", delay=2) return await photo, picker.cameraDevice, picker.cameraFlashMode @@ -171,7 +171,7 @@ async def cancel_photo(self, photo): # Fake the result of a cancelling the photo picker.delegate.imagePickerControllerDidCancel(picker) - await self.redraw("Photo cancelled", delay=0.5) + await self.redraw("Photo cancelled", delay=2) return await photo diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py index 7fdf345c99..81b2fd1551 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,6 +1,6 @@ import asyncio -from pytest import approx +from tests.conftest import approx import toga from toga_iOS.libs import NSRunLoop, UIScreen @@ -39,4 +39,22 @@ async def redraw(self, message=None, delay=0, wait_for=None): def assert_image_size(self, image_size, size, screen, window=None): # Retina displays render images at a higher resolution than their reported size. scale = int(UIScreen.mainScreen.scale) - assert image_size == (size[0] * scale, size[1] * scale) + assert image_size == ( + approx(size[0] * scale, abs=2), + approx(size[1] * scale, abs=2), + ) + + async def _wait_for_assertion(self, assertion, timeout=5, polling_interval=0.1): + # Loop for up to `timeout` seconds, until assertion() passes without + # raising an AssertionError + loop = asyncio.get_running_loop() + start_time = loop.time() + while (loop.time() - start_time) < timeout: + try: + assertion() + return + except AssertionError as e: + exception = e + await asyncio.sleep(polling_interval) + + raise exception diff --git a/iOS/tests_backend/scaffolds/__init__.py b/iOS/tests_backend/scaffolds/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/iOS/tests_backend/scaffolds/base.py b/iOS/tests_backend/scaffolds/base.py new file mode 100644 index 0000000000..9557de9c6c --- /dev/null +++ b/iOS/tests_backend/scaffolds/base.py @@ -0,0 +1,61 @@ +from rubicon.objc import ObjCClass +from tests.conftest import approx + +from ..probe import BaseProbe + +CATransaction = ObjCClass("CATransaction") + + +class ScaffoldProbe(BaseProbe): + def __init__(self, scaffold): + super().__init__() + self.window = scaffold.window + self.scaffold = scaffold + self.impl = scaffold._impl + self.container = self.impl.container + self.nav_controller = self.impl.nav_controller + + async def redraw(self, message=None, delay=0, wait_for=None): + """Request a redraw of the app, waiting until that redraw has completed.""" + # Force a widget repaint + self.container.native.layer.displayIfNeeded() + + # Flush CoreAnimation; this ensures all animations are complete + # and all constraints have been evaluated. + CATransaction.flush() + + await super().redraw(message=message, delay=delay, wait_for=wait_for) + + async def wait_for_layout(self): + await self._wait_for_assertion(self.assert_container_layout) + + def assert_container_layout(self): + # If the window has been laid out, the origin should be at least at the + # position of the top bar height. + assert self.container.content.native.frame.origin.y >= approx( + self.top_bar_height + ) + assert self.container.content.native.frame.size.width > 0 + assert self.container.content.native.frame.size.height > 0 + + @property + def top_bar_height(self): + return ( + self.nav_controller.navigationBar.frame.origin.y + + self.nav_controller.navigationBar.frame.size.height + ) + + @property + def content_size(self): + # As a test, assert that our content is not overlapping the top bar. + self.assert_container_layout() + + # Size does not include bars. + return ( + self.nav_controller.view.frame.size.width, + self.nav_controller.view.frame.size.height + - ( + self.nav_controller.navigationBar.frame.origin.y + + self.nav_controller.navigationBar.frame.size.height + ), + ) diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index a49d212b99..7f22914957 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -1,12 +1,12 @@ -import asyncio - import pytest -from tests.conftest import approx from toga_iOS.libs import UIWindow from .dialogs import DialogsMixin from .probe import BaseProbe +from .scaffolds.base import ScaffoldProbe + +SCAFFOLD_PROBE_CACHE = {} class WindowProbe(BaseProbe, DialogsMixin): @@ -23,28 +23,6 @@ def __init__(self, app, window): self.native = window._impl.native assert isinstance(self.native, UIWindow) - async def _wait_for_assertion(self, assertion, timeout=5, polling_interval=0.1): - # Loop for up to `timeout` seconds, until assertion() passes without - # raising an AssertionError - loop = asyncio.get_running_loop() - start_time = loop.time() - while (loop.time() - start_time) < timeout: - try: - assertion() - return - except AssertionError as e: - exception = e - await asyncio.sleep(polling_interval) - - raise exception - - def _assert_container_layout(self): - # If the window has been laid out, the origin should be at least at the - # position of the top bar height. - assert self.impl.container.content.native.frame.origin.y >= approx( - self.top_bar_height - ) - def _assert_window_state(self, state): # Create an assertion function that the window's instantaneous state is a # specific required value. @@ -55,10 +33,12 @@ def _state_assertion(): async def wait_for_window(self, message, state=None): await self.redraw(message) - - # There may be some internal rendering delays that mean the container's content - # hasn't undergone full layout; wait for that to occur. - await self._wait_for_assertion(self._assert_container_layout) + if self.window.scaffold not in SCAFFOLD_PROBE_CACHE: + SCAFFOLD_PROBE_CACHE[self.window.scaffold] = ScaffoldProbe( + self.window.scaffold + ) + scaffold_probe = SCAFFOLD_PROBE_CACHE[self.window.scaffold] + await scaffold_probe.wait_for_layout() # If a specific window state has been requested, wait for that state to occur. if state: @@ -68,28 +48,6 @@ async def cleanup(self): self.window.close() await self.redraw("Closing window") - @property - def content_size(self): - # As a test, assert that our content is not overlapping the top bar. - self._assert_container_layout() - - # Content height doesn't include the status bar or navigation bar. - return ( - self.native.contentView.frame.size.width, - self.native.contentView.frame.size.height - - ( - self.native.rootViewController.navigationBar.frame.origin.y - + self.native.rootViewController.navigationBar.frame.size.height - ), - ) - - @property - def top_bar_height(self): - return ( - self.native.rootViewController.navigationBar.frame.origin.y - + self.native.rootViewController.navigationBar.frame.size.height - ) - @property def instantaneous_state(self): return self.impl.get_window_state(in_progress_state=False) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 91884135bb..1a2f97f23e 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -16,6 +16,8 @@ test = [ "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-retry==1.7.0", + # This enables people to debug intermittent hard crash errors in CI by enabling --instafail in the test options. + "pytest-instafail==0.5.0", ] [tool.briefcase] diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index b52e6dd93e..2c1b2e0786 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -3,9 +3,10 @@ from unittest.mock import Mock import pytest +from tests_backend.scaffolds.base import ScaffoldProbe import toga -from toga import Position, Size +from toga import Position, Scaffold, Size from toga.colors import CORNFLOWERBLUE, FIREBRICK, GOLDENROD, REBECCAPURPLE from toga.constants import WindowState from toga.style.pack import Pack @@ -267,10 +268,11 @@ async def test_presentation_mode(app, app_probe, main_window, main_window_probe) window_information = {} window_information["window"] = window window_information["window_probe"] = window_probe(app, window) + window_information["scaffold_probe"] = ScaffoldProbe(window.scaffold) window_information["initial_screen"] = window_information["window"].screen window_information["paired_screen"] = app.screens[i] window_information["initial_content_size"] = window_information[ - "window_probe" + "scaffold_probe" ].content_size window_information["widget_probe"] = get_probe(window_widget) window_information["initial_widget_size"] = ( @@ -300,10 +302,10 @@ async def test_presentation_mode(app, app_probe, main_window, main_window_probe) ), f"{window_information['window'].title}:" # 1000x700 is bigger than the original window size, # while being smaller than any likely screen. - assert window_information["window_probe"].content_size[0] > 1000, ( + assert window_information["scaffold_probe"].content_size[0] > 1000, ( f"{window_information['window'].title}:" ) - assert window_information["window_probe"].content_size[1] > 700, ( + assert window_information["scaffold_probe"].content_size[1] > 700, ( f"{window_information['window'].title}:" ) assert ( @@ -332,7 +334,7 @@ async def test_presentation_mode(app, app_probe, main_window, main_window_probe) window_information["window_probe"].instantaneous_state == WindowState.NORMAL ), f"{window_information['window'].title}:" assert ( - window_information["window_probe"].content_size + window_information["scaffold_probe"].content_size == window_information["initial_content_size"] ), f"{window_information['window'].title}:" assert ( @@ -346,6 +348,35 @@ async def test_presentation_mode(app, app_probe, main_window, main_window_probe) ), f"{window_information['window'].title}:" +async def test_presentation_mode_scaffold_change( + app, app_probe, main_window, main_window_probe, scaffold_probe +): + new_scaffold = Scaffold(toga.Box(background_color=CORNFLOWERBLUE)) + new_scaffold_probe = ScaffoldProbe(new_scaffold) + + main_window.state = WindowState.PRESENTATION + await main_window_probe.wait_for_window( + "Main window is now in presentation state", state=WindowState.PRESENTATION + ) + + presentation_content_size = scaffold_probe.content_size + + main_window.content = new_scaffold + await main_window_probe.redraw("Window now has new scaffold attached") + + # Still in presentation + assert main_window_probe.instantaneous_state == WindowState.PRESENTATION + # Content size remains unchanged + assert new_scaffold_probe.content_size == presentation_content_size + + main_window.state = WindowState.NORMAL + await main_window_probe.wait_for_window( + "Main window is no longer in presentation mode", state=WindowState.NORMAL + ) + + assert main_window_probe.instantaneous_state == WindowState.NORMAL + + async def test_window_presentation_exit_on_another_window_presentation( app, main_window_probe ): diff --git a/testbed/tests/app/test_mobile.py b/testbed/tests/app/test_mobile.py index bee0ccf6d0..251d5b3e72 100644 --- a/testbed/tests/app/test_mobile.py +++ b/testbed/tests/app/test_mobile.py @@ -14,7 +14,7 @@ pytest.skip("Test is specific to desktop platforms", allow_module_level=True) -async def test_content_size(app, main_window, main_window_probe): +async def test_content_size(app, main_window, main_window_probe, scaffold_probe): """The content size doesn't spill outsize the viewable area.""" box = toga.Box(style=Pack(background_color=REBECCAPURPLE)) @@ -24,7 +24,7 @@ async def test_content_size(app, main_window, main_window_probe): # The overall layout has both the box, plus the top bar. assert main_window.screen.size.height >= ( - box.layout.content_height + main_window_probe.top_bar_height + box.layout.content_height + scaffold_probe.top_bar_height ) # The box is the same width as the screen. assert main_window.screen.size.width == box.layout.content_width diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 3f5a27a0b0..21835587d4 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -98,6 +98,11 @@ def no_dangling_tasks(): assert not tasks, f"the app has dangling tasks: {tasks}" +@fixture(autouse=True) +async def wait_for_layout(scaffold_probe): + await scaffold_probe.wait_for_layout() + + @fixture(scope="session") def app(): return toga.App.app @@ -128,6 +133,7 @@ def main_window(app): @fixture(autouse=True) async def window_cleanup(app, app_probe, main_window, main_window_probe): original_size = main_window.size + original_title = main_window.title # Ensure that at the beginning of every test, all windows that aren't # the main window have been closed and deleted. This needs to be done in @@ -159,6 +165,7 @@ async def window_cleanup(app, app_probe, main_window, main_window_probe): # Reset the window state and size. main_window.state = WindowState.NORMAL main_window.size = original_size + main_window.title = original_title @fixture(scope="session") @@ -176,6 +183,14 @@ async def main_window_probe(app, main_window): main_window.content = old_content +@fixture +async def scaffold_probe(main_window): + # This needs to be late to avoid circular imports + from tests_backend.scaffolds.base import ScaffoldProbe + + return ScaffoldProbe(main_window.scaffold) + + def pytest_asyncio_loop_factories(config, item): return { "proxy": ProxyEventLoop, diff --git a/testbed/tests/scaffolds/__init__.py b/testbed/tests/scaffolds/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/scaffolds/test_base.py b/testbed/tests/scaffolds/test_base.py new file mode 100644 index 0000000000..73721a82de --- /dev/null +++ b/testbed/tests/scaffolds/test_base.py @@ -0,0 +1,45 @@ +from tests_backend.scaffolds.base import ScaffoldProbe + +import toga + + +async def test_no_content(main_window, main_window_probe): + """An empty scaffold can be created and used for a window without errors.""" + scaffold = toga.Scaffold() + main_window.content = scaffold + await main_window_probe.redraw("Setting empty scaffold") + # Should not error + + +async def test_no_change_in_title(app, main_window, main_window_probe): + """A scaffold can be used as window content.""" + # main_window currently has an implicit scaffold; set title + # to make sure to test that it propagates onto new scaffold + main_window.title = "Scaffold testing!" + await main_window_probe.redraw("Setting initial window title") + + scaffold = toga.Scaffold(content=toga.Box()) + scaffold_probe = ScaffoldProbe(scaffold) + main_window.content = scaffold + await main_window_probe.redraw("New scaffold has been set") + + # Should not impact other properties + assert main_window.title == "Scaffold testing!" + + # Scaffold layout is correct + await scaffold_probe.wait_for_layout() + + # Now add a toolbar. If the backend does not implement toolbar then + # the rest of the test would be SKIP but failures would occur before here. + main_window.toolbar.add(app.cmd1, app.cmd2) + await main_window_probe.redraw("Main window has a toolbar") + assert main_window_probe.has_toolbar() + + scaffold = toga.Scaffold(content=toga.Box()) + scaffold_probe = ScaffoldProbe(scaffold) + main_window.content = scaffold + await main_window_probe.redraw("New scaffold has been set") + + # Should be preserved. + # Still has toolbar + assert main_window_probe.has_toolbar() diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 5b67845d4a..5ae2c320fd 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import Mock import pytest +from tests_backend.probe import BaseProbe import toga from toga.style.pack import TOP @@ -42,6 +43,16 @@ async def container_probe(widget): return get_probe(widget.parent) +# Override as widget causes new scaffold to be set +# Must include unused parameter probe so that the window setup will be finished +@pytest.fixture +async def scaffold_probe(widget, probe): + # This needs to be late to avoid circular imports + from tests_backend.scaffolds.base import ScaffoldProbe + + return ScaffoldProbe(widget.scaffold) + + @pytest.fixture async def other(widget): """A separate widget that can take focus""" @@ -147,6 +158,10 @@ async def test_cleanup(): if ref(): print(gc.get_referrers(ref())) + # Sometimes async things are used to make sure cleanup is called from + # UI thread, so... wait_for is needed. + probe = BaseProbe() + await probe.redraw(delay=2, wait_for=lambda: ref() is None) assert ref() is None return test_cleanup diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 78497a1863..bc41f7002d 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -6,9 +6,10 @@ import pytest from pytest import approx +from tests_backend.scaffolds.base import ScaffoldProbe import toga -from toga.colors import CORNFLOWERBLUE, GOLDENROD, LIGHTBLUE, REBECCAPURPLE +from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE from toga.constants import WindowState from toga.style.pack import COLUMN, Pack @@ -45,6 +46,11 @@ async def second_window_probe(app, app_probe, second_window): return probe +@pytest.fixture +async def second_scaffold_probe(second_window): + return ScaffoldProbe(second_window.scaffold) + + def assert_size(window, expected): size = window.size assert isinstance(size.width, int) @@ -96,10 +102,12 @@ async def test_secondary_window(): ): toga.Window() - async def test_move_and_resize(main_window, main_window_probe, capsys): + async def test_move_and_resize( + main_window, main_window_probe, capsys, scaffold_probe + ): """Move and resize are no-ops on mobile.""" + content_size = scaffold_probe.content_size initial_size = main_window.size - content_size = main_window_probe.content_size assert initial_size[0] > 300 assert initial_size[1] > 500 @@ -126,9 +134,11 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): children=[box1, box2], style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), ) + # Changed content so new scaffold is created + new_scaffold_probe = ScaffoldProbe(main_window.scaffold) await main_window_probe.wait_for_window("Main window content has been set") assert_size(main_window, initial_size) - assert main_window_probe.content_size == content_size + assert new_scaffold_probe.content_size == content_size # Alter the content width to exceed window width box1.style.width = 1000 @@ -136,7 +146,7 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): "Content is too wide for the window" ) assert_size(main_window, initial_size) - assert main_window_probe.content_size == content_size + assert new_scaffold_probe.content_size == content_size space_warning = ( r"Warning: Window content \([\d.]+, [\d.]+\) " @@ -148,7 +158,7 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): box1.style.width = 100 await main_window_probe.wait_for_window("Content fits in window") assert_size(main_window, initial_size) - assert main_window_probe.content_size == content_size + assert new_scaffold_probe.content_size == content_size assert not re.search(space_warning, capsys.readouterr().out) # Alter the content width to exceed window height @@ -157,7 +167,7 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): "Content is too tall for the window" ) assert_size(main_window, initial_size) - assert main_window_probe.content_size == content_size + assert new_scaffold_probe.content_size == content_size assert re.search(space_warning, capsys.readouterr().out) finally: @@ -256,7 +266,7 @@ async def test_window_state_same_as_current_without_intermediate_states( ], ) async def test_window_state_content_size_increase( - app, app_probe, main_window, main_window_probe, state + app, app_probe, main_window, main_window_probe, state, scaffold_probe ): """The size of the window content should increase when the window state is set to maximized, fullscreen or presentation.""" @@ -276,7 +286,7 @@ async def test_window_state_content_size_increase( await main_window_probe.wait_for_window("Main window is shown") assert main_window_probe.instantaneous_state == WindowState.NORMAL - initial_content_size = main_window_probe.content_size + initial_content_size = scaffold_probe.content_size main_window.state = state # Add delay to ensure windows are visible after animation. @@ -286,8 +296,8 @@ async def test_window_state_content_size_increase( assert main_window_probe.instantaneous_state == state # At least one of the dimension should have increased. assert ( - main_window_probe.content_size[0] > initial_content_size[0] - or main_window_probe.content_size[1] > initial_content_size[1] + scaffold_probe.content_size[0] > initial_content_size[0] + or scaffold_probe.content_size[1] > initial_content_size[1] ) main_window.state = state @@ -298,8 +308,8 @@ async def test_window_state_content_size_increase( assert main_window_probe.instantaneous_state == state # At least one of the dimension should have increased. assert ( - main_window_probe.content_size[0] > initial_content_size[0] - or main_window_probe.content_size[1] > initial_content_size[1] + scaffold_probe.content_size[0] > initial_content_size[0] + or scaffold_probe.content_size[1] > initial_content_size[1] ) main_window.state = WindowState.NORMAL @@ -308,7 +318,7 @@ async def test_window_state_content_size_increase( f"Main window is not in {state}", state=WindowState.NORMAL ) assert main_window_probe.instantaneous_state == WindowState.NORMAL - assert main_window_probe.content_size == initial_content_size + assert scaffold_probe.content_size == initial_content_size @pytest.mark.parametrize( "state", @@ -670,13 +680,15 @@ async def test_visibility(app, second_window, second_window_probe): ) ], ) - async def test_move_and_resize(second_window, second_window_probe): + async def test_move_and_resize( + second_window, second_window_probe, second_scaffold_probe + ): """A window can be moved and resized.""" # Determine the extra width consumed by window chrome # (e.g., title bars, borders etc) - extra_width = second_window.size[0] - second_window_probe.content_size[0] - extra_height = second_window.size[1] - second_window_probe.content_size[1] + extra_width = second_window.size[0] - second_scaffold_probe.content_size[0] + extra_height = second_window.size[1] - second_scaffold_probe.content_size[1] second_window.position = (150, 50) await second_window_probe.wait_for_window("Secondary window has been moved") @@ -687,7 +699,7 @@ async def test_move_and_resize(second_window, second_window_probe): await second_window_probe.wait_for_window("Secondary window has been resized") # Qt rendering can result in a small change in window size assert_size(second_window, approx((200, 150), abs=2)) - assert second_window_probe.content_size == approx( + assert second_scaffold_probe.content_size == approx( ( 200 - extra_width, 150 - extra_height, @@ -704,8 +716,10 @@ async def test_move_and_resize(second_window, second_window_probe): await second_window_probe.wait_for_window( "Secondary window has had height adjusted due to content" ) + # Recreate scaffold probe as content has changed + second_scaffold_probe = ScaffoldProbe(second_window.scaffold) assert_size(second_window, approx((200, 210 + extra_height), abs=2)) - assert second_window_probe.content_size == approx( + assert second_scaffold_probe.content_size == approx( (200 - extra_width, 210), abs=2 ) @@ -720,8 +734,9 @@ async def test_move_and_resize(second_window, second_window_probe): ) # Alter both height and width to exceed window size at once - box3 = toga.Box(style=Pack(background_color=LIGHTBLUE, width=300, height=90)) - second_window.content.add(box3) + with box1.style.batch_apply(): + box1.style.width = 300 + box2.style.height = 290 await second_window_probe.wait_for_window( "Secondary window has had width and height adjusted due to content" ) @@ -729,7 +744,7 @@ async def test_move_and_resize(second_window, second_window_probe): second_window, approx((300 + extra_width, 300 + extra_height), abs=2), ) - assert second_window_probe.content_size == approx((300, 300), abs=2) + assert second_scaffold_probe.content_size == approx((300, 300), abs=2) # Try to resize to a size less than the content size second_window.size = (200, 150) @@ -740,7 +755,7 @@ async def test_move_and_resize(second_window, second_window_probe): second_window, approx((300 + extra_width, 300 + extra_height), abs=2), ) - assert second_window_probe.content_size == approx((300, 300), abs=2) + assert second_scaffold_probe.content_size == approx((300, 300), abs=2) # FULLSCREEN->MAXIMIZED known to be flaky on x86_64 - see #3897 @pytest.mark.flaky(retries=5, delay=1) @@ -1104,9 +1119,12 @@ async def test_window_state_content_size_increase( # Wait for window animation before assertion. await second_window_probe.wait_for_window("Secondary window is shown") + # Do this here as content is reassigned + second_scaffold_probe = ScaffoldProbe(second_window.scaffold) + assert second_window_probe.instantaneous_state == WindowState.NORMAL assert second_window_probe.is_resizable - initial_content_size = second_window_probe.content_size + initial_content_size = second_scaffold_probe.content_size second_window.state = state # Wait for window animation before assertion. @@ -1114,16 +1132,16 @@ async def test_window_state_content_size_increase( f"Secondary window is in {state}", state=state ) assert second_window_probe.instantaneous_state == state - assert second_window_probe.content_size[0] > initial_content_size[0] - assert second_window_probe.content_size[1] > initial_content_size[1] + assert second_scaffold_probe.content_size[0] > initial_content_size[0] + assert second_scaffold_probe.content_size[1] > initial_content_size[1] second_window.state = state await second_window_probe.wait_for_window( f"Secondary window is still in {state}", state=state ) assert second_window_probe.instantaneous_state == state - assert second_window_probe.content_size[0] > initial_content_size[0] - assert second_window_probe.content_size[1] > initial_content_size[1] + assert second_scaffold_probe.content_size[0] > initial_content_size[0] + assert second_scaffold_probe.content_size[1] > initial_content_size[1] second_window.state = WindowState.NORMAL # Wait for window animation before assertion. @@ -1132,7 +1150,7 @@ async def test_window_state_content_size_increase( ) assert second_window_probe.instantaneous_state == WindowState.NORMAL assert second_window_probe.is_resizable - assert second_window_probe.content_size == initial_content_size + assert second_scaffold_probe.content_size == initial_content_size @pytest.mark.parametrize( "state", @@ -1333,14 +1351,14 @@ async def test_screen(second_window, second_window_probe): assert all(isinstance(val, int) for val in second_window.screen_position) -async def test_as_image(main_window, main_window_probe): +async def test_as_image(main_window, main_window_probe, scaffold_probe): """The window can be captured as a screenshot""" if main_window_probe.supports_as_image: screenshot = main_window.as_image() main_window_probe.assert_image_size( screenshot.size, - main_window_probe.content_size, + scaffold_probe.content_size, screen=main_window.screen, window=main_window, )