diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 76365fcc4d..b4a977ade1 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -106,3 +106,11 @@ def rotate(self): self.native.findViewById( R.id.content ).getViewTreeObserver().dispatchOnGlobalLayout() + + @property + def tabbing_enabled(self): + xfail("Tabbed windows not implemented for this backend.") + + @tabbing_enabled.setter + def tabbing_enabled(self, value): + xfail("Tabbed windows not implemented for this backend.") diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index bc283f43a3..5e684c565b 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -87,3 +87,7 @@ def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): def press_toolbar_button(self, index): self.native.onOptionsItemSelected(self._toolbar_items()[index]) + + @property + def tabs(self): + pytest.xfail("Tabbed windows not implemented for this backend.") diff --git a/changes/2311.feature.rst b/changes/2311.feature.rst new file mode 100644 index 0000000000..02addee77a --- /dev/null +++ b/changes/2311.feature.rst @@ -0,0 +1 @@ +On macOS, windows now only tab with others of the same class. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index b072464c95..af30dbbe7b 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -289,6 +289,12 @@ def _create_app_commands(self): shortcut=toga.Key.MOD_1 + "m", group=toga.Group.WINDOW, ), + toga.Command( + NativeHandler(SEL("mergeAllWindows:")), + "Merge All Windows", + group=toga.Group.WINDOW, + section=10, + ), # ---- Help menu ---------------------------------- toga.Command( self._menu_visit_homepage, diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 17b70dd16b..296b1fdda0 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -148,6 +148,9 @@ def __init__(self, interface, title, position, size): self.native.interface = self.interface self.native.impl = self + # This causes windows to only tab together with others of the same class. + self.native.tabbingIdentifier = str(self.interface.__class__) + # Cocoa releases windows when they are closed; this causes havoc with # Toga's widget cleanup because the ObjC runtime thinks there's no # references to the object left. Add a reference that can be released @@ -163,7 +166,7 @@ def __init__(self, interface, title, position, size): self.container = Container(on_refresh=self.content_refreshed) self.native.contentView = self.container.native - # Ensure that the container renders it's background in the same color as the window. + # Ensure that the container renders its background in the same color as the window. self.native.wantsLayer = True self.container.native.backgroundColor = self.native.backgroundColor diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 8c82b68a4f..a95f7c3191 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -24,7 +24,7 @@ def __init__(self, app): super().__init__() self.app = app # Prevents erroneous test fails from secondary windows opening as tabs - NSWindow.allowsAutomaticWindowTabbing = False + self.tabbing_enabled = False assert isinstance(self.app._impl.native, NSApplication) @property @@ -175,3 +175,11 @@ def keystroke(self, combination): keyCode=key_code, ) return toga_key(event) + + @property + def tabbing_enabled(self): + return NSWindow.allowsAutomaticWindowTabbing + + @tabbing_enabled.setter + def tabbing_enabled(self, value): + NSWindow.allowsAutomaticWindowTabbing = value diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 9eeba9ff29..bc8c69045a 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -261,3 +261,7 @@ def press_toolbar_button(self, index): restype=None, argtypes=[objc_id], ) + + @property + def tabs(self): + return self.native.tabbedWindows diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 10dcc058b6..cbdefa407d 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -154,3 +154,11 @@ def keystroke(self, combination): event.state = state return toga_key(event) + + @property + def tabbing_enabled(self): + pytest.xfail("Tabbed windows not implemented for this backend.") + + @tabbing_enabled.setter + def tabbing_enabled(self, value): + pytest.xfail("Tabbed windows not implemented for this backend.") diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index a90b60f8ef..20aa3cf4e8 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -2,6 +2,8 @@ from pathlib import Path from unittest.mock import Mock +from pytest import xfail + from toga_gtk.libs import Gdk, Gtk from .probe import BaseProbe @@ -251,3 +253,7 @@ def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): def press_toolbar_button(self, index): item = self.impl.native_toolbar.get_nth_item(index) item.emit("clicked") + + @property + def tabs(self): + xfail("Tabbed windows not implemented for this backend.") diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 98a4ba0369..9eaa063bc9 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -69,3 +69,11 @@ def terminate(self): def rotate(self): self.native = self.app._impl.native self.native.delegate.application(self.native, didChangeStatusBarOrientation=0) + + @property + def tabbing_enabled(self): + pytest.xfail("Tabbed windows not implemented for this backend.") + + @tabbing_enabled.setter + def tabbing_enabled(self, value): + pytest.xfail("Tabbed windows not implemented for this backend.") diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 08f9a34295..29d39b57ed 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -77,3 +77,7 @@ async def close_select_folder_dialog(self, dialog, result, multiple_select): def has_toolbar(self): pytest.skip("Toolbars not implemented on iOS") + + @property + def tabs(self): + pytest.xfail("Tabbed windows not implemented for this backend.") diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py index 70067ce9c5..d7d0f20480 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/test_window.py @@ -17,7 +17,7 @@ def window_probe(app, window): module = import_module("tests_backend.window") - return getattr(module, "WindowProbe")(app, window) + return module.WindowProbe(app, window) @pytest.fixture @@ -216,6 +216,65 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob assert second_window not in app.windows + async def test_window_tabbing(app, app_probe, main_window, main_window_probe): + """Windows tab only with others of the same class (only implemented on macOS)""" + + class WindowSubclass(toga.Window): + pass + + base_windows = [toga.Window() for _ in range(2)] + subclass_windows = [WindowSubclass() for _ in range(2)] + for window in [*base_windows, *subclass_windows]: + window.show() + + try: + base_probe = window_probe(app, base_windows[0]) + subclass_probe = window_probe(app, subclass_windows[0]) + + app_probe.tabbing_enabled = True + + # Double check that nothing's tabbed initially. + assert not main_window_probe.tabs + assert not base_probe.tabs + assert not subclass_probe.tabs + + # Merge All Windows command operates based on which window is active. + app.current_window = main_window + await main_window_probe.wait_for_window("Switched to MainWindow") + app_probe._activate_menu_item(["Window", "Merge All Windows"]) + await main_window_probe.wait_for_window( + "Merge All Windows called on MainWindow" + ) + # There's only one MainWindow, so nothing should have changed. + assert not main_window_probe.tabs + assert not base_probe.tabs + assert not subclass_probe.tabs + + app.current_window = base_probe.window + await main_window_probe.wait_for_window("Switched to base Window") + app_probe._activate_menu_item(["Window", "Merge All Windows"]) + await main_window_probe.wait_for_window( + "Merge All Windows called on base Window" + ) + assert not main_window_probe.tabs + assert len(base_probe.tabs) == 2 + assert not subclass_probe.tabs + + app.current_window = subclass_probe.window + await main_window_probe.wait_for_window("Switched to subclassed Window") + app_probe._activate_menu_item(["Window", "Merge All Windows"]) + await main_window_probe.wait_for_window( + "Merge All Windows called on Window subclass" + ) + assert not main_window_probe.tabs + assert len(base_probe.tabs) == 2 + assert len(subclass_probe.tabs) == 2 + + finally: + app_probe.tabbing_enabled = False + for window in [*base_windows, *subclass_windows]: + window.close() + async def test_secondary_window_cleanup(app_probe): """Memory for windows is cleaned up when windows are deleted.""" # Create and show a window with content. We can't use the second_window fixture diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 4a2640e538..d0f5eafdd4 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -154,3 +154,11 @@ def activate_menu_minimize(self): def keystroke(self, combination): return winforms_to_toga_key(toga_to_winforms_key(combination)) + + @property + def tabbing_enabled(self): + pytest.xfail("Tabbed windows not implemented for this backend.") + + @tabbing_enabled.setter + def tabbing_enabled(self, value): + pytest.xfail("Tabbed windows not implemented for this backend.") diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 5fc6ae3c94..0cb63c9931 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -1,6 +1,7 @@ import asyncio from unittest.mock import Mock +from pytest import xfail from System import EventArgs from System.Windows.Forms import ( Form, @@ -148,3 +149,7 @@ def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): def press_toolbar_button(self, index): self._native_toolbar_item(index).OnClick(EventArgs.Empty) + + @property + def tabs(self): + xfail("Tabbed windows not implemented for this backend.")