From 89c90b75419f9c0a81cc5eeae8c47c796babfc0d Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 31 Jul 2026 15:07:11 -0500 Subject: [PATCH 01/59] Initial pass at implementing Scaffolds for iOS --- core/src/toga/scaffolds/base.py | 11 +++ core/src/toga/window.py | 5 +- iOS/pyproject.toml | 3 + iOS/src/toga_iOS/constraints.py | 11 ++- iOS/src/toga_iOS/container.py | 96 --------------------- iOS/src/toga_iOS/scaffolds/base.py | 106 +++++++++++++++++++++++ iOS/src/toga_iOS/window.py | 110 +++++------------------- iOS/tests_backend/probe.py | 22 ++++- iOS/tests_backend/scaffolds/__init__.py | 0 iOS/tests_backend/scaffolds/base.py | 66 ++++++++++++++ iOS/tests_backend/window.py | 57 ++---------- testbed/tests/app/test_desktop.py | 9 +- testbed/tests/app/test_mobile.py | 4 +- testbed/tests/conftest.py | 5 ++ testbed/tests/widgets/conftest.py | 5 ++ testbed/tests/window/test_window.py | 32 +++---- 16 files changed, 283 insertions(+), 259 deletions(-) create mode 100644 iOS/src/toga_iOS/scaffolds/base.py create mode 100644 iOS/tests_backend/scaffolds/__init__.py create mode 100644 iOS/tests_backend/scaffolds/base.py 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/iOS/pyproject.toml b/iOS/pyproject.toml index 99965f0870..f80a6fdb98 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..519768d04a 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -31,7 +31,16 @@ 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 test thread hilarity ensues. + # With the addition of the Scaffold layer, collection of this object + # on the test thread seems a lot more common for some reason, so this + # makes things more reliable. + try: + self.widget.interface.app.loop.call_soon_threadsafe( + self._remove_constraints + ) + except Exception: + pass 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..796cee6657 --- /dev/null +++ b/iOS/src/toga_iOS/scaffolds/base.py @@ -0,0 +1,106 @@ +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 not (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. + if self.navigation_bar_hidden: + # 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..5a85864839 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,21 +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 + self._title = "" # Set the background color of the root content. try: @@ -49,24 +39,18 @@ 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 self._title def set_title(self, title): - self.container.title = title + self._title = title + self.scaffold.title = title ###################################################################### # Window lifecycle @@ -88,46 +72,11 @@ 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): + self.scaffold = scaffold + self.native.rootViewController = self.scaffold.nav_controller + self.scaffold.title = self._title + self.scaffold.navigation_bar_hidden = self._navigation_bar_hidden ###################################################################### # Window size @@ -221,14 +170,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 +188,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 +211,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/probe.py b/iOS/tests_backend/probe.py index b1d7a0099a..58a30819db 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,5 +1,7 @@ import asyncio +from tests.conftest import approx + import toga from toga_iOS.libs import NSRunLoop, UIScreen @@ -31,4 +33,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..a4ac83c53b --- /dev/null +++ b/iOS/tests_backend/scaffolds/base.py @@ -0,0 +1,66 @@ +from rubicon.objc import ObjCClass +from tests.conftest import approx + +from ..probe import BaseProbe + +CATransaction = ObjCClass("CATransaction") + + +class BaseScaffoldProbe(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) + + def assert_container_layout(self): ... + + @property + def content_size(self): ... + + async def wait_for_layout(self): + await self._wait_for_assertion(self.assert_container_layout) + + +class ScaffoldProbe(BaseScaffoldProbe): + 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 + ) + + @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..9d93b7e610 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -1,12 +1,10 @@ -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 class WindowProbe(BaseProbe, DialogsMixin): @@ -23,28 +21,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. @@ -53,12 +29,13 @@ def _state_assertion(): return _state_assertion + @property + def scaffold_probe(self): + return ScaffoldProbe(self.window.scaffold) + 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) + await self.scaffold_probe.wait_for_layout() # If a specific window state has been requested, wait for that state to occur. if state: @@ -68,28 +45,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/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index c599f3b39e..303387758b 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -259,10 +259,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"] = window_probe(app, window).scaffold_probe 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"] = ( @@ -292,10 +293,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 ( @@ -324,7 +325,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 ( 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 54ffee0ff7..b80a38ff0e 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -158,6 +158,11 @@ async def main_window_probe(app, main_window): main_window.content = old_content +@fixture +async def scaffold_probe(main_window_probe): + yield main_window_probe.scaffold_probe + + def pytest_asyncio_loop_factories(config, item): return { "proxy": ProxyEventLoop, diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 5b67845d4a..8f20a4fdf7 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 @@ -147,6 +148,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 ff038e401b..12b74f88e9 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -89,10 +89,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.""" initial_size = main_window.size - content_size = main_window_probe.content_size + content_size = scaffold_probe.content_size assert initial_size[0] > 300 assert initial_size[1] > 500 @@ -121,7 +123,7 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): ) 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 scaffold_probe.content_size == content_size # Alter the content width to exceed window width box1.style.width = 1000 @@ -129,7 +131,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 scaffold_probe.content_size == content_size space_warning = ( r"Warning: Window content \([\d.]+, [\d.]+\) " @@ -141,7 +143,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 scaffold_probe.content_size == content_size assert not re.search(space_warning, capsys.readouterr().out) # Alter the content width to exceed window height @@ -150,7 +152,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 scaffold_probe.content_size == content_size assert re.search(space_warning, capsys.readouterr().out) finally: @@ -249,7 +251,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.""" @@ -269,7 +271,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. @@ -279,8 +281,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 @@ -291,8 +293,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 @@ -301,7 +303,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", @@ -1331,14 +1333,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, ) From e815479c6b611b613f9d4072db37005351e3d5b5 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sat, 1 Aug 2026 20:46:09 -0500 Subject: [PATCH 02/59] Core tests passing with refactors. --- core/tests/scaffolds/__init__.py | 0 core/tests/scaffolds/conftest.py | 8 ++ core/tests/scaffolds/test_base.py | 151 ++++++++++++++++++++++++++++++ core/tests/window/test_window.py | 69 +------------- dummy/src/toga_dummy/window.py | 3 +- 5 files changed, 162 insertions(+), 69 deletions(-) create mode 100644 core/tests/scaffolds/__init__.py create mode 100644 core/tests/scaffolds/conftest.py create mode 100644 core/tests/scaffolds/test_base.py 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 From d8b442c21d2312b155d7b5b83bfc83f3351f0b33 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sun, 2 Aug 2026 11:46:43 -0500 Subject: [PATCH 03/59] Add more tests --- testbed/tests/conftest.py | 2 ++ testbed/tests/scaffolds/__init__.py | 0 testbed/tests/scaffolds/test_base.py | 45 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 testbed/tests/scaffolds/__init__.py create mode 100644 testbed/tests/scaffolds/test_base.py diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index b80a38ff0e..bbec4663c7 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -110,6 +110,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 @@ -141,6 +142,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") 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() From 795ab0a4e7a1d6aad4749e3bdb4ce65d18a4f600 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sun, 2 Aug 2026 12:34:08 -0500 Subject: [PATCH 04/59] Rerun commit From a4220a2c8c42739cbe1d047c4cd25868d9f82595 Mon Sep 17 00:00:00 2001 From: John Date: Sun, 2 Aug 2026 13:18:29 -0500 Subject: [PATCH 05/59] Update window.py --- iOS/src/toga_iOS/window.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 5a85864839..b71b46d64b 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -46,7 +46,10 @@ def __init__(self, interface, position, size): ###################################################################### def get_title(self): - return self._title + # This may seem a bit less performant than using self._title, but we do it + # so that it's possible to test in the testbed that the title is properly set + # (as scaffold.title is a direct native retrieval) + return str(self.scaffold.title) def set_title(self, title): self._title = title From 622047b5f4aadcfa6073ab5fd2f8d1eb6324546d Mon Sep 17 00:00:00 2001 From: John Date: Sun, 2 Aug 2026 13:19:04 -0500 Subject: [PATCH 06/59] Update base.py --- iOS/src/toga_iOS/scaffolds/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/iOS/src/toga_iOS/scaffolds/base.py b/iOS/src/toga_iOS/scaffolds/base.py index 796cee6657..3d7abe7cf6 100644 --- a/iOS/src/toga_iOS/scaffolds/base.py +++ b/iOS/src/toga_iOS/scaffolds/base.py @@ -72,7 +72,9 @@ def notify_resize(self, container): 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. - if self.navigation_bar_hidden: + # 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. From 2c5cc8beef94f6cf43d5e4f7d1124e9ef59ce862 Mon Sep 17 00:00:00 2001 From: John Date: Sun, 2 Aug 2026 15:39:32 -0500 Subject: [PATCH 07/59] Empty From d8f7f99243839555c82ad8f5fa626771b6ad6f39 Mon Sep 17 00:00:00 2001 From: John Date: Sun, 2 Aug 2026 18:32:13 -0500 Subject: [PATCH 08/59] Rerun CI again From a875a7683755c988a21729a6a8b338fb84897bd4 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sun, 2 Aug 2026 21:05:33 -0500 Subject: [PATCH 09/59] Bump timeout as workaround --- iOS/tests_backend/hardware/camera.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From c9b6dab2b7bcb2835c4c457b4d0ee2e1b8af3723 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Mon, 3 Aug 2026 16:29:10 -0500 Subject: [PATCH 10/59] wip --- cocoa/src/toga_cocoa/libs/appkit.py | 4 ++++ cocoa/src/toga_cocoa/window.py | 24 ++++++------------------ examples/layout/layout/app.py | 4 ++-- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/cocoa/src/toga_cocoa/libs/appkit.py b/cocoa/src/toga_cocoa/libs/appkit.py index b746348804..db15378eef 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -745,6 +745,10 @@ def NSTextAlignment(alignment): NSBezelBorder = 2 NSGrooveBorder = 3 +###################################################################### +# NSViewController.h +NSViewController = ObjCClass("NSViewController") + ###################################################################### # NSWindow.h NSWindow = ObjCClass("NSWindow") diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 2821365b3d..2ccc3e56c5 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -12,7 +12,7 @@ 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.container import ControlledContainer from toga_cocoa.libs import ( NSBackingStoreBuffered, NSImage, @@ -248,8 +248,8 @@ def __init__(self, interface, title, position, size): self.native.delegate = self.native - self.container = Container(on_refresh=self.content_refreshed) - self.native.contentView = self.container.native + self.container = ControlledContainer(on_refresh=self.content_refreshed) + self.native.contentViewController = self.container.controller # Ensure that the container renders it's background in the same color as the # window. @@ -288,21 +288,9 @@ def show(self): ###################################################################### 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. - 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 + # 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 def set_content(self, widget): # Set the content of the window's container diff --git a/examples/layout/layout/app.py b/examples/layout/layout/app.py index bedd46a874..f7d23f9a3f 100644 --- a/examples/layout/layout/app.py +++ b/examples/layout/layout/app.py @@ -55,9 +55,9 @@ def startup(self): for _ in range(3): self.add_label() - self.main_window = toga.MainWindow() - self.main_window.content = self.box + self.main_window = toga.MainWindow(size=(100, 100)) self.main_window.show() + self.main_window.content = self.box def hide_label(self, sender): if self.labels[0].visibility == HIDDEN: From c339fd5760b12c376d3de7f85b3e6ceb72550bf4 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Mon, 3 Aug 2026 17:04:31 -0500 Subject: [PATCH 11/59] Everything except Cocoa toolbars and tests working --- cocoa/pyproject.toml | 3 ++ cocoa/src/toga_cocoa/container.py | 35 ++++++++++++++++++++++ cocoa/src/toga_cocoa/libs/appkit.py | 5 ++++ cocoa/src/toga_cocoa/scaffolds/__init__.py | 0 cocoa/src/toga_cocoa/scaffolds/base.py | 31 +++++++++++++++++++ cocoa/src/toga_cocoa/window.py | 27 ++++++++++++----- 6 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 cocoa/src/toga_cocoa/scaffolds/__init__.py create mode 100644 cocoa/src/toga_cocoa/scaffolds/base.py diff --git a/cocoa/pyproject.toml b/cocoa/pyproject.toml index bfbd2858a2..764b56a874 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 db15378eef..bca4d029b2 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -838,3 +838,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..f7bdefb1dd --- /dev/null +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -0,0 +1,31 @@ +from toga_cocoa.container import ControlledContainer + + +class Scaffold: + def __init__(self, interface): + self.interface = interface + self.container = ControlledContainer(on_refresh=self.content_refreshed) + + @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/window.py b/cocoa/src/toga_cocoa/window.py index 2ccc3e56c5..7f6c67aba3 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -20,6 +20,7 @@ NSMutableDictionary, NSNumber, NSScreen, + NSTitleBinding, NSToolbar, NSToolbarItem, NSWindow, @@ -208,10 +209,12 @@ 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 + self._title = "" + mask = NSWindowStyleMask.Titled if self.interface.closable: mask |= NSWindowStyleMask.Closable @@ -230,6 +233,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,7 +251,6 @@ 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()) @@ -261,10 +269,11 @@ def __init__(self, interface, title, position, size): ###################################################################### def get_title(self): - return str(self.native.title) + return str(self._scaffold.title) def set_title(self, title): - self.native.title = title + self._title = title + self._scaffold.title = title ###################################################################### # Window lifecycle @@ -292,9 +301,11 @@ def content_refreshed(self, container): self.container.min_width = self.interface.content.layout.min_width self.container.min_height = self.interface.content.layout.min_height - def set_content(self, widget): + def set_scaffold(self, scaffold): + self._scaffold = scaffold # Set the content of the window's container - self.container.content = widget + self.native.contentViewController = scaffold.container.controller + scaffold.title = self._title ###################################################################### # Window size @@ -527,8 +538,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 = {} From 5bc23478fe247989bcbb19d445722823aa4ce327 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Mon, 3 Aug 2026 19:32:19 -0500 Subject: [PATCH 12/59] Fix things but presentation mode is now broken --- cocoa/src/toga_cocoa/scaffolds/base.py | 147 +++++++++++++ cocoa/src/toga_cocoa/window.py | 281 +++++++------------------ 2 files changed, 220 insertions(+), 208 deletions(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index f7bdefb1dd..a8b5564c8d 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -1,10 +1,99 @@ +from rubicon.objc import SEL, NSObject, objc_method, objc_property + +from toga.command import Command, Separator from toga_cocoa.container import ControlledContainer +from toga_cocoa.libs import NSMutableArray, NSToolbar, NSToolbarItem + + +def toolbar_identifier(cmd): + return f"Toolbar-{type(cmd).__name__}-{id(cmd)}" + + +class ToolbarDelegate(NSObject): + interface = objc_property(object, weak=True) + impl = objc_property(object, weak=True) + + @objc_method + def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover + """Determine the list of available toolbar items.""" + allowed = NSMutableArray.alloc().init() + for item in self.impl.toolbar_commands: + allowed.addObject_(toolbar_identifier(item)) + return allowed + + @objc_method + def toolbarDefaultItemIdentifiers_(self, toolbar): + """Determine the list of toolbar items that will display by default.""" + default = NSMutableArray.alloc().init() + prev_group = None + for item in self.impl.toolbar_commands: + if ( + prev_group is not None + and item.group != prev_group + and not isinstance(item, Separator) + ): + default.addObject_(toolbar_identifier(prev_group)) + default.addObject_(toolbar_identifier(item)) + prev_group = item.group + + return default + + @objc_method + def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( + self, + toolbar, + identifier, + insert: bool, + ): + """Create the requested toolbar button.""" + native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) + try: + item = self.impl._toolbar_items[str(identifier)] + native.setLabel(item.text) + native.setPaletteLabel(item.text) + if item.tooltip: + native.setToolTip(item.tooltip) + if item.icon: + native.setImage(item.icon._impl.native) + + item._impl.native.add(native) + + native.setTarget_(self) + native.setAction_(SEL("onToolbarButtonPress:")) + except KeyError: # Separator items + pass + + return native + + @objc_method + def validateToolbarItem_(self, item) -> bool: + """Confirm if the toolbar item should be enabled.""" + try: + return self.impl._toolbar_items[str(item.itemIdentifier)].enabled + except KeyError: # pragma: nocover + return False + + @objc_method + def onToolbarButtonPress_(self, obj) -> None: + """Invoke the action tied to the toolbar button.""" + item = self.impl._toolbar_items[str(obj.itemIdentifier)] + item.action() class Scaffold: def __init__(self, interface): self.interface = interface self.container = ControlledContainer(on_refresh=self.content_refreshed) + self.root_container = self.container + self._toolbar_items = {} + self._toolbar_commands = [] + self.native_toolbar = None + self.toolbar_delegate = ToolbarDelegate.alloc().init() + self.toolbar_delegate.impl = self + self.toolbar_delegate.interface = self.interface + + def __del_(self): + self.purge_toolbar() @property def current_container(self): @@ -25,6 +114,64 @@ def refresh(self): if self.container.content: self.container.content.interface.refresh() + @property + def toolbar_commands(self): + return self._toolbar_commands + + def notify_toolbar_change(self): + window = self.interface.window + if window is not None and getattr(window, "_impl", None) is not None: + window._impl.update_toolbar(self) + + def create_toolbar(self): + window = self.interface.window + self.purge_toolbar() + + if window is None: + self.native_toolbar = None + self._toolbar_commands = [] + return + + self._toolbar_commands = [] + if hasattr(window, "toolbar"): + self._toolbar_commands.extend(window.toolbar) + + self._toolbar_items = {} + for cmd in self._toolbar_commands: + if isinstance(cmd, Command): + self._toolbar_items[toolbar_identifier(cmd)] = cmd + + if self._toolbar_commands: + self.native_toolbar = NSToolbar.alloc().initWithIdentifier( + f"Toolbar-{id(self)}" + ) + self.native_toolbar.setDelegate(self.toolbar_delegate) + else: + self.native_toolbar = None + + if window.content: + window.content.refresh() + + def purge_toolbar(self): + window = self.interface.window + if window is None: + return + + while self._toolbar_items: + dead_items = [] + _, cmd = self._toolbar_items.popitem() + # Only purge items associated with the current scaffold's + # toolbar delegate. This ensures proper cleanup. + for item_native in cmd._impl.native: + if ( + isinstance(item_native, NSToolbarItem) + and item_native.target == self.toolbar_delegate + ): + dead_items.append(item_native) + + for item_native in dead_items: + cmd._impl.native.remove(item_native) + 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 diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 7f6c67aba3..915ed434a5 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -8,21 +8,14 @@ objc_property, ) -from toga.command import Command, Separator from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position -from toga_cocoa.container import ControlledContainer from toga_cocoa.libs import ( NSBackingStoreBuffered, NSImage, - NSMutableArray, - NSMutableDictionary, - NSNumber, NSScreen, NSTitleBinding, - NSToolbar, - NSToolbarItem, NSWindow, NSWindowStyleMask, core_graphics, @@ -31,10 +24,6 @@ from .screens import Screen as ScreenImpl -def toolbar_identifier(cmd): - return f"Toolbar-{type(cmd).__name__}-{id(cmd)}" - - class TogaWindow(NSWindow): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -124,89 +113,6 @@ def delayedFullScreenExit_(self, sender) -> None: def windowDidExitFullScreen_(self, notification) -> None: self.impl._apply_state(self.impl._pending_state_transition) - ###################################################################### - # Toolbar delegate methods - ###################################################################### - - @objc_method - def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover - """Determine the list of available toolbar items.""" - # This method is required by the Cocoa API, but it's only ever called if the - # toolbar allows user customization. We don't turn that option on so this method - # 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)) - return allowed - - @objc_method - def toolbarDefaultItemIdentifiers_(self, toolbar): - """Determine the list of toolbar items that will display by default.""" - default = NSMutableArray.alloc().init() - prev_group = None - for item in self.interface.toolbar: - # If there's been a group change, and this item isn't a separator, - # add a separator between groups. - if ( - prev_group is not None - and item.group != prev_group - and not isinstance(item, Separator) - ): - default.addObject_(toolbar_identifier(prev_group)) - default.addObject_(toolbar_identifier(item)) - prev_group = item.group - - return default - - @objc_method - def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( - self, - toolbar, - identifier, - insert: bool, - ): - """Create the requested toolbar button.""" - native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) - try: - item = self.impl._toolbar_items[str(identifier)] - native.setLabel(item.text) - native.setPaletteLabel(item.text) - if item.tooltip: - native.setToolTip(item.tooltip) - if item.icon: - native.setImage(item.icon._impl.native) - - item._impl.native.add(native) - - native.setTarget_(self) - native.setAction_(SEL("onToolbarButtonPress:")) - except KeyError: # Separator items - pass - - return native - - @objc_method - def validateToolbarItem_(self, item) -> bool: - """Confirm if the toolbar item should be enabled.""" - try: - return self.impl._toolbar_items[str(item.itemIdentifier)].enabled - except KeyError: # pragma: nocover - # This branch *shouldn't* ever happen; but there's an edge - # case where a toolbar redraw happens in the middle of deleting - # a toolbar item that can't be reliably reproduced, so it sometimes - # happens in testing. - return False - - ###################################################################### - # Toolbar button press delegate methods - ###################################################################### - - @objc_method - def onToolbarButtonPress_(self, obj) -> None: - """Invoke the action tied to the toolbar button.""" - item = self.impl._toolbar_items[str(obj.itemIdentifier)] - item.action() - class Window: def __init__(self, interface, position, size): @@ -256,14 +162,6 @@ def __init__(self, interface, position, size): self.native.delegate = self.native - self.container = ControlledContainer(on_refresh=self.content_refreshed) - self.native.contentViewController = self.container.controller - - # 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 ###################################################################### @@ -296,16 +194,22 @@ def show(self): # Window content and resources ###################################################################### - 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 - def set_scaffold(self, scaffold): + frame = self.native.frame + print(frame) self._scaffold = scaffold # Set the content of the window's container - self.native.contentViewController = scaffold.container.controller + self.native.contentViewController = scaffold.root_container.controller scaffold.title = self._title + self.update_toolbar() + self.native.setFrame(frame, display=True, animate=False) + + def update_toolbar(self): + if self._scaffold is not None: + self._scaffold.create_toolbar() + self.native.setToolbar(self._scaffold.native_toolbar) + else: + self.native.setToolbar(None) ###################################################################### # Window size @@ -313,7 +217,7 @@ def set_scaffold(self, scaffold): def get_size(self) -> Size: if self.interface.state == WindowState.PRESENTATION: - native_frame = self.container.native.frame + native_frame = self.native.contentViewController.view.native.frame else: native_frame = self.native.frame return Size(int(native_frame.size.width), int(native_frame.size.height)) @@ -321,7 +225,7 @@ def get_size(self) -> Size: def set_size(self, size): frame = self.native.frame frame.size = NSSize(size[0], size[1]) - self.native.setFrame(frame, display=True, animate=True) + self.native.setFrame(frame, display=True, animate=False) ###################################################################### # Window position @@ -383,9 +287,10 @@ 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(): - return WindowState.PRESENTATION - elif self.native.styleMask & NSWindowStyleMask.FullScreen: + # if self.container.native.isInFullScreenMode(): + # return WindowState.PRESENTATION + # el + if self.native.styleMask & NSWindowStyleMask.FullScreen: return WindowState.FULLSCREEN elif self.native.isZoomed: return WindowState.MAXIMIZED @@ -456,36 +361,40 @@ def _apply_state(self, target_state): case _, WindowState.FULLSCREEN: self.native.toggleFullScreen(self.native) - case _, WindowState.PRESENTATION: - self._before_presentation_mode_screen = self.interface.screen - opts = NSMutableDictionary.alloc().init() - opts.setObject( - NSNumber.numberWithBool(True), - forKey="NSFullScreenModeAllScreens", - ) - # The widgets are actually added to window._impl.container.native, - # instead of window.content._impl.native. And - # 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.interface.screen._impl.native, withOptions=opts + case _, WindowState.PRESENTATION: # TODO: Merge + raise NotImplementedError( + "Presentation mode is not yet implemented on macOS." ) - - # 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 - # 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 - # at this point. - self._pending_state_transition = None + # self._before_presentation_mode_screen = self.interface.screen + # opts = NSMutableDictionary.alloc().init() + # opts.setObject( + # NSNumber.numberWithBool(True), + # forKey="NSFullScreenModeAllScreens", + # ) + # # The widgets are actually added to window._impl.container.native, + # # instead of window.content._impl.native. And + # # 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.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 + # # representations. + # self.container.native.window._impl = self + # self.container.native.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 + # # at this point. + # self._pending_state_transition = None case WindowState.MAXIMIZED, WindowState.NORMAL: self.native.setIsZoomed(False) @@ -498,32 +407,34 @@ def _apply_state(self, target_state): self.native.toggleFullScreen(self.native) case _: # PRESENTATION -> NORMAL - opts = NSMutableDictionary.alloc().init() - opts.setObject( - NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" - ) - self.container.native.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. - self.interface.on_resize() - self.interface.content.refresh() - - self.interface.screen = self._before_presentation_mode_screen - del self._before_presentation_mode_screen - - self._apply_state(self._pending_state_transition) + pass # TODO: Merge + # opts = NSMutableDictionary.alloc().init() + # opts.setObject( + # NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" + # ) + # self.container.native.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. + # self.interface.on_resize() + # self.interface.content.refresh() + + # self.interface.screen = self._before_presentation_mode_screen + # del self._before_presentation_mode_screen + + # self._apply_state(self._pending_state_transition) ###################################################################### # Window capabilities ###################################################################### 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 @@ -541,55 +452,9 @@ class MainWindow(Window): def __init__(self, interface, position, size): super().__init__(interface, position, size) - # By default, no toolbar - self._toolbar_items = {} - self.native_toolbar = None - - def __del__(self): - self.purge_toolbar() - def create_menus(self): # macOS doesn't have window-level menus pass def create_toolbar(self): - # Purge any existing toolbar items - self.purge_toolbar() - - # Create the new toolbar items. - if self.interface.toolbar: - for cmd in self.interface.toolbar: - if isinstance(cmd, Command): - self._toolbar_items[toolbar_identifier(cmd)] = cmd - - self.native_toolbar = NSToolbar.alloc().initWithIdentifier( - f"Toolbar-{id(self)}" - ) - self.native_toolbar.setDelegate(self.native) - else: - self.native_toolbar = None - - self.native.setToolbar(self.native_toolbar) - - # Adding/removing a toolbar changes the size of the content window. - if self.interface.content: - self.interface.content.refresh() - - def purge_toolbar(self): - while self._toolbar_items: - dead_items = [] - _, cmd = self._toolbar_items.popitem() - # The command might have toolbar representations on multiple window - # toolbars, and may have other representations (at the very least, a menu - # item). Only clean up the representation pointing at *this* window. Do this - # in 2 passes so that we're not modifying the set of native objects while - # iterating over it. - for item_native in cmd._impl.native: - if ( - isinstance(item_native, NSToolbarItem) - and item_native.target == self.native - ): - dead_items.append(item_native) - - for item_native in dead_items: - cmd._impl.native.remove(item_native) + self.update_toolbar() From 46bbfe3ff78ea0d1b3beba364e501560ee4a0f39 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Mon, 3 Aug 2026 19:41:35 -0500 Subject: [PATCH 13/59] Put a band-aid over presentation mode --- cocoa/src/toga_cocoa/scaffolds/base.py | 2 +- cocoa/src/toga_cocoa/window.py | 103 ++++++++++++------------- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index a8b5564c8d..cfeda7ede2 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -84,7 +84,7 @@ class Scaffold: def __init__(self, interface): self.interface = interface self.container = ControlledContainer(on_refresh=self.content_refreshed) - self.root_container = self.container + self.root_controller = self.container.controller self._toolbar_items = {} self._toolbar_commands = [] self.native_toolbar = None diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 915ed434a5..208b5aded3 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -14,6 +14,8 @@ from toga_cocoa.libs import ( NSBackingStoreBuffered, NSImage, + NSMutableDictionary, + NSNumber, NSScreen, NSTitleBinding, NSWindow, @@ -199,7 +201,7 @@ def set_scaffold(self, scaffold): print(frame) self._scaffold = scaffold # Set the content of the window's container - self.native.contentViewController = scaffold.root_container.controller + self.native.contentViewController = scaffold.root_controller scaffold.title = self._title self.update_toolbar() self.native.setFrame(frame, display=True, animate=False) @@ -287,9 +289,8 @@ 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(): - # return WindowState.PRESENTATION - # el + if self._scaffold.root_controller.view.isInFullScreenMode(): + return WindowState.PRESENTATION if self.native.styleMask & NSWindowStyleMask.FullScreen: return WindowState.FULLSCREEN elif self.native.isZoomed: @@ -361,40 +362,37 @@ def _apply_state(self, target_state): case _, WindowState.FULLSCREEN: self.native.toggleFullScreen(self.native) - case _, WindowState.PRESENTATION: # TODO: Merge - raise NotImplementedError( - "Presentation mode is not yet implemented on macOS." + case _, WindowState.PRESENTATION: + self._before_presentation_mode_screen = self.interface.screen + opts = NSMutableDictionary.alloc().init() + opts.setObject( + NSNumber.numberWithBool(True), + forKey="NSFullScreenModeAllScreens", ) - # self._before_presentation_mode_screen = self.interface.screen - # opts = NSMutableDictionary.alloc().init() - # opts.setObject( - # NSNumber.numberWithBool(True), - # forKey="NSFullScreenModeAllScreens", - # ) - # # The widgets are actually added to window._impl.container.native, - # # instead of window.content._impl.native. And - # # 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.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 - # # representations. - # self.container.native.window._impl = self - # self.container.native.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 - # # at this point. - # self._pending_state_transition = None + # The widgets are actually added to window._impl.container.native, + # instead of window.content._impl.native. And + # window._impl.native.contentView is window._impl.container.native. + # Hence, we need to go fullscreen on window._impl.container.native + # instead. + self._scaffold.root_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 + # representations. + self._scaffold.root_controller.view.window._impl = self + self._scaffold.root_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 + # at this point. + self._pending_state_transition = None case WindowState.MAXIMIZED, WindowState.NORMAL: self.native.setIsZoomed(False) @@ -407,22 +405,21 @@ def _apply_state(self, target_state): self.native.toggleFullScreen(self.native) case _: # PRESENTATION -> NORMAL - pass # TODO: Merge - # opts = NSMutableDictionary.alloc().init() - # opts.setObject( - # NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" - # ) - # self.container.native.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. - # self.interface.on_resize() - # self.interface.content.refresh() - - # self.interface.screen = self._before_presentation_mode_screen - # del self._before_presentation_mode_screen - - # self._apply_state(self._pending_state_transition) + opts = NSMutableDictionary.alloc().init() + opts.setObject( + NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" + ) + self._scaffold.root_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. + self.interface.on_resize() + self.interface.content.refresh() + + self.interface.screen = self._before_presentation_mode_screen + del self._before_presentation_mode_screen + + self._apply_state(self._pending_state_transition) ###################################################################### # Window capabilities From 0fcd4bd86da280fbdd4fe769349e821105acd306 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Tue, 4 Aug 2026 08:49:40 -0500 Subject: [PATCH 14/59] Cocoa fixes --- cocoa/src/toga_cocoa/window.py | 2 +- cocoa/tests_backend/scaffolds/__init__.py | 0 cocoa/tests_backend/scaffolds/base.py | 35 ++++++++++++++ iOS/tests_backend/window.py | 6 +-- testbed/tests/app/test_desktop.py | 3 +- testbed/tests/conftest.py | 7 ++- testbed/tests/window/test_window.py | 58 +++++++++++++++-------- 7 files changed, 81 insertions(+), 30 deletions(-) create mode 100644 cocoa/tests_backend/scaffolds/__init__.py create mode 100644 cocoa/tests_backend/scaffolds/base.py diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 208b5aded3..c031b102d1 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -219,7 +219,7 @@ def update_toolbar(self): def get_size(self) -> Size: if self.interface.state == WindowState.PRESENTATION: - native_frame = self.native.contentViewController.view.native.frame + native_frame = self.native.contentViewController.view.frame else: native_frame = self.native.frame return Size(int(native_frame.size.width), int(native_frame.size.height)) 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..0c056694e2 --- /dev/null +++ b/cocoa/tests_backend/scaffolds/base.py @@ -0,0 +1,35 @@ +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 + + async def redraw(self, message=None, delay=0, wait_for=None): + """Request a redraw of the scaffold, waiting until that redraw has completed.""" + # Force a repaint + # view = self.impl.root_controller.view + # view.setNeedsLayout(True) + # view.layoutSubtreeIfNeeded() + # view.setNeedsDisplay(True) + # view.displayIfNeeded() + + await super().redraw(message=message, delay=delay, wait_for=wait_for) + + 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/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 9d93b7e610..fdcbb314fe 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -29,13 +29,9 @@ def _state_assertion(): return _state_assertion - @property - def scaffold_probe(self): - return ScaffoldProbe(self.window.scaffold) - async def wait_for_window(self, message, state=None): await self.redraw(message) - await self.scaffold_probe.wait_for_layout() + await ScaffoldProbe(self.window.scaffold).wait_for_layout() # If a specific window state has been requested, wait for that state to occur. if state: diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index 303387758b..d0f87af910 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest +from tests_backend.scaffolds.base import ScaffoldProbe import toga from toga import Position, Size @@ -259,7 +260,7 @@ 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"] = window_probe(app, window).scaffold_probe + 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[ diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index bbec4663c7..da2dad2816 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -161,8 +161,11 @@ async def main_window_probe(app, main_window): @fixture -async def scaffold_probe(main_window_probe): - yield main_window_probe.scaffold_probe +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): diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 12b74f88e9..77e5ef07e2 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 @@ -38,6 +39,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) @@ -93,8 +99,8 @@ async def test_move_and_resize( main_window, main_window_probe, capsys, scaffold_probe ): """Move and resize are no-ops on mobile.""" - initial_size = main_window.size content_size = scaffold_probe.content_size + initial_size = main_window.size assert initial_size[0] > 300 assert initial_size[1] > 500 @@ -121,9 +127,11 @@ async def test_move_and_resize( children=[box1, box2], style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), ) + # Changed content so new scaffold is created + new_scaffold_probe = ScaffoldProbe(main_window) await main_window_probe.wait_for_window("Main window content has been set") assert_size(main_window, initial_size) - assert scaffold_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 @@ -131,7 +139,7 @@ async def test_move_and_resize( "Content is too wide for the window" ) assert_size(main_window, initial_size) - assert scaffold_probe.content_size == content_size + assert new_scaffold_probe.content_size == content_size space_warning = ( r"Warning: Window content \([\d.]+, [\d.]+\) " @@ -143,7 +151,7 @@ async def test_move_and_resize( box1.style.width = 100 await main_window_probe.wait_for_window("Content fits in window") assert_size(main_window, initial_size) - assert scaffold_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 @@ -152,7 +160,7 @@ async def test_move_and_resize( "Content is too tall for the window" ) assert_size(main_window, initial_size) - assert scaffold_probe.content_size == content_size + assert new_scaffold_probe.content_size == content_size assert re.search(space_warning, capsys.readouterr().out) finally: @@ -665,13 +673,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") @@ -682,7 +692,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, @@ -699,8 +709,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 ) @@ -715,8 +727,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" ) @@ -724,7 +737,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) @@ -735,7 +748,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 +1117,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) + 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 +1130,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 +1148,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", From ea22cca12941cc8c25cd6d837cbe8b3f7e6ca760 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 4 Aug 2026 09:46:41 -0500 Subject: [PATCH 15/59] small additions --- testbed/tests/window/test_window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 77e5ef07e2..8d86cbcb61 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -128,7 +128,7 @@ async def test_move_and_resize( style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), ) # Changed content so new scaffold is created - new_scaffold_probe = ScaffoldProbe(main_window) + 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 new_scaffold_probe.content_size == content_size @@ -1118,7 +1118,7 @@ async def test_window_state_content_size_increase( await second_window_probe.wait_for_window("Secondary window is shown") # Do this here as content is reassigned - second_scaffold_probe = ScaffoldProbe(second_window) + second_scaffold_probe = ScaffoldProbe(second_window.scaffold) assert second_window_probe.instantaneous_state == WindowState.NORMAL assert second_window_probe.is_resizable From 6b9edbae9ec75073d94d6f1f394637fce5495a22 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Tue, 4 Aug 2026 11:46:12 -0500 Subject: [PATCH 16/59] fixes window (i guess?) --- cocoa/src/toga_cocoa/window.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index c031b102d1..bd82107237 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -197,14 +197,20 @@ def show(self): ###################################################################### def set_scaffold(self, scaffold): + restore_presentation = False + if self.get_window_state() == WindowState.PRESENTATION: + restore_presentation = True + # This is instaneous so yay!!! + self.set_window_state(WindowState.NORMAL) frame = self.native.frame - print(frame) self._scaffold = scaffold # Set the content of the window's container self.native.contentViewController = scaffold.root_controller scaffold.title = self._title self.update_toolbar() self.native.setFrame(frame, display=True, animate=False) + if restore_presentation: + self.set_window_state(WindowState.PRESENTATION) def update_toolbar(self): if self._scaffold is not None: @@ -289,7 +295,10 @@ 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._scaffold.root_controller.view.isInFullScreenMode(): + if ( + hasattr(self, "_scaffold") + and self._scaffold.current_container.controller.view.isInFullScreenMode() + ): return WindowState.PRESENTATION if self.native.styleMask & NSWindowStyleMask.FullScreen: return WindowState.FULLSCREEN @@ -374,15 +383,17 @@ 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._scaffold.root_controller.view.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 # representations. - self._scaffold.root_controller.view.window._impl = self - self._scaffold.root_controller.view.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. @@ -409,7 +420,9 @@ def _apply_state(self, target_state): opts.setObject( NSNumber.numberWithBool(True), forKey="NSFullScreenModeAllScreens" ) - self._scaffold.root_controller.view.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. From 1ccaa2d37b6d894ae74cfbb2967426de7edde110 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Tue, 4 Aug 2026 13:06:06 -0500 Subject: [PATCH 17/59] Unrelated fixes for test suite to run properly --- cocoa/src/toga_cocoa/widgets/numberinput.py | 6 +++++- cocoa/src/toga_cocoa/widgets/textinput.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index 6a2316d2a0..a551019eed 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -224,7 +224,11 @@ def set_max_value(self, value): self.native_stepper.maxValue = float(value) def set_text_align(self, value): - self.native_input.alignment = NSTextAlignment(value) + if self.interface.window and 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.setAlignment(NSTextAlignment(value)) 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 4fd30da371..e6a6c8bb1f 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.interface.window and 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: From 8547f6a8ecbbe417199f6679c03c9666a2abc21a Mon Sep 17 00:00:00 2001 From: John Zhou Date: Tue, 4 Aug 2026 14:22:20 -0500 Subject: [PATCH 18/59] Fix coverage --- cocoa/src/toga_cocoa/scaffolds/base.py | 15 ++++++++----- cocoa/src/toga_cocoa/window.py | 7 ++---- testbed/tests/app/test_desktop.py | 31 +++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index cfeda7ede2..1e27daf031 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -118,16 +118,17 @@ def refresh(self): def toolbar_commands(self): return self._toolbar_commands - def notify_toolbar_change(self): - window = self.interface.window - if window is not None and getattr(window, "_impl", None) is not None: - window._impl.update_toolbar(self) + # def notify_toolbar_change(self): + # window = self.interface.window + # if window is not None and getattr(window, "_impl", None) is not None: + # window._impl.update_toolbar(self) def create_toolbar(self): window = self.interface.window self.purge_toolbar() - if window is None: + # Shouldn't happen in normal operations, but just in case + if window is None: # pragma: no cover self.native_toolbar = None self._toolbar_commands = [] return @@ -154,7 +155,9 @@ def create_toolbar(self): def purge_toolbar(self): window = self.interface.window - if window is None: + + # Defensive measure + if window is None: # pragma: no cover return while self._toolbar_items: diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index bd82107237..9c49801d9b 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -213,11 +213,8 @@ def set_scaffold(self, scaffold): self.set_window_state(WindowState.PRESENTATION) def update_toolbar(self): - if self._scaffold is not None: - self._scaffold.create_toolbar() - self.native.setToolbar(self._scaffold.native_toolbar) - else: - self.native.setToolbar(None) + self._scaffold.create_toolbar() + self.native.setToolbar(self._scaffold.native_toolbar) ###################################################################### # Window size diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py index d0f87af910..ffb89447b2 100644 --- a/testbed/tests/app/test_desktop.py +++ b/testbed/tests/app/test_desktop.py @@ -6,7 +6,7 @@ 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 @@ -340,6 +340,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 ): From 57da14c3f6156533567559fa8d3cafc5ba4681d7 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 4 Aug 2026 14:37:36 -0500 Subject: [PATCH 19/59] Fix typo in destructor method name --- cocoa/src/toga_cocoa/scaffolds/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index 1e27daf031..2221cacea8 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -92,7 +92,7 @@ def __init__(self, interface): self.toolbar_delegate.impl = self self.toolbar_delegate.interface = self.interface - def __del_(self): + def __del__(self): self.purge_toolbar() @property From a87b57d8c2311065c7c25bce0b82076ccebc35cd Mon Sep 17 00:00:00 2001 From: John Date: Tue, 4 Aug 2026 22:05:20 -0500 Subject: [PATCH 20/59] Update base.py --- cocoa/src/toga_cocoa/scaffolds/base.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index 2221cacea8..529bbd398e 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -17,7 +17,7 @@ class ToolbarDelegate(NSObject): def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover """Determine the list of available toolbar items.""" allowed = NSMutableArray.alloc().init() - for item in self.impl.toolbar_commands: + for item in self.impl._toolbar_items: allowed.addObject_(toolbar_identifier(item)) return allowed @@ -26,7 +26,7 @@ def toolbarDefaultItemIdentifiers_(self, toolbar): """Determine the list of toolbar items that will display by default.""" default = NSMutableArray.alloc().init() prev_group = None - for item in self.impl.toolbar_commands: + for item in self.impl._toolbar_items: if ( prev_group is not None and item.group != prev_group @@ -114,15 +114,6 @@ def refresh(self): if self.container.content: self.container.content.interface.refresh() - @property - def toolbar_commands(self): - return self._toolbar_commands - - # def notify_toolbar_change(self): - # window = self.interface.window - # if window is not None and getattr(window, "_impl", None) is not None: - # window._impl.update_toolbar(self) - def create_toolbar(self): window = self.interface.window self.purge_toolbar() From e2cdee8459aab064b92f62ce7626e6bae3eaa8d7 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 4 Aug 2026 22:14:38 -0500 Subject: [PATCH 21/59] Update window.py --- cocoa/src/toga_cocoa/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 9c49801d9b..c1915f061d 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -222,7 +222,7 @@ def update_toolbar(self): def get_size(self) -> Size: if self.interface.state == WindowState.PRESENTATION: - native_frame = self.native.contentViewController.view.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)) From 43190a90a44bdb09901309fb6c419fca01bb1198 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 4 Aug 2026 22:31:46 -0500 Subject: [PATCH 22/59] use proper api --- cocoa/src/toga_cocoa/scaffolds/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index 529bbd398e..ffb59f1e9b 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -17,7 +17,7 @@ class ToolbarDelegate(NSObject): def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover """Determine the list of available toolbar items.""" allowed = NSMutableArray.alloc().init() - for item in self.impl._toolbar_items: + for item in self.impl._toolbar_commands: allowed.addObject_(toolbar_identifier(item)) return allowed @@ -26,7 +26,7 @@ def toolbarDefaultItemIdentifiers_(self, toolbar): """Determine the list of toolbar items that will display by default.""" default = NSMutableArray.alloc().init() prev_group = None - for item in self.impl._toolbar_items: + for item in self.impl._toolbar_commands: if ( prev_group is not None and item.group != prev_group From 31e66587e42757ff70a61f24bb5cb2123db3658d Mon Sep 17 00:00:00 2001 From: John Date: Tue, 4 Aug 2026 22:36:56 -0500 Subject: [PATCH 23/59] refactor --- iOS/tests_backend/scaffolds/base.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/iOS/tests_backend/scaffolds/base.py b/iOS/tests_backend/scaffolds/base.py index a4ac83c53b..3e51f4c3d2 100644 --- a/iOS/tests_backend/scaffolds/base.py +++ b/iOS/tests_backend/scaffolds/base.py @@ -6,7 +6,7 @@ CATransaction = ObjCClass("CATransaction") -class BaseScaffoldProbe(BaseProbe): +class ScaffoldProbe(BaseProbe): def __init__(self, scaffold): super().__init__() self.window = scaffold.window @@ -26,16 +26,9 @@ async def redraw(self, message=None, delay=0, wait_for=None): await super().redraw(message=message, delay=delay, wait_for=wait_for) - def assert_container_layout(self): ... - - @property - def content_size(self): ... - async def wait_for_layout(self): await self._wait_for_assertion(self.assert_container_layout) - -class ScaffoldProbe(BaseScaffoldProbe): 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. From 2d91c98722ccf018ab409921b7e9fb82c61944bf Mon Sep 17 00:00:00 2001 From: John Zhou Date: Wed, 5 Aug 2026 08:55:06 -0500 Subject: [PATCH 24/59] Minor fix to constraint removals --- iOS/src/toga_iOS/constraints.py | 35 ++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index 519768d04a..c1af939320 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -1,3 +1,6 @@ +from functools import partial +from weakref import ref + from toga_iOS.libs import ( NSLayoutAttributeBottom, NSLayoutAttributeLeft, @@ -8,6 +11,19 @@ ) +def _remove_constraints(container_ref, constraints_created, constraint_refs): + 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) + container.native.removeConstraint(constraint) + container.native.removeConstraint(constraint) + container.native.removeConstraint(constraint) + + class Constraints: def __init__(self, widget): """A wrapper object storing the constraints required to position a widget at a @@ -31,13 +47,22 @@ 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 - # If this gets called on the test thread hilarity ensues. - # With the addition of the Scaffold layer, collection of this object - # on the test thread seems a lot more common for some reason, so this - # makes things more reliable. + # 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. try: self.widget.interface.app.loop.call_soon_threadsafe( - self._remove_constraints + 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), + ], + ) ) except Exception: pass From 559e0593ee72305798b146ca75300001ce9a9419 Mon Sep 17 00:00:00 2001 From: John Date: Wed, 5 Aug 2026 09:06:43 -0500 Subject: [PATCH 25/59] Update numberinput.py --- cocoa/src/toga_cocoa/widgets/numberinput.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index a551019eed..d89d3e0e29 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -228,7 +228,7 @@ def set_text_align(self, value): # 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.setAlignment(NSTextAlignment(value)) + self.native_input.alignment = NSTextAlignment(value) def set_font(self, font): self.native_input.font = font._impl.native From c451112aa1b4cc4f1a758f2f64d81500ad57b50e Mon Sep 17 00:00:00 2001 From: John Date: Wed, 5 Aug 2026 09:10:34 -0500 Subject: [PATCH 26/59] cleanup --- cocoa/tests_backend/scaffolds/base.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/cocoa/tests_backend/scaffolds/base.py b/cocoa/tests_backend/scaffolds/base.py index 0c056694e2..4f1ae5e067 100644 --- a/cocoa/tests_backend/scaffolds/base.py +++ b/cocoa/tests_backend/scaffolds/base.py @@ -9,17 +9,6 @@ def __init__(self, scaffold): self.impl = scaffold._impl self.container = self.impl.container - async def redraw(self, message=None, delay=0, wait_for=None): - """Request a redraw of the scaffold, waiting until that redraw has completed.""" - # Force a repaint - # view = self.impl.root_controller.view - # view.setNeedsLayout(True) - # view.layoutSubtreeIfNeeded() - # view.setNeedsDisplay(True) - # view.displayIfNeeded() - - await super().redraw(message=message, delay=delay, wait_for=wait_for) - def assert_container_layout(self): pass From f771f5cdec214b00e04189c1de49d3ce3cda7afc Mon Sep 17 00:00:00 2001 From: John Date: Wed, 5 Aug 2026 09:33:53 -0500 Subject: [PATCH 27/59] Refactor _remove_constraints function Refactor _remove_constraints function for clarity and reliability. --- iOS/src/toga_iOS/constraints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index c1af939320..bc365ba132 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -11,7 +11,10 @@ ) -def _remove_constraints(container_ref, constraints_created, constraint_refs): +# 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: @@ -19,9 +22,6 @@ def _remove_constraints(container_ref, constraints_created, constraint_refs): if constraint_ref(): constraint = constraint_ref() container.native.removeConstraint(constraint) - container.native.removeConstraint(constraint) - container.native.removeConstraint(constraint) - container.native.removeConstraint(constraint) class Constraints: From 638a8425215451ef6bfa3c635af53fd53867e815 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:04:36 -0500 Subject: [PATCH 28/59] Use an LLM to do some preliminary reversal --- cocoa/src/toga_cocoa/scaffolds/base.py | 141 +------------------------ cocoa/src/toga_cocoa/window.py | 112 ++++++++++++++++++-- 2 files changed, 107 insertions(+), 146 deletions(-) diff --git a/cocoa/src/toga_cocoa/scaffolds/base.py b/cocoa/src/toga_cocoa/scaffolds/base.py index ffb59f1e9b..46c7f132bd 100644 --- a/cocoa/src/toga_cocoa/scaffolds/base.py +++ b/cocoa/src/toga_cocoa/scaffolds/base.py @@ -1,99 +1,12 @@ -from rubicon.objc import SEL, NSObject, objc_method, objc_property - -from toga.command import Command, Separator from toga_cocoa.container import ControlledContainer -from toga_cocoa.libs import NSMutableArray, NSToolbar, NSToolbarItem - - -def toolbar_identifier(cmd): - return f"Toolbar-{type(cmd).__name__}-{id(cmd)}" - - -class ToolbarDelegate(NSObject): - interface = objc_property(object, weak=True) - impl = objc_property(object, weak=True) - - @objc_method - def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover - """Determine the list of available toolbar items.""" - allowed = NSMutableArray.alloc().init() - for item in self.impl._toolbar_commands: - allowed.addObject_(toolbar_identifier(item)) - return allowed - - @objc_method - def toolbarDefaultItemIdentifiers_(self, toolbar): - """Determine the list of toolbar items that will display by default.""" - default = NSMutableArray.alloc().init() - prev_group = None - for item in self.impl._toolbar_commands: - if ( - prev_group is not None - and item.group != prev_group - and not isinstance(item, Separator) - ): - default.addObject_(toolbar_identifier(prev_group)) - default.addObject_(toolbar_identifier(item)) - prev_group = item.group - - return default - - @objc_method - def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( - self, - toolbar, - identifier, - insert: bool, - ): - """Create the requested toolbar button.""" - native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) - try: - item = self.impl._toolbar_items[str(identifier)] - native.setLabel(item.text) - native.setPaletteLabel(item.text) - if item.tooltip: - native.setToolTip(item.tooltip) - if item.icon: - native.setImage(item.icon._impl.native) - - item._impl.native.add(native) - - native.setTarget_(self) - native.setAction_(SEL("onToolbarButtonPress:")) - except KeyError: # Separator items - pass - - return native - - @objc_method - def validateToolbarItem_(self, item) -> bool: - """Confirm if the toolbar item should be enabled.""" - try: - return self.impl._toolbar_items[str(item.itemIdentifier)].enabled - except KeyError: # pragma: nocover - return False - - @objc_method - def onToolbarButtonPress_(self, obj) -> None: - """Invoke the action tied to the toolbar button.""" - item = self.impl._toolbar_items[str(obj.itemIdentifier)] - item.action() 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 - self._toolbar_items = {} - self._toolbar_commands = [] - self.native_toolbar = None - self.toolbar_delegate = ToolbarDelegate.alloc().init() - self.toolbar_delegate.impl = self - self.toolbar_delegate.interface = self.interface - - def __del__(self): - self.purge_toolbar() @property def current_container(self): @@ -114,58 +27,6 @@ def refresh(self): if self.container.content: self.container.content.interface.refresh() - def create_toolbar(self): - window = self.interface.window - self.purge_toolbar() - - # Shouldn't happen in normal operations, but just in case - if window is None: # pragma: no cover - self.native_toolbar = None - self._toolbar_commands = [] - return - - self._toolbar_commands = [] - if hasattr(window, "toolbar"): - self._toolbar_commands.extend(window.toolbar) - - self._toolbar_items = {} - for cmd in self._toolbar_commands: - if isinstance(cmd, Command): - self._toolbar_items[toolbar_identifier(cmd)] = cmd - - if self._toolbar_commands: - self.native_toolbar = NSToolbar.alloc().initWithIdentifier( - f"Toolbar-{id(self)}" - ) - self.native_toolbar.setDelegate(self.toolbar_delegate) - else: - self.native_toolbar = None - - if window.content: - window.content.refresh() - - def purge_toolbar(self): - window = self.interface.window - - # Defensive measure - if window is None: # pragma: no cover - return - - while self._toolbar_items: - dead_items = [] - _, cmd = self._toolbar_items.popitem() - # Only purge items associated with the current scaffold's - # toolbar delegate. This ensures proper cleanup. - for item_native in cmd._impl.native: - if ( - isinstance(item_native, NSToolbarItem) - and item_native.target == self.toolbar_delegate - ): - dead_items.append(item_native) - - for item_native in dead_items: - cmd._impl.native.remove(item_native) - 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 diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index c1915f061d..7671c41b9e 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -8,16 +8,20 @@ objc_property, ) +from toga import Command from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position from toga_cocoa.libs import ( NSBackingStoreBuffered, NSImage, + NSMutableArray, NSMutableDictionary, NSNumber, NSScreen, NSTitleBinding, + NSToolbar, + NSToolbarItem, NSWindow, NSWindowStyleMask, core_graphics, @@ -26,6 +30,10 @@ from .screens import Screen as ScreenImpl +def toolbar_identifier(cmd): + return f"Toolbar-{type(cmd).__name__}-{id(cmd)}" + + class TogaWindow(NSWindow): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -115,6 +123,58 @@ def delayedFullScreenExit_(self, sender) -> None: def windowDidExitFullScreen_(self, notification) -> None: self.impl._apply_state(self.impl._pending_state_transition) + ###################################################################### + # Toolbar delegate methods handled by the native window + ###################################################################### + + @objc_method + def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover + allowed = NSMutableArray.alloc().init() + for item in self.interface.toolbar: + allowed.addObject_(toolbar_identifier(item)) + return allowed + + @objc_method + def toolbarDefaultItemIdentifiers_(self, toolbar): + default = NSMutableArray.alloc().init() + for item in self.interface.toolbar: + default.addObject_(toolbar_identifier(item)) + return default + + @objc_method + def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( + self, toolbar, identifier, insert: bool + ): + native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) + try: + item = self.impl._toolbar_items[str(identifier)] + native.setLabel(item.text) + native.setPaletteLabel(item.text) + if item.tooltip: + native.setToolTip(item.tooltip) + if item.icon: + native.setImage(item.icon._impl.native) + + item._impl.native.add(native) + + native.setTarget_(self) + native.setAction_(SEL("onToolbarButtonPress:")) + except KeyError: + pass + return native + + @objc_method + def validateToolbarItem_(self, item) -> bool: + try: + return self.impl._toolbar_items[str(item.itemIdentifier)].enabled + except KeyError: + return False + + @objc_method + def onToolbarButtonPress_(self, obj) -> None: + item = self.impl._toolbar_items[str(obj.itemIdentifier)] + item.action() + class Window: def __init__(self, interface, position, size): @@ -207,15 +267,10 @@ def set_scaffold(self, scaffold): # Set the content of the window's container self.native.contentViewController = scaffold.root_controller scaffold.title = self._title - self.update_toolbar() self.native.setFrame(frame, display=True, animate=False) if restore_presentation: self.set_window_state(WindowState.PRESENTATION) - def update_toolbar(self): - self._scaffold.create_toolbar() - self.native.setToolbar(self._scaffold.native_toolbar) - ###################################################################### # Window size ###################################################################### @@ -458,10 +513,55 @@ def get_image_data(self): class MainWindow(Window): def __init__(self, interface, position, size): super().__init__(interface, position, size) + # By default, no toolbar + self._toolbar_items = {} + self.native_toolbar = None def create_menus(self): # macOS doesn't have window-level menus pass def create_toolbar(self): - self.update_toolbar() + # Purge any existing toolbar items + self.purge_toolbar() + + # Create the new toolbar items. + if self.interface.toolbar: + for cmd in self.interface.toolbar: + if isinstance(cmd, Command): + self._toolbar_items[toolbar_identifier(cmd)] = cmd + + self.native_toolbar = NSToolbar.alloc().initWithIdentifier( + f"Toolbar-{id(self)}" + ) + self.native_toolbar.setDelegate(self.native) + else: + self.native_toolbar = None + + self.native.setToolbar(self.native_toolbar) + + # Adding/removing a toolbar changes the size of the content window. + if self.interface.content: + self.interface.content.refresh() + + def __del__(self): + self.purge_toolbar() + + def purge_toolbar(self): + while self._toolbar_items: + dead_items = [] + _, cmd = self._toolbar_items.popitem() + # The command might have toolbar representations on multiple window + # toolbars, and may have other representations (at the very least, a menu + # item). Only clean up the representation pointing at *this* window. Do this + # in 2 passes so that we're not modifying the set of native objects while + # iterating over it. + for item_native in cmd._impl.native: + if ( + isinstance(item_native, NSToolbarItem) + and item_native.target == self.native + ): + dead_items.append(item_native) + + for item_native in dead_items: + cmd._impl.native.remove(item_native) From d89821895639330211d7616c8f750c323f27162f Mon Sep 17 00:00:00 2001 From: John Date: Fri, 7 Aug 2026 14:05:31 -0500 Subject: [PATCH 29/59] readd docstring removed by AI --- cocoa/src/toga_cocoa/window.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 7671c41b9e..1089c55fdc 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -124,11 +124,15 @@ def windowDidExitFullScreen_(self, notification) -> None: self.impl._apply_state(self.impl._pending_state_transition) ###################################################################### - # Toolbar delegate methods handled by the native window + # Toolbar delegate methods ###################################################################### @objc_method def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover + """Determine the list of available toolbar items.""" + # This method is required by the Cocoa API, but it's only ever called if the + # toolbar allows user customization. We don't turn that option on so this method + # 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)) From af25698d2f7ca7a9979a02b56486b1c3936d21c0 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 7 Aug 2026 14:06:12 -0500 Subject: [PATCH 30/59] Update window.py --- cocoa/src/toga_cocoa/window.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 1089c55fdc..b12efdab03 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -140,9 +140,21 @@ def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover @objc_method def toolbarDefaultItemIdentifiers_(self, toolbar): + """Determine the list of toolbar items that will display by default.""" default = NSMutableArray.alloc().init() + prev_group = None for item in self.interface.toolbar: + # If there's been a group change, and this item isn't a separator, + # add a separator between groups. + if ( + prev_group is not None + and item.group != prev_group + and not isinstance(item, Separator) + ): + default.addObject_(toolbar_identifier(prev_group)) default.addObject_(toolbar_identifier(item)) + prev_group = item.group + return default @objc_method From 2d87273cb550603b93b8fd52446b7ea74f11d05b Mon Sep 17 00:00:00 2001 From: John Date: Fri, 7 Aug 2026 14:08:59 -0500 Subject: [PATCH 31/59] revert other changes --- cocoa/src/toga_cocoa/window.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index b12efdab03..1fe58041c4 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -159,8 +159,12 @@ def toolbarDefaultItemIdentifiers_(self, toolbar): @objc_method def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( - self, toolbar, identifier, insert: bool + self, + toolbar, + identifier, + insert: bool, ): + """Create the requested toolbar button.""" native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) try: item = self.impl._toolbar_items[str(identifier)] @@ -175,19 +179,29 @@ def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( native.setTarget_(self) native.setAction_(SEL("onToolbarButtonPress:")) - except KeyError: + except KeyError: # Separator items pass return native @objc_method def validateToolbarItem_(self, item) -> bool: + """Confirm if the toolbar item should be enabled.""" try: return self.impl._toolbar_items[str(item.itemIdentifier)].enabled - except KeyError: + except KeyError: # pragma: nocover + # This branch *shouldn't* ever happen; but there's an edge + # case where a toolbar redraw happens in the middle of deleting + # a toolbar item that can't be reliably reproduced, so it sometimes + # happens in testing. return False + ###################################################################### + # Toolbar button press delegate methods + ###################################################################### + @objc_method def onToolbarButtonPress_(self, obj) -> None: + """Invoke the action tied to the toolbar button.""" item = self.impl._toolbar_items[str(obj.itemIdentifier)] item.action() From 5bdaa52ab3245b904d9b6cc94c8fcc519d3fe6ec Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:15:06 -0500 Subject: [PATCH 32/59] more reversals --- cocoa/src/toga_cocoa/window.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 1fe58041c4..788a9abfb5 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -543,10 +543,14 @@ def get_image_data(self): class MainWindow(Window): def __init__(self, interface, position, size): super().__init__(interface, position, size) + # By default, no toolbar self._toolbar_items = {} self.native_toolbar = None + def __del__(self): + self.purge_toolbar() + def create_menus(self): # macOS doesn't have window-level menus pass @@ -574,9 +578,6 @@ def create_toolbar(self): if self.interface.content: self.interface.content.refresh() - def __del__(self): - self.purge_toolbar() - def purge_toolbar(self): while self._toolbar_items: dead_items = [] From c5aaebc7c1178e27c48820af2a0fc9494755be13 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:17:49 -0500 Subject: [PATCH 33/59] fix --- cocoa/src/toga_cocoa/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 788a9abfb5..f03a57931b 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -8,7 +8,7 @@ objc_property, ) -from toga import Command +from toga import Command, Separator from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position @@ -315,7 +315,7 @@ def get_size(self) -> Size: def set_size(self, size): frame = self.native.frame frame.size = NSSize(size[0], size[1]) - self.native.setFrame(frame, display=True, animate=False) + self.native.setFrame(frame, display=True, animate=True) ###################################################################### # Window position From 3500df84993e30e1d912e33b6edbadb36a8b7d2a Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:22:59 -0500 Subject: [PATCH 34/59] Refocus input widgets after done --- cocoa/src/toga_cocoa/widgets/numberinput.py | 2 ++ cocoa/src/toga_cocoa/widgets/textinput.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index d89d3e0e29..09d58683c6 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -229,6 +229,8 @@ def set_text_align(self, value): # 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 e6a6c8bb1f..48d944ef5e 100644 --- a/cocoa/src/toga_cocoa/widgets/textinput.py +++ b/cocoa/src/toga_cocoa/widgets/textinput.py @@ -196,6 +196,9 @@ def set_text_align(self, value): else: self.error_label.alignment = NSTextAlignment(RIGHT) + # Refocus when we're done. + self.focus() + def set_font(self, font): self.native.font = font._impl.native self.error_label.font = font._impl.native From 520e9562dc84dcf1069e892bdce9d0c9296d26e3 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:27:19 -0500 Subject: [PATCH 35/59] Cleanup vestigial objc syntax --- cocoa/src/toga_cocoa/window.py | 14 +++++++------- examples/layout/layout/app.py | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index f03a57931b..16152b2c50 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -8,7 +8,7 @@ objc_property, ) -from toga import Command, Separator +from toga.command import Command, Separator from toga.constants import WindowState from toga.types import Position, Size from toga.window import _initial_position @@ -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 @@ -165,7 +165,7 @@ def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( insert: bool, ): """Create the requested toolbar button.""" - native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier) + native = NSToolbarItem.alloc().initWithItemIdentifier(identifier) try: item = self.impl._toolbar_items[str(identifier)] native.setLabel(item.text) @@ -177,8 +177,8 @@ def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_( item._impl.native.add(native) - native.setTarget_(self) - native.setAction_(SEL("onToolbarButtonPress:")) + native.setTarget(self) + native.setAction(SEL("onToolbarButtonPress:")) except KeyError: # Separator items pass return native diff --git a/examples/layout/layout/app.py b/examples/layout/layout/app.py index f7d23f9a3f..481e51a385 100644 --- a/examples/layout/layout/app.py +++ b/examples/layout/layout/app.py @@ -55,9 +55,13 @@ def startup(self): for _ in range(3): self.add_label() + # 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.show() self.main_window.content = self.box + self.main_window.show() def hide_label(self, sender): if self.labels[0].visibility == HIDDEN: From 4e115d7c8e89aa93e5271e262145b404686fcb4a Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:29:08 -0500 Subject: [PATCH 36/59] add back elif --- cocoa/src/toga_cocoa/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 16152b2c50..a788ed76ae 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -382,7 +382,7 @@ def get_window_state(self, in_progress_state=False): and self._scaffold.current_container.controller.view.isInFullScreenMode() ): return WindowState.PRESENTATION - if self.native.styleMask & NSWindowStyleMask.FullScreen: + elif self.native.styleMask & NSWindowStyleMask.FullScreen: return WindowState.FULLSCREEN elif self.native.isZoomed: return WindowState.MAXIMIZED From c70170d84081b614fd38c384fdbf678210dc8a28 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:32:18 -0500 Subject: [PATCH 37/59] Document --- cocoa/src/toga_cocoa/window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index a788ed76ae..aa4a4621e1 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -377,6 +377,8 @@ 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 + # 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() From 148751b5d42abaf1aaca0e1b51236aedc2ee68d1 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:34:57 -0500 Subject: [PATCH 38/59] get rid of cache --- cocoa/src/toga_cocoa/window.py | 7 ++----- iOS/src/toga_iOS/window.py | 8 ++------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index aa4a4621e1..34222d075c 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -211,8 +211,6 @@ def __init__(self, interface, position, size): self.interface = interface self.interface._impl = self - self._title = "" - mask = NSWindowStyleMask.Titled if self.interface.closable: mask |= NSWindowStyleMask.Closable @@ -262,7 +260,6 @@ def get_title(self): return str(self._scaffold.title) def set_title(self, title): - self._title = title self._scaffold.title = title ###################################################################### @@ -290,13 +287,13 @@ def set_scaffold(self, scaffold): restore_presentation = False if self.get_window_state() == WindowState.PRESENTATION: restore_presentation = True - # This is instaneous so yay!!! self.set_window_state(WindowState.NORMAL) frame = self.native.frame self._scaffold = scaffold + # Get the current title and sync it up with the new scaffold. + scaffold.title = self.get_title() # Set the content of the window's container self.native.contentViewController = scaffold.root_controller - scaffold.title = self._title self.native.setFrame(frame, display=True, animate=False) if restore_presentation: self.set_window_state(WindowState.PRESENTATION) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index b71b46d64b..aaaa2e3716 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -29,7 +29,6 @@ def __init__(self, interface, position, size): self.interface._impl = self self.native = UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds) - self._title = "" # Set the background color of the root content. try: @@ -46,13 +45,9 @@ def __init__(self, interface, position, size): ###################################################################### def get_title(self): - # This may seem a bit less performant than using self._title, but we do it - # so that it's possible to test in the testbed that the title is properly set - # (as scaffold.title is a direct native retrieval) return str(self.scaffold.title) def set_title(self, title): - self._title = title self.scaffold.title = title ###################################################################### @@ -77,8 +72,9 @@ def show(self): def set_scaffold(self, scaffold): self.scaffold = scaffold + # Get the current title and sync it up with the new scaffold. + self.scaffold.title = self.get_title() self.native.rootViewController = self.scaffold.nav_controller - self.scaffold.title = self._title self.scaffold.navigation_bar_hidden = self._navigation_bar_hidden ###################################################################### From 354be05c3b2afe3cfe842ab02325b4d28cf2dc63 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 14:38:40 -0500 Subject: [PATCH 39/59] Cache scaffold probing --- iOS/tests_backend/window.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index fdcbb314fe..7f22914957 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -6,6 +6,8 @@ from .probe import BaseProbe from .scaffolds.base import ScaffoldProbe +SCAFFOLD_PROBE_CACHE = {} + class WindowProbe(BaseProbe, DialogsMixin): supports_fullscreen = False @@ -31,7 +33,12 @@ def _state_assertion(): async def wait_for_window(self, message, state=None): await self.redraw(message) - await ScaffoldProbe(self.window.scaffold).wait_for_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: From 6cd0fec12a283136a1b4f787c501955620d42bc2 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 20:11:58 -0500 Subject: [PATCH 40/59] Fix (hopefully) --- cocoa/src/toga_cocoa/widgets/numberinput.py | 11 +++++++---- cocoa/src/toga_cocoa/widgets/textinput.py | 5 +---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index 09d58683c6..255983cb30 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 not self.has_focus: self.interface.window._impl.native.makeFirstResponder(self.native_input) def get_readonly(self): @@ -224,7 +227,7 @@ def set_max_value(self, value): self.native_stepper.maxValue = float(value) def set_text_align(self, value): - if self.interface.window and self.has_focus(): + 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) diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py index 48d944ef5e..9f0ffe0795 100644 --- a/cocoa/src/toga_cocoa/widgets/textinput.py +++ b/cocoa/src/toga_cocoa/widgets/textinput.py @@ -185,7 +185,7 @@ def set_placeholder(self, value): self.native.cell.placeholderString = value def set_text_align(self, value): - if self.interface.window and self.has_focus: + 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) @@ -196,9 +196,6 @@ def set_text_align(self, value): else: self.error_label.alignment = NSTextAlignment(RIGHT) - # Refocus when we're done. - self.focus() - def set_font(self, font): self.native.font = font._impl.native self.error_label.font = font._impl.native From 2a6795a19008a077ebce68c13510abda0e5485ab Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 21:06:14 -0500 Subject: [PATCH 41/59] Fix again! --- cocoa/src/toga_cocoa/widgets/numberinput.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index 255983cb30..a0f3027d46 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -202,7 +202,7 @@ def has_focus(self): ) 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): From 85b8df2a10bf3d761854f92bf2e20b54ed907651 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 21:06:50 -0500 Subject: [PATCH 42/59] Fix III --- cocoa/src/toga_cocoa/window.py | 2 +- iOS/src/toga_iOS/window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 34222d075c..49689dfe21 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -289,9 +289,9 @@ def set_scaffold(self, scaffold): restore_presentation = True self.set_window_state(WindowState.NORMAL) frame = self.native.frame - self._scaffold = scaffold # Get the current title and sync it up with the new scaffold. scaffold.title = self.get_title() + self._scaffold = scaffold # Set the content of the window's container self.native.contentViewController = scaffold.root_controller self.native.setFrame(frame, display=True, animate=False) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index aaaa2e3716..839229b366 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -71,9 +71,9 @@ def show(self): ###################################################################### def set_scaffold(self, scaffold): - self.scaffold = scaffold # Get the current title and sync it up with the new scaffold. self.scaffold.title = self.get_title() + self.scaffold = scaffold self.native.rootViewController = self.scaffold.nav_controller self.scaffold.navigation_bar_hidden = self._navigation_bar_hidden From a3fc6e8079376724b3e550d9f5b0481afac1decb Mon Sep 17 00:00:00 2001 From: John Zhou Date: Fri, 7 Aug 2026 21:24:17 -0500 Subject: [PATCH 43/59] Fix --- cocoa/src/toga_cocoa/window.py | 5 ++++- iOS/src/toga_iOS/window.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 49689dfe21..28d5e353df 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -290,7 +290,10 @@ def set_scaffold(self, scaffold): self.set_window_state(WindowState.NORMAL) frame = self.native.frame # Get the current title and sync it up with the new scaffold. - scaffold.title = self.get_title() + # 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.native.contentViewController = scaffold.root_controller diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 839229b366..0dbd959f6d 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -72,7 +72,10 @@ def show(self): def set_scaffold(self, scaffold): # Get the current title and sync it up with the new scaffold. - self.scaffold.title = self.get_title() + # This check is required as the initial scaffold set will not have + # a previous scaffold to grab title from. + if hasattr(self, "_scaffold"): + self.scaffold.title = self.get_title() self.scaffold = scaffold self.native.rootViewController = self.scaffold.nav_controller self.scaffold.navigation_bar_hidden = self._navigation_bar_hidden From 38be61cae43985b74d9fdcda566d53ecca86342b Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 06:38:11 -0500 Subject: [PATCH 44/59] rerun CI From 52be318c80f1a863983bf8088fdb2e4ca55a9931 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 07:07:21 -0500 Subject: [PATCH 45/59] rerun ios ci From d9ef0c58a85c30ef099f71b65744c615d4b7e4e5 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 07:27:08 -0500 Subject: [PATCH 46/59] Update window.py --- iOS/src/toga_iOS/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 0dbd959f6d..ae7016af6b 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -74,7 +74,7 @@ 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"): + if hasattr(self, "scaffold"): self.scaffold.title = self.get_title() self.scaffold = scaffold self.native.rootViewController = self.scaffold.nav_controller From 5e6d578872e2163df3fa0eafeb6d5dcc72ac9e0b Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 07:47:57 -0500 Subject: [PATCH 47/59] iOS please work From 3ec08c76a03e958a21340b1d58d65df6d8d68b80 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 08:03:57 -0500 Subject: [PATCH 48/59] Update window.py --- iOS/src/toga_iOS/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index ae7016af6b..73b7517aa4 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -75,7 +75,7 @@ def set_scaffold(self, scaffold): # This check is required as the initial scaffold set will not have # a previous scaffold to grab title from. if hasattr(self, "scaffold"): - self.scaffold.title = self.get_title() + scaffold.title = self.get_title() self.scaffold = scaffold self.native.rootViewController = self.scaffold.nav_controller self.scaffold.navigation_bar_hidden = self._navigation_bar_hidden From e6e8b806f45064600a25f241832cb54b679db2f2 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 08:04:29 -0500 Subject: [PATCH 49/59] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 992d4132de..351c1d654b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -643,7 +643,7 @@ jobs: run: | ${{ matrix.briefcase-run-prefix }} \ briefcase run ${{ matrix.platform }} --log --test \ - ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} + ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} -s - name: Upload Logs uses: actions/upload-artifact@v7.0.1 From 566cdb87d50b476829c3de0c52857cf0272637c1 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sat, 8 Aug 2026 09:51:47 -0500 Subject: [PATCH 50/59] fix --- testbed/tests/conftest.py | 5 +++++ testbed/tests/widgets/conftest.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index da2dad2816..7e648e1ca3 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -80,6 +80,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 diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 8f20a4fdf7..6d720f33fc 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -43,6 +43,15 @@ async def container_probe(widget): return get_probe(widget.parent) +# Override as widget causes new scaffold to be set +@pytest.fixture +async def scaffold_probe(widget): + # 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""" From b66d507476ff891ac3fd45f14fe66deaf83dbb31 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sat, 8 Aug 2026 10:04:21 -0500 Subject: [PATCH 51/59] add probe --- testbed/tests/widgets/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 6d720f33fc..5ae2c320fd 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -44,8 +44,9 @@ async def container_probe(widget): # 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): +async def scaffold_probe(widget, probe): # This needs to be late to avoid circular imports from tests_backend.scaffolds.base import ScaffoldProbe From e0319b5565510b063a5b7bb3868118fc6324ba4c Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sat, 8 Aug 2026 10:17:15 -0500 Subject: [PATCH 52/59] if plug --- testbed/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 1550cd2830..9fdd2cd34b 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -16,6 +16,7 @@ test = [ "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-retry==1.7.0", + "pytest-instafail", ] [tool.briefcase] From 9c03fc9050eb63f1845ccb486cf113ac6aa49d22 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Sat, 8 Aug 2026 10:18:10 -0500 Subject: [PATCH 53/59] Enable instafail plugin. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 351c1d654b..34e71e06de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -643,7 +643,7 @@ jobs: run: | ${{ matrix.briefcase-run-prefix }} \ briefcase run ${{ matrix.platform }} --log --test \ - ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} -s + ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} -s --instafail - name: Upload Logs uses: actions/upload-artifact@v7.0.1 From 536ecb1840599cd9a5a455bcd9a5555c6e34d80c Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 11:56:07 -0500 Subject: [PATCH 54/59] Update constraints.py --- iOS/src/toga_iOS/constraints.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index bc365ba132..751bbc1289 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -1,3 +1,4 @@ +import contextlib from functools import partial from weakref import ref @@ -50,7 +51,7 @@ def __del__(self): # pragma: nocover # 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. - try: + with contextlib.suppress(Exception): self.widget.interface.app.loop.call_soon_threadsafe( partial( _remove_constraints, @@ -64,8 +65,6 @@ def __del__(self): # pragma: nocover ], ) ) - except Exception: - pass def _remove_constraints(self): if self.container: From c51995f869c88b816cc46ad68c0a8ffeb106fe36 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 11:56:54 -0500 Subject: [PATCH 55/59] Update base.py --- iOS/src/toga_iOS/scaffolds/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOS/src/toga_iOS/scaffolds/base.py b/iOS/src/toga_iOS/scaffolds/base.py index 3d7abe7cf6..b2c9d7c245 100644 --- a/iOS/src/toga_iOS/scaffolds/base.py +++ b/iOS/src/toga_iOS/scaffolds/base.py @@ -50,7 +50,7 @@ def content_refreshed(self, container): # shown on screen so we can ignore that safely. # Else, If the minimum layout is bigger than the current window, log a # warning - if not (container.width, container.height) == (0, 0) and ( + if (container.width, container.height) != (0, 0) and ( container.width < min_width or container.height < min_height ): print( From 903078279c71078ccf662d497d1f914c66a1052d Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 11:58:27 -0500 Subject: [PATCH 56/59] seems like CI is stable now --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 933861fe11..30eae95c27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -583,7 +583,7 @@ jobs: run: | ${{ matrix.briefcase-run-prefix }} \ briefcase run ${{ matrix.platform }} --log --test \ - ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} -s --instafail + ${{ matrix.briefcase-run-args }} --app ${{ matrix.testbed-app }} -- --ci ${{ matrix.briefcase-test-args }} - name: Upload Logs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From f543b35ecbee618411c1991fab5cdc538d716ab7 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 11:59:33 -0500 Subject: [PATCH 57/59] Update pyproject.toml --- testbed/pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 82f3a12df4..9fd7f23535 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -16,7 +16,8 @@ test = [ "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-retry==1.7.0", - "pytest-instafail", + # This enables people to debug intermittent hard crash erros in CI by enabling --instafail in the test options. + "pytest-instafail==0.5.0", ] [tool.briefcase] From 2cbe1dec22665bd352912b24b11ffb238408ba9d Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 12:01:07 -0500 Subject: [PATCH 58/59] Update pyproject.toml --- testbed/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 9fd7f23535..3d86964b6b 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -16,7 +16,7 @@ test = [ "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-retry==1.7.0", - # This enables people to debug intermittent hard crash erros in CI by enabling --instafail in the test options. + # This enables people to debug intermittent hard crash errors in CI by enabling --instafail in the test options. "pytest-instafail==0.5.0", ] From 898e48fb6432dda012dfaf8210957f999a0ed4c7 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 8 Aug 2026 12:30:40 -0500 Subject: [PATCH 59/59] Ensure container width and height are greater than zero Add assertions to check container dimensions are positive. --- iOS/tests_backend/scaffolds/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iOS/tests_backend/scaffolds/base.py b/iOS/tests_backend/scaffolds/base.py index 3e51f4c3d2..9557de9c6c 100644 --- a/iOS/tests_backend/scaffolds/base.py +++ b/iOS/tests_backend/scaffolds/base.py @@ -35,6 +35,8 @@ def assert_container_layout(self): 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):