diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bba4ea2b7..d9fee1b115 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,10 +207,15 @@ jobs: - backend: linux runs-on: ubuntu-22.04 # The package list should be the same as in tutorial-0.rst, and the BeeWare - # tutorial, plus flwm to provide a window manager + # tutorial, plus blackbox to provide a window manager. We need a window + # manager that is reasonably lightweight, honors full screen mode, and + # treats the window position as the top-left corner of the *window*, not the + # top-left corner of the window *content*. The default GNOME window managers of + # most distros meet these requirementt, but they're heavyweight; flwm doesn't + # work either. Blackbox is the lightest WM we've found that works. pre-command: | sudo apt update -y - sudo apt install -y flwm pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-webkit2-4.0 + sudo apt install -y blackbox pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-webkit2-4.0 # Start Virtual X server echo "Start X server..." @@ -219,7 +224,7 @@ jobs: # Start Window manager echo "Start window manager..." - DISPLAY=:99 flwm & + DISPLAY=:99 blackbox & sleep 1 briefcase-run-prefix: 'DISPLAY=:99' diff --git a/1215.bugfix.rst b/1215.bugfix.rst new file mode 100644 index 0000000000..a77fc7e1ca --- /dev/null +++ b/1215.bugfix.rst @@ -0,0 +1 @@ +A memory leak associated with creation and deletion of windows has been resolved. diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 269bf49171..5a94967730 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -1,4 +1,5 @@ import asyncio +import weakref from rubicon.java import android_events @@ -173,7 +174,6 @@ def native(self): class App: def __init__(self, interface): self.interface = interface - self.interface._impl = self self._listener = None self.loop = android_events.AndroidEventLoop() @@ -182,6 +182,14 @@ def __init__(self, interface): def native(self): return self._listener.native if self._listener else None + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create(self): # The `_listener` listens for activity event callbacks. For simplicity, # the app's `.native` is the listener's native Java class. diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 71d7ea1970..03e6ce9fba 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -1,3 +1,5 @@ +import weakref + from decimal import ROUND_UP from .container import Container @@ -23,9 +25,16 @@ class Window(Container): def __init__(self, interface, title, position, size): super().__init__() self.interface = interface - self.interface._impl = self # self.set_title(title) + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def set_app(self, app): self.app = app native_parent = app.native.findViewById(R__id.content) diff --git a/changes/2058.feature.rst b/changes/2058.feature.rst new file mode 100644 index 0000000000..e50fbe1a5f --- /dev/null +++ b/changes/2058.feature.rst @@ -0,0 +1 @@ +Window and MainWindow now have 100% test coverage, and complete API documentation. diff --git a/changes/2058.removal.1.rst b/changes/2058.removal.1.rst new file mode 100644 index 0000000000..b0775e2a9a --- /dev/null +++ b/changes/2058.removal.1.rst @@ -0,0 +1 @@ +Windows no longer need to be explicitly added to the app's window list. When a window is shown, it will be automatically added to the windows for the currently running app. diff --git a/changes/2058.removal.2.rst b/changes/2058.removal.2.rst new file mode 100644 index 0000000000..3499758242 --- /dev/null +++ b/changes/2058.removal.2.rst @@ -0,0 +1 @@ +The ``multiselect`` argument to Open File and Select Folder dialogs has been renamed ``multiple_select``, for consistency with other widgets that have multiple selection capability. diff --git a/changes/2058.removal.3.rst b/changes/2058.removal.3.rst new file mode 100644 index 0000000000..4c6d52f106 --- /dev/null +++ b/changes/2058.removal.3.rst @@ -0,0 +1 @@ +``Window.resizeable`` and ``Window.closeable`` have been renamed ``Window.resizable`` and ``Window.closable``, to adhere to US spelling conventions. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index c983720d4d..aa556c8f58 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -2,6 +2,7 @@ import inspect import os import sys +import weakref from urllib.parse import unquote, urlparse from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy @@ -112,13 +113,20 @@ class App: def __init__(self, interface): self.interface = interface - self.interface._impl = self self._cursor_visible = True asyncio.set_event_loop_policy(EventLoopPolicy()) self.loop = asyncio.new_event_loop() + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create(self): self.native = NSApplication.sharedApplication self.native.setActivationPolicy(NSApplicationActivationPolicyRegular) @@ -135,12 +143,21 @@ def create(self): self.appDelegate.native = self.native self.native.setDelegate_(self.appDelegate) - formal_name = self.interface.formal_name + self._create_app_commands() + # Call user code to populate the main window + self.interface._startup() + + # Create the lookup table of menu items, + # then force the creation of the menus. + self.create_menus() + + def _create_app_commands(self): + formal_name = self.interface.formal_name self.interface.commands.add( # ---- App menu ----------------------------------- toga.Command( - lambda _: self.interface.about(), + self._menu_about, "About " + formal_name, group=toga.Group.APP, ), @@ -176,12 +193,35 @@ def create(self): ), # Quit should always be the last item, in a section on its own toga.Command( - lambda _: self.interface.exit(), + self._menu_exit, "Quit " + formal_name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, ), + # ---- File menu ---------------------------------- + # This is a bit of an oddity. Safari has 2 distinct "Close Window" and + # "Close All Windows" menu items (partially to differentiate from "Close + # Tab"). Most other Apple HIG apps have a "Close" item that becomes + # "Close All" when you press Option (MOD_2). That behavior isn't something + # we're currently set up to implement, so we live with a separate menu item + # for now. + toga.Command( + self._menu_close_window, + "Close Window", + shortcut=toga.Key.MOD_1 + "W", + group=toga.Group.FILE, + order=1, + section=50, + ), + toga.Command( + self._menu_close_all_windows, + "Close All Windows", + shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "W", + group=toga.Group.FILE, + order=2, + section=50, + ), # ---- Edit menu ---------------------------------- toga.Command( NativeHandler(SEL("undo:")), @@ -244,26 +284,39 @@ def create(self): section=10, order=60, ), + # ---- Window menu ---------------------------------- + toga.Command( + self._menu_minimize, + "Minimize", + shortcut=toga.Key.MOD_1 + "m", + group=toga.Group.WINDOW, + ), # ---- Help menu ---------------------------------- toga.Command( - lambda _: self.interface.visit_homepage(), + lambda _, **kwargs: self.interface.visit_homepage(), "Visit homepage", enabled=self.interface.home_page is not None, group=toga.Group.HELP, ), ) - self._create_app_commands() - # Call user code to populate the main window - self.interface._startup() + def _menu_about(self, app, **kwargs): + self.interface.about() - # Create the lookup table of menu items, - # then force the creation of the menus. - self.create_menus() + def _menu_exit(self, app, **kwargs): + self.interface.exit() - def _create_app_commands(self): - # No extra commands - pass + def _menu_close_window(self, app, **kwargs): + if self.interface.current_window: + self.interface.current_window._impl.native.performClose(None) + + def _menu_close_all_windows(self, app, **kwargs): + for window in self.interface.windows: + window._impl.native.performClose(None) + + def _menu_minimize(self, app, **kwargs): + if self.interface.current_window: + self.interface.current_window._impl.native.miniaturize(None) def create_menus(self): # Recreate the menu @@ -427,6 +480,7 @@ def select_file(self, **kwargs): class DocumentApp(App): def _create_app_commands(self): + super()._create_app_commands() self.interface.commands.add( toga.Command( lambda _: self.select_file(), diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index b48698ad68..592769ba6e 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -1,3 +1,5 @@ +import weakref + from rubicon.objc import objc_method from .libs import ( @@ -32,7 +34,7 @@ def __init__( min_width=100, min_height=100, layout_native=None, - on_refresh=None, + parent=None, ): """A container for layouts. @@ -45,11 +47,11 @@ def __init__( 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 parent: The parent of this container; this is the object that will be + notified when this container's layout is refreshed. """ self._content = None - self.on_refresh = on_refresh + self.parent = weakref.ref(parent) self.native = TogaView.alloc().init() self.layout_native = self.native if layout_native is None else layout_native @@ -103,8 +105,7 @@ def content(self, widget): widget.container = self def refreshed(self): - if self.on_refresh: - self.on_refresh(self) + self.parent().content_refreshed(self) @property def width(self): diff --git a/cocoa/src/toga_cocoa/dialogs.py b/cocoa/src/toga_cocoa/dialogs.py index 7951d81f8c..d2e5fd70ed 100644 --- a/cocoa/src/toga_cocoa/dialogs.py +++ b/cocoa/src/toga_cocoa/dialogs.py @@ -7,9 +7,9 @@ NSAlertFirstButtonReturn, NSAlertStyle, NSBezelBorder, - NSFileHandlingPanelOKButton, NSFont, NSMakeRect, + NSModalResponseOK, NSOpenPanel, NSSavePanel, NSScrollView, @@ -20,7 +20,7 @@ class BaseDialog(ABC): def __init__(self, interface): self.interface = interface - self.interface.impl = self + self.interface._impl = self class NSAlertDialog(BaseDialog): @@ -46,21 +46,22 @@ def __init__( self.build_dialog(**kwargs) self.native.beginSheetModalForWindow( - interface.window._impl.native, completionHandler=completion_handler + interface.window._impl.native, + completionHandler=completion_handler, ) def build_dialog(self): pass def completion_handler(self, return_value: int) -> None: - self.on_result(self, None) + self.on_result(None, None) self.interface.future.set_result(None) def bool_completion_handler(self, return_value: int) -> None: result = return_value == NSAlertFirstButtonReturn - self.on_result(self, result) + self.on_result(None, result) self.interface.future.set_result(result) @@ -169,55 +170,62 @@ def __init__( filename, initial_directory, file_types, - multiselect, + multiple_select, on_result=None, ): super().__init__(interface=interface) self.on_result = on_result # Create the panel - self.create_panel(multiselect) + self.create_panel(multiple_select) - # Set all the - self.panel.title = title + # Set the title of the panel + self.native.title = title if filename: - self.panel.nameFieldStringValue = filename + self.native.nameFieldStringValue = filename if initial_directory: - self.panel.directoryURL = NSURL.URLWithString( + self.native.directoryURL = NSURL.URLWithString( str(initial_directory.as_uri()) ) - self.panel.allowedFileTypes = file_types + self.native.allowedFileTypes = file_types - if multiselect: + if multiple_select: handler = self.multi_path_completion_handler else: handler = self.single_path_completion_handler - self.panel.beginSheetModalForWindow( + self.native.beginSheetModalForWindow( interface.window._impl.native, completionHandler=handler, ) + # Provided as a stub that can be mocked in test conditions + def selected_path(self): + return self.native.URL + + # Provided as a stub that can be mocked in test conditions + def selected_paths(self): + return self.native.URLs + def single_path_completion_handler(self, return_value: int) -> None: - if return_value == NSFileHandlingPanelOKButton: - result = Path(self.panel.URL.path) + if return_value == NSModalResponseOK: + result = Path(str(self.selected_path().path)) else: result = None - self.on_result(self, result) - + self.on_result(None, result) self.interface.future.set_result(result) def multi_path_completion_handler(self, return_value: int) -> None: - if return_value == NSFileHandlingPanelOKButton: - result = [Path(url.path) for url in self.panel.URLs] + if return_value == NSModalResponseOK: + result = [Path(url.path) for url in self.selected_paths()] else: result = None - self.on_result(self, result) + self.on_result(None, result) self.interface.future.set_result(result) @@ -238,12 +246,12 @@ def __init__( filename=filename, initial_directory=initial_directory, file_types=None, # File types aren't offered by Cocoa save panels. - multiselect=False, + multiple_select=False, on_result=on_result, ) - def create_panel(self, multiselect): - self.panel = NSSavePanel.alloc().init() + def create_panel(self, multiple_select): + self.native = NSSavePanel.alloc().init() class OpenFileDialog(FileDialog): @@ -253,7 +261,7 @@ def __init__( title, initial_directory, file_types, - multiselect, + multiple_select, on_result=None, ): super().__init__( @@ -262,16 +270,16 @@ def __init__( filename=None, initial_directory=initial_directory, file_types=file_types, - multiselect=multiselect, + multiple_select=multiple_select, on_result=on_result, ) - def create_panel(self, multiselect): - self.panel = NSOpenPanel.alloc().init() - self.panel.allowsMultipleSelection = multiselect - self.panel.canChooseDirectories = False - self.panel.canCreateDirectories = False - self.panel.canChooseFiles = True + def create_panel(self, multiple_select): + self.native = NSOpenPanel.alloc().init() + self.native.allowsMultipleSelection = multiple_select + self.native.canChooseDirectories = False + self.native.canCreateDirectories = False + self.native.canChooseFiles = True class SelectFolderDialog(FileDialog): @@ -280,7 +288,7 @@ def __init__( interface, title, initial_directory, - multiselect, + multiple_select, on_result=None, ): super().__init__( @@ -289,14 +297,14 @@ def __init__( filename=None, initial_directory=initial_directory, file_types=None, - multiselect=multiselect, + multiple_select=multiple_select, on_result=on_result, ) - def create_panel(self, multiselect): - self.panel = NSOpenPanel.alloc().init() - self.panel.allowsMultipleSelection = multiselect - self.panel.canChooseDirectories = True - self.panel.canCreateDirectories = True - self.panel.canChooseFiles = False - self.panel.resolvesAliases = True + def create_panel(self, multiple_select): + self.native = NSOpenPanel.alloc().init() + self.native.allowsMultipleSelection = multiple_select + self.native.canChooseDirectories = True + self.native.canCreateDirectories = True + self.native.canChooseFiles = False + self.native.resolvesAliases = True diff --git a/cocoa/src/toga_cocoa/libs/appkit.py b/cocoa/src/toga_cocoa/libs/appkit.py index 7e30335a8b..4386f44dc0 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -589,8 +589,6 @@ class NSLineBreakMode(Enum): # NSSavePanel.h NSSavePanel = ObjCClass("NSSavePanel") -NSFileHandlingPanelOKButton = 1 - ###################################################################### # NSScreen.h NSScreen = ObjCClass("NSScreen") @@ -758,11 +756,24 @@ def NSTextAlignment(alignment): NSWindow = ObjCClass("NSWindow") NSWindow.declare_property("frame") -NSBorderlessWindowMask = 0 -NSTitledWindowMask = 1 << 0 -NSClosableWindowMask = 1 << 1 -NSMiniaturizableWindowMask = 1 << 2 -NSResizableWindowMask = 1 << 3 + +class NSWindowStyleMask(IntEnum): + Borderless = 0 + Titled = 1 << 0 + Closable = 1 << 1 + Miniaturizable = 1 << 2 + Resizable = 1 << 3 + UnifiedTitleAndToolbar = 1 << 12 + FullScreen = 1 << 14 + FullSizeContentView = 1 << 15 + UtilityWindow = 1 << 4 + DocModalWindow = 1 << 6 + NonactivatingPanel = 1 << 7 + HUDWindow = 1 << 13 + + +NSModalResponseOK = 1 +NSModalResponseCancel = 0 # NSCompositingOperationXXX is equivalent to NSCompositeXXX NSCompositingOperationClear = 0 diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index 66566470ea..5aa04e1b7c 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -68,7 +68,7 @@ def content_refreshed(self, container): def add_content(self, index, text, widget): # Create the container for the widget - container = Container(on_refresh=self.content_refreshed) + container = Container(parent=self) container.content = widget self.sub_containers.insert(index, container) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 5c9226e22b..073ca8b8a7 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -47,7 +47,7 @@ def create(self): # of the contentView if scrolling is enabled in that axis. self.document_container = Container( layout_native=self.native.contentView, - on_refresh=self.content_refreshed, + parent=self, ) self.native.documentView = self.document_container.native diff --git a/cocoa/src/toga_cocoa/widgets/splitcontainer.py b/cocoa/src/toga_cocoa/widgets/splitcontainer.py index 13948dacd2..82bed9846d 100644 --- a/cocoa/src/toga_cocoa/widgets/splitcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/splitcontainer.py @@ -45,10 +45,7 @@ def create(self): self.native.impl = self self.native.delegate = self.native - self.sub_containers = [ - Container(on_refresh=self.content_refreshed), - Container(on_refresh=self.content_refreshed), - ] + self.sub_containers = [Container(parent=self), Container(parent=self)] self.native.addSubview(self.sub_containers[0].native) self.native.addSubview(self.sub_containers[1].native) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 52de33dd7f..aba092dac6 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,21 +1,19 @@ +import weakref + from toga.command import Command as BaseCommand from toga_cocoa.container import Container from toga_cocoa.libs import ( SEL, NSBackingStoreBuffered, - NSClosableWindowMask, NSMakeRect, - NSMiniaturizableWindowMask, NSMutableArray, - NSObject, NSPoint, - NSResizableWindowMask, NSScreen, NSSize, - NSTitledWindowMask, NSToolbar, NSToolbarItem, NSWindow, + NSWindowStyleMask, objc_method, objc_property, ) @@ -25,7 +23,7 @@ def toolbar_identifier(cmd): return "ToolbarItem-%s" % id(cmd) -class WindowDelegate(NSObject): +class TogaWindow(NSWindow): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -36,17 +34,8 @@ def windowShouldClose_(self, notification) -> bool: @objc_method def windowDidResize_(self, notification) -> None: if self.interface.content: - # print() - # print("Window resize", ( - # notification.object.contentView.frame.size.width, - # notification.object.contentView.frame.size.height - # )) - if ( - notification.object.contentView.frame.size.width > 0.0 - and notification.object.contentView.frame.size.height > 0.0 - ): - # Set the window to the new size - self.interface.content.refresh() + # Set the window to the new size + self.interface.content.refresh() ###################################################################### # Toolbar delegate methods @@ -115,25 +104,19 @@ def onToolbarButtonPress_(self, obj) -> None: item.action(obj) -class TogaWindow(NSWindow): - interface = objc_property(object, weak=True) - impl = objc_property(object, weak=True) - - class Window: def __init__(self, interface, title, position, size): self.interface = interface - self.interface._impl = self - mask = NSTitledWindowMask - if self.interface.closeable: - mask |= NSClosableWindowMask + mask = NSWindowStyleMask.Titled + if self.interface.closable: + mask |= NSWindowStyleMask.Closable - if self.interface.resizeable: - mask |= NSResizableWindowMask + if self.interface.resizable: + mask |= NSWindowStyleMask.Resizable if self.interface.minimizable: - mask |= NSMiniaturizableWindowMask + mask |= NSWindowStyleMask.Miniaturizable # Create the window with a default frame; # we'll update size and position later. @@ -146,19 +129,31 @@ def __init__(self, interface, title, position, size): self.native.interface = self.interface self.native.impl = self + # 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 an explicit reference to the window. + self.native.retain() + self.set_title(title) self.set_size(size) self.set_position(position) - self.delegate = WindowDelegate.alloc().init() - self.delegate.interface = self.interface - self.delegate.impl = self - - self.native.delegate = self.delegate + self.native.delegate = self.native - self.container = Container(on_refresh=self.content_refreshed) + self.container = Container(parent=self) self.native.contentView = self.container.native + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + + def __del__(self): + self.native.release() + def create_toolbar(self): self._toolbar_items = {} for cmd in self.interface.toolbar: @@ -168,7 +163,7 @@ def create_toolbar(self): self._toolbar_native = NSToolbar.alloc().initWithIdentifier( "Toolbar-%s" % id(self) ) - self._toolbar_native.setDelegate(self.delegate) + self._toolbar_native.setDelegate(self.native) self.native.setToolbar(self._toolbar_native) @@ -200,10 +195,6 @@ def set_title(self, title): self.native.title = title def get_position(self): - # If there is no active screen, we can't get a position - if len(NSScreen.screens) == 0: - return 0, 0 - # The "primary" screen has index 0 and origin (0, 0). primary_screen = NSScreen.screens[0].frame window_frame = self.native.frame @@ -217,10 +208,6 @@ def get_position(self): ) def set_position(self, position): - # If there is no active screen, we can't set a position - if len(NSScreen.screens) == 0: - return - # The "primary" screen has index 0 and origin (0, 0). primary_screen = NSScreen.screens[0].frame @@ -253,17 +240,16 @@ def get_visible(self): return bool(self.native.isVisible) def set_full_screen(self, is_full_screen): - self.interface.factory.not_implemented("Window.set_full_screen()") + current_state = bool(self.native.styleMask & NSWindowStyleMask.FullScreen) + if is_full_screen != current_state: + self.native.toggleFullScreen(self.native) def cocoa_windowShouldClose(self): - if self.interface.on_close._raw: - # The on_close handler has a cleanup method that will enforce - # the close if the on_close handler requests it; this initial - # "should close" request can always return False. - self.interface.on_close(self) - return False - else: - return True + # The on_close handler has a cleanup method that will enforce + # the close if the on_close handler requests it; this initial + # "should close" request can always return False. + self.interface.on_close(None) + return False def close(self): self.native.close() diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py new file mode 100644 index 0000000000..55ec6208df --- /dev/null +++ b/cocoa/tests_backend/window.py @@ -0,0 +1,249 @@ +from unittest.mock import Mock + +from rubicon.objc.collections import ObjCListInstance + +from toga_cocoa.libs import ( + NSURL, + NSAlertFirstButtonReturn, + NSAlertSecondButtonReturn, + NSModalResponseCancel, + NSModalResponseOK, + NSOpenPanel, + NSSavePanel, + NSWindow, + NSWindowStyleMask, +) + +from .probe import BaseProbe + + +class WindowProbe(BaseProbe): + supports_minimize_control = True + supports_move_while_hidden = True + supports_unminimize = True + + def __init__(self, app, window): + super().__init__() + self.app = app + self.window = window + self.impl = window._impl + self.native = window._impl.native + assert isinstance(self.native, NSWindow) + + async def wait_for_window(self, message, minimize=False, full_screen=False): + await self.redraw( + message, + delay=0.75 if full_screen else 0.5 if minimize else None, + ) + + def close(self): + self.native.performClose(None) + + @property + def content_size(self): + return ( + self.native.contentView.frame.size.width, + self.native.contentView.frame.size.height, + ) + + @property + def is_full_screen(self): + return bool(self.native.styleMask & NSWindowStyleMask.FullScreen) + + @property + def is_resizable(self): + return bool(self.native.styleMask & NSWindowStyleMask.Resizable) + + @property + def is_closable(self): + return bool(self.native.styleMask & NSWindowStyleMask.Closable) + + @property + def is_minimizable(self): + return bool(self.native.styleMask & NSWindowStyleMask.Miniaturizable) + + @property + def is_minimized(self): + return bool(self.native.isMiniaturized) + + def minimize(self): + self.native.performMiniaturize(None) + + def unminimize(self): + self.native.deminiaturize(None) + + async def close_info_dialog(self, dialog): + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertFirstButtonReturn, + ) + await self.redraw("Info dialog dismissed") + + async def close_question_dialog(self, dialog, result): + if result: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertFirstButtonReturn, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertSecondButtonReturn, + ) + await self.redraw(f"Question dialog ({'YES' if result else 'NO'}) dismissed") + + async def close_confirm_dialog(self, dialog, result): + if result: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertFirstButtonReturn, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertSecondButtonReturn, + ) + + await self.redraw(f"Question dialog ({'OK' if result else 'CANCEL'}) dismissed") + + async def close_error_dialog(self, dialog): + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertFirstButtonReturn, + ) + await self.redraw("Error dialog dismissed") + + async def close_stack_trace_dialog(self, dialog, result): + if result is None: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertFirstButtonReturn, + ) + await self.redraw("Stack trace dialog dismissed") + else: + if result: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertFirstButtonReturn, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSAlertSecondButtonReturn, + ) + + await self.redraw( + f"Stack trace dialog ({'RETRY' if result else 'QUIT'}) dismissed" + ) + + async def close_save_file_dialog(self, dialog, result): + assert isinstance(dialog.native, NSSavePanel) + + if result: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseOK, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseCancel, + ) + + await self.redraw( + f"Save file dialog ({'SAVE' if result else 'CANCEL'}) dismissed" + ) + + async def close_open_file_dialog(self, dialog, result, multiple_select): + assert isinstance(dialog.native, NSOpenPanel) + + if result is not None: + if multiple_select: + if result: + # Since we are mocking selected_path(), it's never actually invoked + # under test conditions. Call it just to confirm that it returns the + # type we think it does. + assert isinstance(dialog.selected_paths(), ObjCListInstance) + + dialog.selected_paths = Mock( + return_value=[ + NSURL.fileURLWithPath(str(path), isDirectory=False) + for path in result + ] + ) + else: + dialog.selected_path = Mock( + return_value=NSURL.fileURLWithPath( + str(result), + isDirectory=False, + ) + ) + + # If there's nothing selected, you can't press OK. + if result: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseOK, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseCancel, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseCancel, + ) + + await self.redraw( + f"Open {'multiselect ' if multiple_select else ''}file dialog " + f"({'OPEN' if result else 'CANCEL'}) dismissed" + ) + + async def close_select_folder_dialog(self, dialog, result, multiple_select): + assert isinstance(dialog.native, NSOpenPanel) + + if result is not None: + if multiple_select: + if result: + # Since we are mocking selected_path(), it's never actually invoked + # under test conditions. Call it just to confirm that it returns the + # type we think it does. + assert isinstance(dialog.selected_paths(), ObjCListInstance) + + dialog.selected_paths = Mock( + return_value=[ + NSURL.fileURLWithPath(str(path), isDirectory=True) + for path in result + ] + ) + else: + dialog.selected_path = Mock( + return_value=NSURL.fileURLWithPath( + str(result), + isDirectory=True, + ) + ) + + # If there's nothing selected, you can't press OK. + if result: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseOK, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseCancel, + ) + else: + self.native.endSheet( + self.native.attachedSheet, + returnCode=NSModalResponseCancel, + ) + + await self.redraw( + f"{'Multiselect' if multiple_select else ' Select'} folder dialog " + f"({'OPEN' if result else 'CANCEL'}) dismissed" + ) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 90ea3a24e8..69cf7a320d 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -127,43 +127,49 @@ def __init__( title: str | None = None, position: tuple[int, int] = (100, 100), size: tuple[int, int] = (640, 480), - toolbar: list[Widget] | None = None, - resizeable: bool = True, + resizable: bool = True, minimizable: bool = True, - factory: None = None, # DEPRECATED ! - on_close: None = None, - ) -> None: - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### + ): + """Create a new application Main Window. + + :param id: The ID of the window. + :param title: Title for the window. Defaults to the formal name of the app. + :param position: Position of the window, as a tuple of ``(x, y)`` coordinates. + :param size: Size of the window, as a tuple of ``(width, height)``, in pixels. + :param resizeable: Can the window be manually resized by the user? + :param minimizable: Can the window be minimized by the user? + """ super().__init__( id=id, title=title, position=position, size=size, - toolbar=toolbar, - resizeable=resizeable, - closeable=True, + resizable=resizable, + closable=True, minimizable=minimizable, - on_close=on_close, ) - @Window.on_close.setter - def on_close(self, handler): - """Raise an exception. ``on_exit`` for the app should be used instead of ``on_close`` on - main window. + @property + def _default_title(self) -> str: + return App.app.formal_name + + @property + def on_close(self) -> None: + """The handler to invoke before the window is closed in response to a user + action. - Args: - handler (:obj:`callable`): The handler passed. + Always returns ``None``. Main windows should use :meth:`toga.App.on_exit`, + rather than ``on_close``. + + :raises ValueError: if an attempt is made to set the ``on_close`` handler for an + App. """ + return None + + @on_close.setter + def on_close(self, handler: Any): if handler: - raise AttributeError( + raise ValueError( "Cannot set on_close handler for the main window. Use the app on_exit handler instead" ) @@ -492,13 +498,15 @@ def main_window(self) -> MainWindow: @main_window.setter def main_window(self, window: MainWindow) -> None: self._main_window = window - self.windows += window self._impl.set_main_window(window) @property def current_window(self): """Return the currently active content window.""" - return self._impl.get_current_window().interface + window = self._impl.get_current_window() + if window is None: + return window + return window.interface @current_window.setter def current_window(self, window): diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 8a1f1cd2d9..4f66dabd83 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -248,8 +248,6 @@ def __init__( ################################################################## # End backwards compatibility. ################################################################## - orig_action = action - self.action = wrapped_handler(self, action) self.text = text self.shortcut = shortcut @@ -260,6 +258,9 @@ def __init__( self.section = section if section else 0 self.order = order if order else 0 + orig_action = action + self.action = wrapped_handler(self, action) + self.factory = get_platform_factory() self._impl = self.factory.Command(interface=self) @@ -392,6 +393,9 @@ def add(self, *commands): if self.on_change: self.on_change() + def __len__(self): + return len(self._commands) + def __iter__(self): prev_cmd = None for cmd in sorted(self._commands): diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index c434a3454b..5d22ed279b 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -99,7 +99,12 @@ def _handler(widget, *args, **kwargs): else: # A dummy no-op handler def _handler(widget, *args, **kwargs): - pass + try: + if cleanup: + cleanup(interface, None) + except Exception as e: + print("Error in handler cleanup:", e, file=sys.stderr) + traceback.print_exc() _handler._raw = None diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 68af53e3de..e535951fd3 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import weakref from builtins import id as identifier from typing import TYPE_CHECKING, Iterator, NoReturn @@ -201,20 +202,20 @@ def app(self) -> App | None: :raises ValueError: If this widget is already associated with another app. """ - return self._app + return self._app() if self._app else None @app.setter def app(self, app: App | None) -> None: # If the widget is already assigned to an app - if self._app: - if self._app == app: + if self.app: + if self.app == app: # If app is the same as the previous app, return return # Deregister the widget from the old app - self._app.widgets.remove(self.id) + self.app.widgets.remove(self.id) - self._app = app + self._app = weakref.ref(app) if app else None self._impl.set_app(app) for child in self.children: child.app = app @@ -230,7 +231,7 @@ def window(self) -> Window | None: When setting the window for a widget, all children of this widget will be recursively assigned to the same window. """ - return self._window + return self._window() if self._window else None @window.setter def window(self, window: Window | None) -> None: @@ -238,7 +239,7 @@ def window(self, window: Window | None) -> None: if self.window is not None: self.window.widgets.remove(self.id) - self._window = window + self._window = weakref.ref(window) if window else None self._impl.set_window(window) for child in self.children: diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 5d9996a0a4..bd1c6b6ecf 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -60,20 +60,6 @@ def __init__(self, window: Window): class Window: - """The top level container of an application. - - Args: - id: The ID of the window. - title: Title for the window. - position: Position of the window, as x,y coordinates. - size: Size of the window, as (width, height) sizes, in pixels. - toolbar: (Deprecated, will have no effect) - resizeable: Toggle if the window is resizable by the user. - closeable: Toggle if the window is closable by the user. - minimizable: Toggle if the window is minimizable by the user. - on_close: A callback to invoke when the user makes a request to close the window. - """ - _WINDOW_CLASS = "Window" def __init__( @@ -82,21 +68,44 @@ def __init__( title: str | None = None, position: tuple[int, int] = (100, 100), size: tuple[int, int] = (640, 480), - toolbar: list[Widget | None] = None, - resizeable: bool = True, - closeable: bool = True, + resizable: bool = True, + closable: bool = True, minimizable: bool = True, - factory: None = None, # DEPRECATED ! on_close: OnCloseHandler | None = None, + resizeable=None, # DEPRECATED + closeable=None, # DEPRECATED ) -> None: + """Create a new Window. + + :param id: The ID of the window. + :param title: Title for the window. Defaults to "Toga". + :param position: Position of the window, as a tuple of ``(x, y)`` coordinates. + :param size: Size of the window, as a tuple of ``(width, height)``, in pixels. + :param resizable: Can the window be manually resized by the user? + :param closable: Should the window provide the option to be manually closed? + :param minimizable: Can the window be minimized by the user? + :param on_close: The initial ``on_close`` handler. + :param resizeable: **DEPRECATED** - Use ``resizable``. + :param closeable: **DEPRECATED** - Use ``closable``. + """ ###################################################################### - # 2022-09: Backwards compatibility + # 2023-08: Backwards compatibility ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) + if resizeable is not None: + warnings.warn( + "Window.resizeable has been renamed Window.resizable", + DeprecationWarning, + ) + resizable = resizeable + + if closeable is not None: + warnings.warn( + "Window.closeable has been renamed Window.closable", + DeprecationWarning, + ) + closable = closeable ###################################################################### - # End backwards compatibility. + # End backwards compatibility ###################################################################### self.widgets = WidgetRegistry() @@ -107,14 +116,14 @@ def __init__( self._content = None self._is_full_screen = False - self.resizeable = resizeable - self.closeable = closeable - self.minimizable = minimizable + self._resizable = resizable + self._closable = closable + self._minimizable = minimizable self.factory = get_platform_factory() self._impl = getattr(self.factory, self._WINDOW_CLASS)( interface=self, - title="Toga" if title is None else title, + title=title if title else self._default_title, position=position, size=size, ) @@ -125,28 +134,21 @@ def __init__( @property def id(self) -> str: - """The DOM identifier for the window. - - This id can be used to target CSS directives. - """ + """The DOM identifier for the window.""" return self._id @property def app(self) -> App | None: """Instance of the :class:`toga.App` that this window belongs to. - Returns: - The app that it belongs to :class:`toga.App`. - - Raises: - Exception: If the window already is associated with another app. - """ + :raises ValueError: If a window is already assigned to an app, and an attempt is made + to assign the window to a new app.""" return self._app @app.setter def app(self, app: App) -> None: if self._app: - raise Exception("Window is already associated with an App") + raise ValueError("Window is already associated with an App") self._app = app self._impl.set_app(app._impl) @@ -154,17 +156,36 @@ def app(self, app: App) -> None: if self.content: self.content.app = app + @property + def _default_title(self) -> str: + return "Toga" + @property def title(self) -> str: - """Title of the window. If no title is given it defaults to ``"Toga"``.""" + """Title of the window. If no title is provided, the title will default to ``"Toga"``.""" return self._impl.get_title() @title.setter def title(self, title: str) -> None: if not title: - title = "Toga" + title = self._default_title + + self._impl.set_title(str(title).split("\n")[0]) + + @property + def resizable(self) -> bool: + """Is the window resizable?""" + return self._resizable - self._impl.set_title(title) + @property + def closable(self) -> bool: + """Can the window be closed by a user action?""" + return self._closable + + @property + def minimizable(self) -> bool: + """Can the window be minimized?""" + return self._minimizable @property def toolbar(self) -> CommandSet: @@ -219,23 +240,37 @@ def position(self, position: tuple[int, int]) -> None: self._impl.set_position(position) def show(self) -> None: - """Show window, if hidden.""" + """Show the window, if hidden.""" + if self.app is None: - raise AttributeError( - "Can't show a window that doesn't have an associated app" - ) + # Needs to be a late import to avoid circular dependencies. + from toga import App + + App.app.windows += self + self._impl.show() def hide(self) -> None: """Hide window, if shown.""" if self.app is None: - raise AttributeError( - "Can't hide a window that doesn't have an associated app" - ) + # Needs to be a late import to avoid circular dependencies. + from toga import App + + App.app.windows += self + self._impl.hide() @property def full_screen(self) -> bool: + """Is the window in full screen mode? + + .. note:: + Full screen mode is *not* the same as "maximized". A full screen window + has no title bar, tool bar or window control widgets; some or all of these + controls may be visible on a maximized app. A good example of "full screen" + mode is a slideshow app in presentation mode - the only visible content is + the slide. + """ return self._is_full_screen @full_screen.setter @@ -245,6 +280,7 @@ def full_screen(self, is_full_screen: bool) -> None: @property def visible(self) -> bool: + "Is the window visible?" return self._impl.get_visible() @visible.setter @@ -256,18 +292,28 @@ def visible(self, visible: bool) -> None: @property def on_close(self) -> OnCloseHandler: - """The handler to invoke before the window is closed.""" + """The handler to invoke before the window is closed in response to a user + action. + + If the handler returns ``False``, the request to close the window will be + cancelled. + """ return self._on_close @on_close.setter def on_close(self, handler: OnCloseHandler | None) -> None: def cleanup(window: Window, should_close: bool) -> None: - if should_close: + if should_close or handler is None: window.close() self._on_close = wrapped_handler(self, handler, cleanup=cleanup) def close(self) -> None: + """Close the window. + + This *does not* invoke the ``on_close`` handler; the window will be immediately + and unconditionally closed. + """ self.app.windows -= self self._impl.close() @@ -283,7 +329,7 @@ def info_dialog( ) -> Dialog: """Ask the user to acknowledge some information. - Presents as a dialog with a single 'OK' button to close the dialog. + Presents as a dialog with a single "OK" button to close the dialog. :param title: The title of the dialog window. :param message: The message to display. @@ -306,15 +352,15 @@ def question_dialog( ) -> Dialog: """Ask the user a yes/no question. - Presents as a dialog with a 'YES' and 'NO' button. + Presents as a dialog with "Yes" and "No" buttons. :param title: The title of the dialog window. :param message: The question to be answered. :param on_result: A callback that will be invoked when the user selects an option on the dialog. :returns: An awaitable Dialog object. The Dialog object returns - ``True`` when the 'YES' button was pressed, ``False`` when - the 'NO' button was pressed. + ``True`` when the "Yes" button was pressed, ``False`` when + the "No" button was pressed. """ dialog = Dialog(self) self.factory.dialogs.QuestionDialog( @@ -330,16 +376,16 @@ def confirm_dialog( ) -> Dialog: """Ask the user to confirm if they wish to proceed with an action. - Presents as a dialog with 'Cancel' and 'OK' buttons (or whatever labels - are appropriate on the current platform) + Presents as a dialog with "Cancel" and "OK" buttons (or whatever labels + are appropriate on the current platform). :param title: The title of the dialog window. :param message: A message describing the action to be confirmed. :param on_result: A callback that will be invoked when the user selects an option on the dialog. :returns: An awaitable Dialog object. The Dialog object returns - ``True`` when the 'OK' button was pressed, ``False`` when - the 'CANCEL' button was pressed. + ``True`` when the "OK" button was pressed, ``False`` when + the "CANCEL" button was pressed. """ dialog = Dialog(self) self.factory.dialogs.ConfirmDialog( @@ -355,14 +401,14 @@ def error_dialog( ) -> Dialog: """Ask the user to acknowledge an error state. - Presents as an error dialog with a 'OK' button to close the dialog. + Presents as an error dialog with a "OK" button to close the dialog. :param title: The title of the dialog window. :param message: The error message to display. :param on_result: A callback that will be invoked when the user selects an option on the dialog. :returns: An awaitable Dialog object. The Dialog object returns - ``None`` after the user pressed the 'OK' button. + ``None`` after the user pressed the "OK" button. """ dialog = Dialog(self) self.factory.dialogs.ErrorDialog( @@ -430,7 +476,7 @@ def stack_trace_dialog( self.factory.dialogs.StackTraceDialog( dialog, title, - message, + message=message, content=content, retry=retry, on_result=wrapped_handler(self, on_result), @@ -448,18 +494,18 @@ def save_file_dialog( Presents the user a system-native "Save file" dialog. - This opens a native dialog where the user can select a place to save a file. - It is possible to suggest a filename and force the user to use a specific file extension. - If no path is returned (e.g. dialog is canceled), a ValueError is raised. + This opens a native dialog where the user can select a place to save a file. It + is possible to suggest a filename, and constrain the list of allowed file + extensions. :param title: The title of the dialog window :param suggested_filename: A default filename :param file_types: A list of strings with the allowed file extensions. - :param on_result: A callback that will be invoked when the user - selects an option on the dialog. - :returns: An awaitable Dialog object. The Dialog object returns - a path object for the selected file location, or ``None`` if - the user cancelled the save operation. + :param on_result: A callback that will be invoked when the user selects an + option on the dialog. + :returns: An awaitable Dialog object. The Dialog object returns a path object + for the selected file location, or ``None`` if the user cancelled the save + operation. """ dialog = Dialog(self) # Convert suggested filename to a path (if it isn't already), @@ -486,8 +532,9 @@ def open_file_dialog( title: str, initial_directory: Path | str | None = None, file_types: list[str] | None = None, - multiselect: Literal[False] = False, + multiple_select: Literal[False] = False, on_result: DialogResultHandler[Path | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -497,8 +544,9 @@ def open_file_dialog( title: str, initial_directory: Path | str | None = None, file_types: list[str] | None = None, - multiselect: Literal[True] = True, + multiple_select: Literal[True] = True, on_result: DialogResultHandler[list[Path] | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -508,8 +556,9 @@ def open_file_dialog( title: str, initial_directory: Path | str | None = None, file_types: list[str] | None = None, - multiselect: bool = False, + multiple_select: bool = False, on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -518,8 +567,9 @@ def open_file_dialog( title: str, initial_directory: Path | str | None = None, file_types: list[str] | None = None, - multiselect: bool = False, + multiple_select: bool = False, on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: """Ask the user to select a file (or files) to open. @@ -528,24 +578,38 @@ def open_file_dialog( :param title: The title of the dialog window :param initial_directory: The initial folder in which to open the dialog. If ``None``, use the default location provided by the operating system - (which will often be "last used location") + (which will often be the last used location) :param file_types: A list of strings with the allowed file extensions. - :param multiselect: If True, the user will be able to select multiple - files; if False, the selection will be restricted to a single file/ + :param multiple_select: If True, the user will be able to select multiple + files; if False, the selection will be restricted to a single file. :param on_result: A callback that will be invoked when the user selects an option on the dialog. + :param multiselect: **DEPRECATED** Use ``multiple_select``. :returns: An awaitable Dialog object. The Dialog object returns - a list of ``Path`` objects if ``multiselect`` is ``True``, or a single + a list of ``Path`` objects if ``multiple_select`` is ``True``, or a single ``Path`` otherwise. Returns ``None`` if the open operation is cancelled by the user. """ + ###################################################################### + # 2023-08: Backwards compatibility + ###################################################################### + if multiselect is not None: + warnings.warn( + "open_file_dialog(multiselect) has been renamed multiple_select", + DeprecationWarning, + ) + multiple_select = multiselect + ###################################################################### + # End Backwards compatibility + ###################################################################### + dialog = Dialog(self) self.factory.dialogs.OpenFileDialog( dialog, title, initial_directory=Path(initial_directory) if initial_directory else None, file_types=file_types, - multiselect=multiselect, + multiple_select=multiple_select, on_result=wrapped_handler(self, on_result), ) return dialog @@ -555,8 +619,9 @@ def select_folder_dialog( self, title: str, initial_directory: Path | str | None = None, - multiselect: Literal[False] = False, + multiple_select: Literal[False] = False, on_result: DialogResultHandler[Path | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -565,8 +630,9 @@ def select_folder_dialog( self, title: str, initial_directory: Path | str | None = None, - multiselect: Literal[True] = True, + multiple_select: Literal[True] = True, on_result: DialogResultHandler[list[Path] | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -575,8 +641,9 @@ def select_folder_dialog( self, title: str, initial_directory: Path | str | None = None, - multiselect: bool = False, + multiple_select: bool = False, on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: ... @@ -584,8 +651,9 @@ def select_folder_dialog( self, title: str, initial_directory: Path | str | None = None, - multiselect: bool = False, + multiple_select: bool = False, on_result: DialogResultHandler[list[Path] | Path | None] | None = None, + multiselect=None, # DEPRECATED ) -> Dialog: """Ask the user to select a directory/folder (or folders) to open. @@ -595,21 +663,57 @@ def select_folder_dialog( :param initial_directory: The initial folder in which to open the dialog. If ``None``, use the default location provided by the operating system (which will often be "last used location") - :param multiselect: If True, the user will be able to select multiple + :param multiple_select: If True, the user will be able to select multiple files; if False, the selection will be restricted to a single file/ :param on_result: A callback that will be invoked when the user selects an option on the dialog. + :param multiselect: **DEPRECATED** Use ``multiple_select``. :returns: An awaitable Dialog object. The Dialog object returns - a list of ``Path`` objects if ``multiselect`` is ``True``, or a single + a list of ``Path`` objects if ``multiple_select`` is ``True``, or a single ``Path`` otherwise. Returns ``None`` if the open operation is cancelled by the user. """ + ###################################################################### + # 2023-08: Backwards compatibility + ###################################################################### + if multiselect is not None: + warnings.warn( + "select_folder_dialog(multiselect) has been renamed multiple_select", + DeprecationWarning, + ) + multiple_select = multiselect + ###################################################################### + # End Backwards compatibility + ###################################################################### + dialog = Dialog(self) self.factory.dialogs.SelectFolderDialog( dialog, title, initial_directory=Path(initial_directory) if initial_directory else None, - multiselect=multiselect, + multiple_select=multiple_select, on_result=wrapped_handler(self, on_result), ) return dialog + + ###################################################################### + # 2023-08: Backwards compatibility + ###################################################################### + + @property + def resizeable(self) -> bool: + """**DEPRECATED** Use :attr:`resizable`""" + warnings.warn( + "Window.resizeable has been renamed Window.resizable", + DeprecationWarning, + ) + return self._resizable + + @property + def closeable(self) -> bool: + """**DEPRECATED** Use :attr:`closable`""" + warnings.warn( + "Window.closeable has been renamed Window.closable", + DeprecationWarning, + ) + return self._closable diff --git a/core/tests/test_app.py b/core/tests/test_app.py index e6ff1b747d..0f646be4cf 100644 --- a/core/tests/test_app.py +++ b/core/tests/test_app.py @@ -1,4 +1,5 @@ -from unittest.mock import MagicMock +import asyncio +from unittest.mock import Mock import toga from toga.widgets.base import WidgetRegistry @@ -13,7 +14,7 @@ def setUp(self): self.app_id = "org.beeware.test-app" self.id = "dom-id" - self.content = MagicMock() + self.content = Mock() self.content_id = "content-id" self.content.id = self.content_id @@ -161,16 +162,19 @@ def test_beep(self): self.assertActionPerformed(self.app, "beep") def test_add_background_task(self): + thing = Mock() + async def test_handler(sender): - pass + thing() self.app.add_background_task(test_handler) - self.assertActionPerformedWith( - self.app, - "loop:call_soon_threadsafe", - handler=test_handler, - args=(None,), - ) + + async def run_test(): + # Give the background task time to run. + await asyncio.sleep(0.1) + thing.assert_called_once() + + self.app._impl.loop.run_until_complete(run_test()) def test_override_startup(self): class BadApp(toga.App): @@ -196,19 +200,19 @@ def setUp(self): self.app_id = "beeware.org" self.id = "id" - self.content = MagicMock() + self.content = Mock() self.app = toga.DocumentApp(self.name, self.app_id, id=self.id) def test_app_documents(self): self.assertEqual(self.app.documents, []) - doc = MagicMock() + doc = Mock() self.app._documents.append(doc) self.assertEqual(self.app.documents, [doc]) def test_override_startup(self): - mock = MagicMock() + mock = Mock() class DocApp(toga.DocumentApp): def startup(self): diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index afc49d689c..47f6ee59ef 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -30,12 +30,6 @@ def test_document_app(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_main_window(self): - with self.assertWarns(DeprecationWarning): - widget = toga.MainWindow(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_command(self): with self.assertWarns(DeprecationWarning): widget = toga.Command(self.callback, "Test", factory=self.factory) @@ -62,12 +56,6 @@ def test_icon(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_window(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Window(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_canvas_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Canvas(factory=self.factory) diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index 302bb4e095..32dbdcc818 100644 --- a/core/tests/test_handlers.py +++ b/core/tests/test_handlers.py @@ -18,6 +18,44 @@ def test_noop_handler(): wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) +def test_noop_handler_with_cleanup(): + """cleanup is still performed when a no-op handler is used""" + obj = Mock() + cleanup = Mock() + + wrapped = wrapped_handler(obj, None, cleanup=cleanup) + + assert wrapped._raw is None + + # This does nothing, but doesn't raise an error. + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + + # Cleanup method was invoked + cleanup.assert_called_once_with(obj, None) + + +def test_noop_handler_with_cleanup_error(capsys): + """If cleanup on a no-op handler raises an error, it is logged""" + obj = Mock() + cleanup = Mock(side_effect=Exception("Problem in cleanup")) + + wrapped = wrapped_handler(obj, None, cleanup=cleanup) + + assert wrapped._raw is None + + # This does nothing, but doesn't raise an error. + wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + + # Cleanup method was invoked + cleanup.assert_called_once_with(obj, None) + + # Evidence of the handler cleanup error is in the log. + assert ( + "Error in handler cleanup: Problem in cleanup\nTraceback (most recent call last):\n" + in capsys.readouterr().err + ) + + def test_function_handler(): """A function can be used as a handler""" obj = Mock() @@ -136,13 +174,11 @@ def handler(*args, **kwargs): ) -def test_generator_handler(): +def test_generator_handler(event_loop): """A generator can be used as a handler""" obj = Mock() handler_call = {} - loop = asyncio.new_event_loop() - def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -164,7 +200,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -175,13 +211,11 @@ async def waiter(): } -def test_generator_handler_error(capsys): +def test_generator_handler_error(event_loop, capsys): """A generator can raise an error""" obj = Mock() handler_call = {} - loop = asyncio.new_event_loop() - def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -201,7 +235,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -216,14 +250,12 @@ async def waiter(): ) -def test_generator_handler_with_cleanup(): +def test_generator_handler_with_cleanup(event_loop): """A generator can have cleanup""" obj = Mock() cleanup = Mock() handler_call = {} - loop = asyncio.new_event_loop() - def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -246,7 +278,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -260,14 +292,12 @@ async def waiter(): cleanup.assert_called_once_with(obj, 42) -def test_generator_handler_with_cleanup_error(capsys): +def test_generator_handler_with_cleanup_error(event_loop, capsys): """A generator can raise an error during cleanup""" obj = Mock() cleanup = Mock(side_effect=Exception("Problem in cleanup")) handler_call = {} - loop = asyncio.new_event_loop() - def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -290,7 +320,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -310,13 +340,11 @@ async def waiter(): ) -def test_coroutine_handler(): +def test_coroutine_handler(event_loop): """A coroutine can be used as a handler""" obj = Mock() handler_call = {} - loop = asyncio.new_event_loop() - async def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -336,7 +364,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -346,13 +374,11 @@ async def waiter(): } -def test_coroutine_handler_error(capsys): +def test_coroutine_handler_error(event_loop, capsys): """A coroutine can raise an error""" obj = Mock() handler_call = {} - loop = asyncio.new_event_loop() - async def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -372,7 +398,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -387,14 +413,12 @@ async def waiter(): ) -def test_coroutine_handler_with_cleanup(): +def test_coroutine_handler_with_cleanup(event_loop): """A coroutine can have cleanup""" obj = Mock() cleanup = Mock() handler_call = {} - loop = asyncio.new_event_loop() - async def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -415,7 +439,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -428,14 +452,12 @@ async def waiter(): cleanup.assert_called_once_with(obj, 42) -def test_coroutine_handler_with_cleanup_error(capsys): +def test_coroutine_handler_with_cleanup_error(event_loop, capsys): """A coroutine can raise an error during cleanup""" obj = Mock() cleanup = Mock(side_effect=Exception("Problem in cleanup")) handler_call = {} - loop = asyncio.new_event_loop() - async def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs @@ -456,7 +478,7 @@ async def waiter(): await asyncio.sleep(0.01) count += 1 - loop.run_until_complete(waiter()) + event_loop.run_until_complete(waiter()) # Handler arguments are as expected. assert handler_call == { @@ -488,7 +510,7 @@ def test_native_handler(): assert wrapped == native_method -def test_async_result(): +def test_async_result(event_loop): class TestAsyncResult(AsyncResult): RESULT_TYPE = "Test" diff --git a/core/tests/test_window.py b/core/tests/test_window.py index 83d689ecb7..a5f38bd3ee 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -1,379 +1,815 @@ from pathlib import Path -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import Mock + +import pytest import toga -from toga.command import CommandSet -from toga.widgets.base import WidgetRegistry -from toga_dummy.utils import TestCase - - -class TestWindow(TestCase): - def setUp(self): - super().setUp() - self.window = toga.Window() - self.app = toga.App("test_name", "id.app") - - def test_window_widgets_registry_on_constructor(self): - self.assertTrue(isinstance(self.window.widgets, WidgetRegistry)) - self.assertEqual(len(self.window.widgets), 0) - - def test_show_is_not_called_in_constructor(self): - self.assertActionNotPerformed(self.window, "show") - - def test_show_raises_error_when_app_not_set(self): - with self.assertRaisesRegex( - AttributeError, "^Can't show a window that doesn't have an associated app$" - ): - self.window.show() - - def test_window_show_with_app_set(self): - self.window.app = self.app - self.window.show() - self.assertActionPerformed(self.window, "show") - self.assertTrue(self.window.visible) - self.assertValueSet(self.window, "visible", True) - - def test_hide_raises_error_when_app_not_set(self): - with self.assertRaisesRegex( - AttributeError, "^Can't hide a window that doesn't have an associated app$" - ): - self.window.hide() - - def test_window_hide_with_app_set(self): - self.window.app = self.app - self.window.hide() - self.assertActionPerformed(self.window, "hide") - self.assertFalse(self.window.visible) - self.assertValueSet(self.window, "visible", False) - - def test_window_show_by_setting_visible_to_true(self): - self.window.app = self.app - self.window.visible = True - self.assertActionPerformed(self.window, "show") - self.assertTrue(self.window.visible) - self.assertValueSet(self.window, "visible", True) - - def test_window_show_by_setting_visible_to_false(self): - self.window.app = self.app - self.window.visible = False - self.assertActionPerformed(self.window, "hide") - self.assertFalse(self.window.visible) - self.assertValueSet(self.window, "visible", False) - - def test_set_window_application_twice(self): - self.assertIsNotNone(self.window.id) - new_app = toga.App("error_name", "id.error") - self.window.app = self.app - with self.assertRaisesRegex( - Exception, "^Window is already associated with an App$" - ): - self.window.app = new_app - - def test_window_title(self): - # Assert default value - title = self.window.title - self.assertEqual(title, "Toga") - self.assertValueGet(self.window, "title") - - # Set a new window title - self.window.title = "New title" - self.assertValueSet(self.window, "title", "New title") - - # New window title can be retrieved - title = self.window.title - self.assertValueGet(self.window, "title") - self.assertEqual(title, "New title") - - # Set a default window title - self.window.title = None - self.assertValueSet(self.window, "title", "Toga") - - # New window title can be retrieved - title = self.window.title - self.assertValueGet(self.window, "title") - self.assertEqual(title, "Toga") - - def test_toolbar(self): - toolbar = self.window.toolbar - self.assertIsInstance(toolbar, CommandSet) - - def test_set_content_without_app(self): - content = MagicMock() - - self.window.content = content - self.assertEqual(content.window, self.window) - self.assertIsNone(content.app) - - def test_set_content_with_app(self): - content = MagicMock() - - self.window.app = self.app - self.window.content = content - - self.assertEqual(content.window, self.window) - self.assertEqual(content.app, self.app) - - def test_set_app_after_content(self): - content = MagicMock() - - self.window.content = content - self.window.app = self.app - - self.assertEqual(content.window, self.window) - self.assertEqual(content.app, self.app) - - def test_set_app_adds_window_widgets_to_app(self): - id0, id1, id2, id3 = "id0", "id1", "id2", "id3" - widget1, widget2, widget3 = ( - toga.Label(id=id1, text="label 1"), - toga.Label(id=id2, text="label 1"), - toga.Label(id=id3, text="label 1"), - ) - content = toga.Box(id=id0, children=[widget1, widget2, widget3]) - - self.window.content = content - - # The window has widgets in it's repository - self.assertEqual(len(self.window.widgets), 4) - self.assertEqual(self.window.widgets[id0], content) - self.assertEqual(self.window.widgets[id1], widget1) - self.assertEqual(self.window.widgets[id2], widget2) - self.assertEqual(self.window.widgets[id3], widget3) - - # The app doesn't know about the widgets - self.assertEqual(len(self.app.widgets), 0) - - # Assign the window to the app - self.window.app = self.app - - # The window's content widgets are now known to the app. - self.assertEqual(len(self.app.widgets), 4) - self.assertEqual(self.app.widgets[id0], content) - self.assertEqual(self.app.widgets[id1], widget1) - self.assertEqual(self.app.widgets[id2], widget2) - self.assertEqual(self.app.widgets[id3], widget3) - - def test_size(self): - # Add some content - content = MagicMock() - self.window.content = content - - # Confirm defaults - self.assertEqual(self.window.size, (640, 480)) - self.assertValueGet(self.window, "size") - - content.refresh.assert_called_once_with() - - def test_set_size(self): - # Add some content - content = MagicMock() - self.window.content = content - - # A new size can be assigned - new_size = (1200, 40) - self.window.size = new_size - self.assertValueSet(self.window, "size", new_size) - - # Side effect of setting window size is a refresh on window content - self.assertEqual(content.refresh.call_args_list, [call(), call()]) - - # New size can be retrieved - self.assertEqual(self.window.size, new_size) - self.assertValueGet(self.window, "size") - - def test_position(self): - # Confirm defaults - self.assertEqual(self.window.position, (100, 100)) - - # A new position can be assigned - new_position = (40, 79) - self.window.position = new_position - self.assertValueSet(self.window, "position", new_position) - - # New position can be retrieved - self.assertEqual(self.window.position, new_position) - self.assertValueGet(self.window, "position") - - def test_full_screen_set(self): - self.assertFalse(self.window.full_screen) - with patch.object(self.window, "_impl"): - self.window.full_screen = True - self.assertTrue(self.window.full_screen) - self.window._impl.set_full_screen.assert_called_once_with(True) - - def test_on_close(self): - with patch.object(self.window, "_impl"): - self.app.windows += self.window - self.assertIsNone(self.window.on_close._raw) - - # set a new callback - def callback(window, **extra): - return f"called {type(window)} with {extra}" - - self.window.on_close = callback - self.assertEqual(self.window.on_close._raw, callback) - self.assertEqual( - self.window.on_close(None, a=1), - "called with {'a': 1}", - ) - - def test_on_close_at_create(self): - def callback(window, **extra): - return f"called {type(window)} with {extra}" - - window = toga.Window(on_close=callback) - self.app.windows += window - - self.assertEqual(window.on_close._raw, callback) - self.assertEqual( - window.on_close(None, a=1), - "called with {'a': 1}", - ) +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) - self.assertActionPerformed(window, "close") - def test_close(self): - # Ensure the window is associated with an app - self.app.windows += self.window - with patch.object(self.window, "_impl"): - self.window.close() - self.window._impl.close.assert_called_once_with() +@pytest.fixture +def app(event_loop): + return toga.App("Test App", "org.beeware.toga.window") - def test_question_dialog(self): - title = "question_dialog_test" - message = "sample_text" - self.window.question_dialog(title, message) +@pytest.fixture +def window(): + return toga.Window() + - self.assertActionPerformedWith( - self.window, "question_dialog", title=title, message=message - ) +def test_window_created(): + "A Window can be created with minimal arguments" + window = toga.Window() - def test_confirm_dialog(self): - title = "confirm_dialog_test" - message = "sample_text" + assert window.app is None + assert window.content is None - self.window.confirm_dialog(title, message) + assert window._impl.interface == window + assert_action_performed(window, "create Window") - self.assertActionPerformedWith( - self.window, "confirm_dialog", title=title, message=message - ) + # We can't know what the ID is, but it must be a string. + assert isinstance(window.id, str) + assert window.title == "Toga" + assert window.position == (100, 100) + assert window.size == (640, 480) + assert window.resizable + assert window.closable + assert window.minimizable + assert len(window.toolbar) == 0 + assert window.on_close._raw is None + + +def test_window_created_explicit(): + "Explicit arguments at construction are stored" + on_close_handler = Mock() - def test_error_dialog(self): - title = "error_dialog_test" - message = "sample_text" + window = toga.Window( + id="my-window", + title="My Window", + position=(10, 20), + size=(200, 300), + resizable=False, + closable=False, + minimizable=False, + on_close=on_close_handler, + ) - self.window.error_dialog(title, message) + assert window.app is None + assert window.content is None - self.assertActionPerformedWith( - self.window, "error_dialog", title=title, message=message - ) + assert window._impl.interface == window + assert_action_performed(window, "create Window") - def test_info_dialog(self): - title = "info_dialog_test" - message = "sample_text" + assert window.id == "my-window" + assert window.title == "My Window" + assert window.position == (10, 20) + assert window.size == (200, 300) + assert not window.resizable + assert not window.closable + assert not window.minimizable + assert len(window.toolbar) == 0 + assert window.on_close._raw == on_close_handler - self.window.info_dialog(title, message) - self.assertActionPerformedWith( - self.window, "info_dialog", title=title, message=message - ) +def test_set_app(window, app): + """A window can be assigned to an app""" + assert window.app is None - def test_stack_trace_dialog(self): - title = "stack_trace_dialog_test" - message = "sample_text" - content = "sample_content" - retry = True - - self.window.stack_trace_dialog(title, message, content, retry) - - self.assertActionPerformedWith( - self.window, - "stack_trace_dialog", - title=title, - message=message, - content=content, - retry=retry, - ) + window.app = app - def test_save_file_dialog_with_initial_directory(self): - title = "save_file_dialog_test" - suggested_filename = "/path/to/initial_filename.doc" - file_types = ["test"] + assert window.app == app - self.window.save_file_dialog(title, suggested_filename, file_types) + app2 = toga.App("Test App 2", "org.beeware.toga.window2") + with pytest.raises(ValueError, match=r"Window is already associated with an App"): + window.app = app2 - self.assertActionPerformedWith( - self.window, - "save_file_dialog", - title=title, - filename="initial_filename.doc", - initial_directory=Path("/path/to"), - file_types=file_types, - ) - def test_save_file_dialog_with_self_as_initial_directory(self): - title = "save_file_dialog_test" - suggested_filename = "./initial_filename.doc" - file_types = ["test"] +def test_set_app_with_content(window, app): + """If a window has content, the content is assigned to the app""" + content = toga.Box() + window.content = content - self.window.save_file_dialog(title, suggested_filename, file_types) + assert window.app is None + assert content.app is None - self.assertActionPerformedWith( - self.window, - "save_file_dialog", - title=title, - filename="initial_filename.doc", - initial_directory=None, - file_types=file_types, - ) + window.app = app - def test_open_file_dialog(self): - title = "title_test" - initial_directory = "/path/to/initial_directory" - file_types = ["test"] - multiselect = True - - self.window.open_file_dialog(title, initial_directory, file_types, multiselect) - - self.assertActionPerformedWith( - self.window, - "open_file_dialog", - title=title, - initial_directory=Path(initial_directory), - file_types=file_types, - multiselect=multiselect, - ) + assert window.app == app + assert content.app == app - def test_select_folder_dialog(self): - title = "" - initial_directory = "/path/to/initial_directory" - multiselect = True - self.window.select_folder_dialog(title, initial_directory, multiselect) +@pytest.mark.parametrize( + "value, expected", + [ + ("New Text", "New Text"), + ("", "Toga"), + (None, "Toga"), + (12345, "12345"), + ("Contains\nnewline", "Contains"), + ], +) +def test_title(window, value, expected): + """The title of the window can be changed""" + window.title = value + assert window.title == expected - self.assertActionPerformedWith( - self.window, - "select_folder_dialog", - title=title, - initial_directory=Path(initial_directory), - multiselect=multiselect, - ) - def test_window_set_content_once(self): - content = Mock() - self.window.content = content +def test_change_content(window, app): + """The content of a window can be changed""" + window.app = app + assert window.content is None + assert window.app == app + + # Set the content of the window + content1 = toga.Box() + window.content = content1 + + # The content has been assigned and refreshed + assert content1.app == app + assert content1.window == window + assert_action_performed_with(window, "set content", widget=content1._impl) + assert_action_performed(content1, "refresh") + + # Set the content of the window to something new + content2 = toga.Box() + window.content = content2 + + # The content has been assigned and refreshed + assert content2.app == app + assert content2.window == window + assert_action_performed_with(window, "set content", widget=content2._impl) + assert_action_performed(content2, "refresh") + + # The original content has been removed + assert content1.window is None + + +def test_set_position(window): + """The position of the window can be set.""" + window.position = (123, 456) + + assert window.position == (123, 456) + + +def test_set_size(window): + """The size of the window can be set.""" + window.size = (123, 456) + + assert window.size == (123, 456) + + +def test_set_size_with_content(window): + """The size of the window can be set.""" + content = toga.Box() + window.content = content + + window.size = (123, 456) + + assert window.size == (123, 456) + assert_action_performed(content, "refresh") + + +def test_show_hide(window, app): + """The window can be shown and hidden.""" + assert window.app is None + + window.show() + + # The window has been assigned to the app, and is visible + assert window.app == app + assert window in app.windows + assert_action_performed(window, "show") + assert window.visible + + # Hide with an explicit call + window.hide() + + # Window is still assigned to the app, but is not visible + assert window.app == app + assert window in app.windows + assert_action_performed(window, "hide") + assert not window.visible + - self.assertEqual(content.window, self.window) +def test_hide_show(window, app): + """The window can be hidden then shown.""" + assert window.app is None - self.assertActionPerformed(self.window, "set content") + window.hide() - def test_window_set_content_twice(self): - content1, content2 = Mock(), Mock() - self.window.content = content1 - self.window.content = content2 + # The window has been assigned to the app, and is not visible + assert window.app == app + assert window in app.windows + assert_action_performed(window, "hide") + assert not window.visible - self.assertEqual(content1.window, None) - self.assertEqual(content2.window, self.window) + # Show with an explicit call + window.show() + + # Window is still assigned to the app, but is not visible + assert window.app == app + assert window in app.windows + assert_action_performed(window, "show") + assert window.visible + + +def test_visibility(window, app): + """The window can be shown and hidden using the visible property.""" + assert window.app is None + + window.visible = True + + # The window has been assigned to the app, and is visible + assert window.app == app + assert window in app.windows + assert_action_performed(window, "show") + assert window.visible + + # Hide with an explicit call + window.visible = False + + # Window is still assigned to the app, but is not visible + assert window.app == app + assert window in app.windows + assert_action_performed(window, "hide") + assert not window.visible + + +def test_full_screen(window, app): + """A window can be set full screen.""" + assert not window.full_screen + + window.full_screen = True + assert window.full_screen + assert_action_performed_with(window, "set full screen", full_screen=True) + + window.full_screen = False + assert not window.full_screen + assert_action_performed_with(window, "set full screen", full_screen=False) + + +def test_close_direct(window, app): + """A window can be closed directly""" + on_close_handler = Mock(return_value=True) + window.on_close = on_close_handler + + window.show() + assert window.app == app + assert window in app.windows + + # Close the window directly + window.close() + + # Window has been closed, but the close handler has *not* been invoked. + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + on_close_handler.assert_not_called() + + +def test_close_no_handler(window, app): + """A window without a close handler can be closed""" + window.show() + assert window.app == app + assert window in app.windows + + # Close the window + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + + +def test_close_sucessful_handler(window, app): + """A window with a successful close handler can be closed""" + on_close_handler = Mock(return_value=True) + window.on_close = on_close_handler + + window.show() + assert window.app == app + assert window in app.windows + + # Close the window + window._impl.simulate_close() + + # Window has been closed, and is no longer in the app's list of windows. + assert window.app == app + assert window not in app.windows + assert_action_performed(window, "close") + on_close_handler.assert_called_once_with(window) + + +def test_close_rejected_handler(window, app): + """A window can have a close handler that rejects closing""" + on_close_handler = Mock(return_value=False) + window.on_close = on_close_handler + + window.show() + assert window.app == app + assert window in app.windows + + # Close the window + window._impl.simulate_close() + + # Window has *not* been closed + assert window.app == app + assert window in app.windows + assert_action_not_performed(window, "close") + on_close_handler.assert_called_once_with(window) + + +def test_info_dialog(window, app): + """An info dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.info_dialog("Title", "Body", on_result=on_result_handler) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + async def run_dialog(dialog): + dialog._impl.simulate_result(None) + assert await dialog is None + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show info dialog", + title="Title", + message="Body", + ) + on_result_handler.assert_called_once_with(window, None) + + +def test_question_dialog(window, app): + """A question dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.question_dialog("Title", "Body", on_result=on_result_handler) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + async def run_dialog(dialog): + dialog._impl.simulate_result(True) + assert await dialog is True + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show question dialog", + title="Title", + message="Body", + ) + on_result_handler.assert_called_once_with(window, True) + + +def test_confirm_dialog(window, app): + """A confirm dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.confirm_dialog("Title", "Body", on_result=on_result_handler) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + async def run_dialog(dialog): + dialog._impl.simulate_result(True) + assert await dialog is True + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show confirm dialog", + title="Title", + message="Body", + ) + on_result_handler.assert_called_once_with(window, True) + + +def test_error_dialog(window, app): + """An error dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.error_dialog("Title", "Body", on_result=on_result_handler) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + async def run_dialog(dialog): + dialog._impl.simulate_result(None) + assert await dialog is None + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show error dialog", + title="Title", + message="Body", + ) + on_result_handler.assert_called_once_with(window, None) + + +def test_stack_trace_dialog(window, app): + """A stack trace dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.stack_trace_dialog( + "Title", + "Body", + "The error", + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + async def run_dialog(dialog): + dialog._impl.simulate_result(None) + assert await dialog is None + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show stack trace dialog", + title="Title", + message="Body", + content="The error", + retry=False, + ) + on_result_handler.assert_called_once_with(window, None) + + +def test_save_file_dialog(window, app): + """A save file dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.save_file_dialog( + "Title", + Path("/path/to/initial_file.txt"), + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + saved_file = Path("/saved/path/filename.txt") + + async def run_dialog(dialog): + dialog._impl.simulate_result(saved_file) + assert await dialog is saved_file + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show save file dialog", + title="Title", + filename="initial_file.txt", + initial_directory=Path("/path/to"), + file_types=None, + ) + on_result_handler.assert_called_once_with(window, saved_file) + + +def test_save_file_dialog_default_directory(window, app): + """If no path is provided, a save file dialog will use the default directory""" + window.app = app + on_result_handler = Mock() + dialog = window.save_file_dialog( + "Title", + "initial_file.txt", + file_types=[".txt", ".pdf"], + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + saved_file = Path("/saved/path/filename.txt") + + async def run_dialog(dialog): + dialog._impl.simulate_result(saved_file) + assert await dialog is saved_file + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show save file dialog", + title="Title", + filename="initial_file.txt", + initial_directory=None, + file_types=[".txt", ".pdf"], + ) + on_result_handler.assert_called_once_with(window, saved_file) + + +def test_open_file_dialog(window, app): + """A open file dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.open_file_dialog( + "Title", + "/path/to/folder", + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + opened_file = Path("/opened/path/filename.txt") + + async def run_dialog(dialog): + dialog._impl.simulate_result(opened_file) + assert await dialog is opened_file + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show open file dialog", + title="Title", + initial_directory=Path("/path/to/folder"), + file_types=None, + multiple_select=False, + ) + on_result_handler.assert_called_once_with(window, opened_file) + + +def test_open_file_dialog_default_directory(window, app): + """If no path is provided, a open file dialog will use the default directory""" + window.app = app + on_result_handler = Mock() + dialog = window.open_file_dialog( + "Title", + file_types=[".txt", ".pdf"], + multiple_select=True, + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + opened_files = [ + Path("/opened/path/filename.txt"), + Path("/other/path/filename2.txt"), + ] + + async def run_dialog(dialog): + dialog._impl.simulate_result(opened_files) + assert await dialog is opened_files + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show open file dialog", + title="Title", + initial_directory=None, + file_types=[".txt", ".pdf"], + multiple_select=True, + ) + on_result_handler.assert_called_once_with(window, opened_files) + + +def test_select_folder_dialog(window, app): + """A select folder dialog can be shown""" + window.app = app + on_result_handler = Mock() + dialog = window.select_folder_dialog( + "Title", + Path("/path/to/folder"), + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + opened_file = Path("/opened/path/filename.txt") + + async def run_dialog(dialog): + dialog._impl.simulate_result(opened_file) + assert await dialog is opened_file + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show select folder dialog", + title="Title", + initial_directory=Path("/path/to/folder"), + multiple_select=False, + ) + on_result_handler.assert_called_once_with(window, opened_file) + + +def test_select_folder_dialog_default_directory(window, app): + """If no path is provided, a select folder dialog will use the default directory""" + window.app = app + on_result_handler = Mock() + dialog = window.select_folder_dialog( + "Title", + multiple_select=True, + on_result=on_result_handler, + ) + + assert dialog.window == window + assert dialog.app == app + + with pytest.raises( + RuntimeError, + match=r"Can't check dialog result directly; use await or an on_result handler", + ): + # Perform a synchronous comparison; this will raise a runtime error + dialog == 1 + + opened_files = [ + Path("/opened/path/filename.txt"), + Path("/other/path/filename2.txt"), + ] + + async def run_dialog(dialog): + dialog._impl.simulate_result(opened_files) + assert await dialog is opened_files + + app._impl.loop.run_until_complete(run_dialog(dialog)) + + assert_action_performed_with( + window, + "show select folder dialog", + title="Title", + initial_directory=None, + multiple_select=True, + ) + on_result_handler.assert_called_once_with(window, opened_files) + + +def test_deprecated_names_open_file_dialog(window, app): + """Deprecated names still work on open file dialogs.""" + window.app = app + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"open_file_dialog\(multiselect\) has been renamed multiple_select", + ): + dialog = window.open_file_dialog( + "Title", + "/path/to/folder", + multiselect=True, + on_result=on_result_handler, + ) + + opened_files = [Path("/opened/path/filename.txt")] + + dialog._impl.simulate_result(opened_files) + + assert_action_performed_with( + window, + "show open file dialog", + title="Title", + initial_directory=Path("/path/to/folder"), + file_types=None, + multiple_select=True, + ) + on_result_handler.assert_called_once_with(window, opened_files) + + +def test_deprecated_names_select_folder_dialog(window, app): + """Deprecated names still work on open file dialogs.""" + window.app = app + on_result_handler = Mock() + with pytest.warns( + DeprecationWarning, + match=r"select_folder_dialog\(multiselect\) has been renamed multiple_select", + ): + dialog = window.select_folder_dialog( + "Title", + "/path/to/folder", + multiselect=True, + on_result=on_result_handler, + ) - self.assertActionPerformed(self.window, "set content") + opened_files = [Path("/opened/path")] + + dialog._impl.simulate_result(opened_files) + + assert_action_performed_with( + window, + "show select folder dialog", + title="Title", + initial_directory=Path("/path/to/folder"), + multiple_select=True, + ) + on_result_handler.assert_called_once_with(window, opened_files) + + +def test_deprecated_names_resizeable(): + """Deprecated spelling of resizable still works""" + with pytest.warns( + DeprecationWarning, + match=r"Window.resizeable has been renamed Window.resizable", + ): + window = toga.Window(title="Deprecated", resizeable=True) + + with pytest.warns( + DeprecationWarning, + match=r"Window.resizeable has been renamed Window.resizable", + ): + assert window.resizeable + + +def test_deprecated_names_closeable(): + """Deprecated spelling of closable still works""" + with pytest.warns( + DeprecationWarning, + match=r"Window.closeable has been renamed Window.closable", + ): + window = toga.Window(title="Deprecated", closeable=True) + + with pytest.warns( + DeprecationWarning, + match=r"Window.closeable has been renamed Window.closable", + ): + assert window.closeable diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index ab3b036f3f..dc9b7d000e 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -48,6 +48,9 @@ Alternatively, you can subclass App and implement the startup method app = MyApp('First App', 'org.beeware.helloworld') app.main_loop() +All App instances must have a main window. This main window must exist at the conclusion +of the ``startup()`` method. + Reference --------- diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 56ee42a770..41968d8568 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -7,13 +7,13 @@ API Reference Core application components --------------------------- -=============================================== ======================== +=============================================== =================================================== Component Description -=============================================== ======================== +=============================================== =================================================== :doc:`Application ` The application itself - :doc:`Window ` Window object - :doc:`MainWindow ` Main Window -=============================================== ======================== + :doc:`Window ` An operating system-managed container of widgets. + :doc:`MainWindow ` The main window of the application. +=============================================== =================================================== General widgets --------------- diff --git a/docs/reference/api/mainwindow.rst b/docs/reference/api/mainwindow.rst index caedcd55a4..1bd8ddbede 100644 --- a/docs/reference/api/mainwindow.rst +++ b/docs/reference/api/mainwindow.rst @@ -1,6 +1,12 @@ MainWindow ========== +The main window of the application. + +.. figure:: /reference/images/MainWindow.png + :align: center + :width: 300px + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,20 +14,25 @@ MainWindow :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(MainWindow|Component))'} -A window for displaying components to the user - Usage ----- -A MainWindow is used for desktop applications, where components need to be shown within a window-manager. Windows can be configured on -instantiation and support displaying multiple widgets, toolbars and resizing. +The Main Window of an application is a normal :class:`toga.Window`, with one exception - +when the Main Window is closed, the application exits. .. code-block:: python import toga - window = toga.MainWindow('id-window', title='This is a window!') - window.show() + main_window = toga.MainWindow(title='My Application') + + self.toga.App.main_window = main_window + main_window.show() + +As the main window is closely bound to the App, a main window *cannot* define an +``on_close`` handler. Instead, if you want to prevent the main window from exiting, you +should use an ``on_exit`` handler on the :class:`toga.App` that the main window is +associated with. Reference --------- diff --git a/docs/reference/api/window.rst b/docs/reference/api/window.rst index 0b02584aaa..6b4251eb94 100644 --- a/docs/reference/api/window.rst +++ b/docs/reference/api/window.rst @@ -1,6 +1,12 @@ Window ====== +An operating system-managed container of widgets. + +.. figure:: /reference/images/Window.png + :align: center + :width: 300px + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,38 +14,45 @@ Window :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(Window|Component))'} -A window for displaying components to the user - Usage ----- -The window class is used for desktop applications, where components need to be shown within a window-manager. Windows can be configured on -instantiation and support displaying multiple widgets, toolbars and resizing. +A window is the top-level container that the operating system uses to contain widgets. +The window has content, which will usually be a container widget of some kind. A window +may also have other decorations, such as a title bar or toolbar. + +By default, a window is not visible. When the window is shown, it will be associated +with the currently active application. The content of the window can be changed by +re-assigning the content of the window to a new widget. .. code-block:: python import toga + window = toga.Window() + window.content = toga.Box(children=[...]) + window.show() - class ExampleWindow(toga.App): - def startup(self): - self.label = toga.Label('Hello World') - outer_box = toga.Box( - children=[self.label] - ) - self.window = toga.Window() - self.window.content = outer_box - - self.window.show() + # Change the window's content to something new + window.content = toga.Box(children=[...]) +The operating system may provide controls that allow the user to resize, reposition, +minimize or maximize the the window. However, the availability of these controls is +entirely operating system dependent. - def main(): - return ExampleWindow('Window', 'org.beeware.window') +If the operating system provides a way to close the window, Toga will call the +``on_close`` handler. This handler must return a Boolean confirming whether the close is +permitted. This can be used to implement protections against closing a window with +unsaved changes. +Notes +----- - if __name__ == '__main__': - app = main() - app.main_loop() +* A mobile application can only have a single window (the :class:`~toga.MainWindow`), + and that window cannot be moved, resized, hidden, or made full screen. Toga will raise + an exception if you attempt to create a secondary window on a mobile platform. If you + try to modify the size, position, or visibility of the main window, the request will + be ignored. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 77228f0e74..f37e1df8a3 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -1,7 +1,7 @@ Component,Type,Component,Description,macOS,GTK,Windows,iOS,Android,Web Application,Core Component,:class:`~toga.App`,The application itself,|b|,|b|,|b|,|b|,|b|,|b| -Window,Core Component,:class:`~toga.Window`,Window object,|b|,|b|,|b|,|b|,|b|,|b| -MainWindow,Core Component,:class:`~toga.MainWindow`,Main window of the application,|b|,|b|,|b|,|b|,|b|,|b| +Window,Core Component,:class:`~toga.Window`,An operating system-managed container of widgets.,|b|,|b|,|b|,|b|,|b|,|b| +MainWindow,Core Component,:class:`~toga.MainWindow`,The main window of the application.,|b|,|b|,|b|,|b|,|b|,|b| ActivityIndicator,General Widget,:class:`~toga.ActivityIndicator`,A spinning activity animation,|y|,|y|,,,,|b| Button,General Widget,:class:`~toga.Button`,Basic clickable Button,|y|,|y|,|y|,|y|,|y|,|b| Canvas,General Widget,:class:`~toga.Canvas`,Area you can draw on,|b|,|b|,|b|,|b|,, diff --git a/docs/reference/images/MainWindow.png b/docs/reference/images/MainWindow.png new file mode 100644 index 0000000000..b8d72e6fd0 Binary files /dev/null and b/docs/reference/images/MainWindow.png differ diff --git a/docs/reference/images/Window.png b/docs/reference/images/Window.png new file mode 100644 index 0000000000..2efa3ce470 Binary files /dev/null and b/docs/reference/images/Window.png differ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 05a1116ab9..27372e7b9e 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -12,7 +12,6 @@ Bugfixes Cairo cancelled clickable -closable codepoint coroutine CSS @@ -33,7 +32,6 @@ KDE linters macOS Manjaro -minimizable Monetization namespace OpenStreetMap diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 9a5fd67fe4..50aef337b6 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -1,27 +1,27 @@ -from .utils import LoggedObject, not_required, not_required_on +import asyncio +import weakref + +from .utils import LoggedObject, not_required_on from .window import Window class MainWindow(Window): - @not_required - def toga_on_close(self): - self.action("handle MainWindow on_close") - - -@not_required -class EventLoop: - def __init__(self, app): - self.app = app - - def call_soon_threadsafe(self, handler, *args): - self.app._action("loop:call_soon_threadsafe", handler=handler, args=args) + pass class App(LoggedObject): def __init__(self, interface): super().__init__() self.interface = interface - self.loop = EventLoop(self) + self.loop = asyncio.new_event_loop() + + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) def create(self): self._action("create") diff --git a/dummy/src/toga_dummy/dialogs.py b/dummy/src/toga_dummy/dialogs.py index 40323cffb1..9bd760065b 100644 --- a/dummy/src/toga_dummy/dialogs.py +++ b/dummy/src/toga_dummy/dialogs.py @@ -1,53 +1,76 @@ +from .utils import not_required + + +@not_required # Testbed coverage is complete. class BaseDialog: - def __init__(self, interface): + def __init__(self, interface, on_result): self.interface = interface self.interface._impl = self + self.on_result = on_result + + def simulate_result(self, result): + self.on_result(None, result) + self.interface.future.set_result(result) +@not_required # Testbed coverage is complete. class InfoDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "info_dialog", title=title, message=message, on_result=on_result + "show info dialog", + title=title, + message=message, ) +@not_required # Testbed coverage is complete. class QuestionDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "question_dialog", title=title, message=message, on_result=on_result + "show question dialog", + title=title, + message=message, ) +@not_required # Testbed coverage is complete. class ConfirmDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "confirm_dialog", title=title, message=message, on_result=on_result + "show confirm dialog", + title=title, + message=message, ) +@not_required # Testbed coverage is complete. class ErrorDialog(BaseDialog): def __init__(self, interface, title, message, on_result=None): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "error_dialog", title=title, message=message, on_result=on_result + "show error dialog", + title=title, + message=message, ) +@not_required # Testbed coverage is complete. class StackTraceDialog(BaseDialog): - def __init__(self, interface, title, message, on_result=None, **kwargs): - super().__init__(interface) + def __init__(self, interface, title, message, content, retry, on_result=None): + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "stack_trace_dialog", + "show stack trace dialog", title=title, message=message, - on_result=on_result, - **kwargs + content=content, + retry=retry, ) +@not_required # Testbed coverage is complete. class SaveFileDialog(BaseDialog): def __init__( self, @@ -58,17 +81,17 @@ def __init__( file_types=None, on_result=None, ): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "save_file_dialog", + "show save file dialog", title=title, filename=filename, initial_directory=initial_directory, file_types=file_types, - on_result=on_result, ) +@not_required # Testbed coverage is complete. class OpenFileDialog(BaseDialog): def __init__( self, @@ -76,34 +99,33 @@ def __init__( title, initial_directory, file_types, - multiselect, + multiple_select, on_result=None, ): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "open_file_dialog", + "show open file dialog", title=title, initial_directory=initial_directory, file_types=file_types, - multiselect=multiselect, - on_result=on_result, + multiple_select=multiple_select, ) +@not_required # Testbed coverage is complete. class SelectFolderDialog(BaseDialog): def __init__( self, interface, title, initial_directory, - multiselect, + multiple_select, on_result=None, ): - super().__init__(interface) + super().__init__(interface, on_result=on_result) interface.window._impl._action( - "select_folder_dialog", + "show select folder dialog", title=title, initial_directory=initial_directory, - multiselect=multiselect, - on_result=on_result, + multiple_select=multiple_select, ) diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index ed5d263c3e..6762b84e3a 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -1,7 +1,9 @@ -from .utils import LoggedObject, not_required, not_required_on +import weakref +from .utils import LoggedObject, not_required -@not_required + +@not_required # not part of the formal API spec class Container: def __init__(self, content=None): self.baseline_dpi = 96 @@ -37,9 +39,11 @@ def refreshed(self): self.content.refresh() +@not_required # Testbed coverage is complete class Window(LoggedObject): def __init__(self, interface, title, position, size): super().__init__() + self._action("create Window") self.interface = interface self.container = Container() @@ -47,11 +51,17 @@ def __init__(self, interface, title, position, size): self.set_position(position) self.set_size(size) + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create_toolbar(self): self._action("create toolbar") - # Some platforms inherit this method from a base class. - @not_required_on("android", "winforms") def set_content(self, widget): self.container.content = widget self._action("set content", widget=widget) @@ -91,11 +101,10 @@ def get_visible(self): def close(self): self._action("close") + self._set_value("visible", False) - @not_required_on("mobile") def set_full_screen(self, is_full_screen): - self._set_value("is_full_screen", is_full_screen) + self._action("set full screen", full_screen=is_full_screen) - @not_required - def toga_on_close(self): - self._action("handle Window on_close") + def simulate_close(self): + self.interface.on_close(None) diff --git a/examples/window/window/app.py b/examples/window/window/app.py index 2e770f561d..ca7732ba2a 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -23,12 +23,15 @@ def do_small(self, widget, **kwargs): def do_large(self, widget, **kwargs): self.main_window.size = (1500, 1000) - def do_full_screen(self, widget, **kwargs): + def do_app_full_screen(self, widget, **kwargs): if self.is_full_screen: self.exit_full_screen() else: self.set_full_screen(self.main_window) + def do_window_full_screen(self, widget, **kwargs): + self.main_window.full_screen = not self.main_window.full_screen + def do_title(self, widget, **kwargs): self.main_window.title = f"Time is {datetime.now()}" @@ -37,25 +40,23 @@ def do_new_windows(self, widget, **kwargs): "Non-resizable Window", position=(200, 200), size=(300, 300), - resizeable=False, + resizable=False, on_close=self.close_handler, ) non_resize_window.content = toga.Box( children=[toga.Label("This window is not resizable")] ) - self.app.windows += non_resize_window non_resize_window.show() non_close_window = toga.Window( "Non-closeable Window", position=(300, 300), size=(300, 300), - closeable=False, + closable=False, ) non_close_window.content = toga.Box( - children=[toga.Label("This window is not closeable")] + children=[toga.Label("This window is not closable")] ) - self.app.windows += non_close_window non_close_window.show() no_close_handler_window = toga.Window( @@ -66,7 +67,6 @@ def do_new_windows(self, widget, **kwargs): no_close_handler_window.content = toga.Box( children=[toga.Label("This window has no close handler")] ) - self.app.windows += no_close_handler_window no_close_handler_window.show() async def do_current_window_cycling(self, widget, **kwargs): @@ -143,8 +143,13 @@ def startup(self): btn_do_large = toga.Button( "Become large", on_press=self.do_large, style=btn_style ) - btn_do_full_screen = toga.Button( - "Become full screen", on_press=self.do_full_screen, style=btn_style + btn_do_app_full_screen = toga.Button( + "Make app full screen", on_press=self.do_app_full_screen, style=btn_style + ) + btn_do_window_full_screen = toga.Button( + "Make window full screen", + on_press=self.do_window_full_screen, + style=btn_style, ) btn_do_title = toga.Button( "Change title", on_press=self.do_title, style=btn_style @@ -175,7 +180,8 @@ def startup(self): btn_do_right, btn_do_small, btn_do_large, - btn_do_full_screen, + btn_do_app_full_screen, + btn_do_window_full_screen, btn_do_title, btn_do_new_windows, btn_do_current_window_cycling, diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 5f7dee07c6..956c3b67eb 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -3,6 +3,7 @@ import os.path import signal import sys +import weakref from urllib.parse import unquote, urlparse import gbulb @@ -55,13 +56,20 @@ class App: def __init__(self, interface): self.interface = interface - self.interface._impl = self gbulb.install(gtk=True) self.loop = asyncio.new_event_loop() self.create() + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create(self): # Stimulate the build of the app self.native = Gtk.Application( diff --git a/gtk/src/toga_gtk/container.py b/gtk/src/toga_gtk/container.py index 4072e7f098..9210eb1c8a 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -178,10 +178,7 @@ def do_size_allocate(self, allocation): # the Toga widget. Toga maintains a tree of children; all nodes # in that tree are direct children of the container. for widget in self.get_children(): - if not widget.get_visible(): - # print(" not visible {widget.interface}") - pass - else: + if widget.get_visible(): # Set the size of the child widget to the computed layout size. # print(f" allocate child {widget.interface}: {widget.interface.layout}") widget_allocation = Gdk.Rectangle() diff --git a/gtk/src/toga_gtk/dialogs.py b/gtk/src/toga_gtk/dialogs.py index 74e3a81350..7b0395b5ee 100644 --- a/gtk/src/toga_gtk/dialogs.py +++ b/gtk/src/toga_gtk/dialogs.py @@ -7,7 +7,7 @@ class BaseDialog(ABC): def __init__(self, interface): self.interface = interface - self.interface.impl = self + self.interface._impl = self class MessageDialog(BaseDialog): @@ -15,14 +15,15 @@ def __init__( self, interface, title, - message, message_type, buttons, success_result=None, on_result=None, + **kwargs, ): super().__init__(interface=interface) self.on_result = on_result + self.success_result = success_result self.native = Gtk.MessageDialog( transient_for=interface.window._impl.native, @@ -31,22 +32,25 @@ def __init__( buttons=buttons, text=title, ) - self.native.format_secondary_text(message) + self.build_dialog(**kwargs) - return_value = self.native.run() - self.native.destroy() + self.native.connect("response", self.gtk_response) + self.native.show() - if success_result: - result = return_value == success_result + def build_dialog(self, message): + self.native.format_secondary_text(message) + + def gtk_response(self, dialog, response): + if self.success_result: + result = response == self.success_result else: result = None - # def completion_handler(self, return_value: bool) -> None: - if self.on_result: - self.on_result(self, result) - + self.on_result(self, result) self.interface.future.set_result(result) + self.native.destroy() + class InfoDialog(MessageDialog): def __init__(self, interface, title, message, on_result=None): @@ -98,10 +102,59 @@ def __init__(self, interface, title, message, on_result=None): ) -class StackTraceDialog(BaseDialog): - def __init__(self, interface, title, message, on_result=None, **kwargs): - super().__init__(interface=interface) - interface.window.factory.not_implemented("Window.stack_trace_dialog()") +class StackTraceDialog(MessageDialog): + def __init__(self, interface, title, on_result=None, **kwargs): + super().__init__( + interface=interface, + title=title, + message_type=Gtk.MessageType.ERROR, + buttons=( + Gtk.ButtonsType.CANCEL if kwargs.get("retry") else Gtk.ButtonsType.OK + ), + success_result=Gtk.ResponseType.OK if kwargs.get("retry") else None, + on_result=on_result, + **kwargs, + ) + + def build_dialog(self, message, content, retry): + container = self.native.get_message_area() + + self.native.format_secondary_text(message) + + # Create a scrolling readonly text area, in monospace font, to contain the stack trace. + buffer = Gtk.TextBuffer() + buffer.set_text(content) + + trace = Gtk.TextView() + trace.set_buffer(buffer) + trace.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + trace.set_property("editable", False) + trace.set_property("cursor-visible", False) + + trace.get_style_context().add_class("toga") + trace.get_style_context().add_class("stacktrace") + trace.get_style_context().add_class("dialog") + + style_provider = Gtk.CssProvider() + style_provider.load_from_data(b".toga.stacktrace {font-family: monospace;}") + + trace.get_style_context().add_provider( + style_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_size_request(500, 200) + scroll.add(trace) + + container.pack_end(scroll, False, False, 0) + + container.show_all() + + # If this is a retry dialog, add a retry button (which maps to OK). + if retry: + self.native.add_button("Retry", Gtk.ResponseType.OK) class FileDialog(BaseDialog): @@ -112,7 +165,7 @@ def __init__( filename, initial_directory, file_types, - multiselect, + multiple_select, action, ok_icon, on_result=None, @@ -141,26 +194,35 @@ def __init__( filter_filetype.add_pattern("*." + file_type) self.native.add_filter(filter_filetype) - if multiselect: + self.multiple_select = multiple_select + if self.multiple_select: self.native.set_select_multiple(True) - response = self.native.run() + self.native.connect("response", self.gtk_response) + self.native.show() + + # Provided as a stub that can be mocked in test conditions + def selected_path(self): + return self.native.get_filename() + # Provided as a stub that can be mocked in test conditions + def selected_paths(self): + return self.native.get_filenames() + + def gtk_response(self, dialog, response): if response == Gtk.ResponseType.OK: - if multiselect: - result = [Path(filename) for filename in self.native.get_filenames()] + if self.multiple_select: + result = [Path(filename) for filename in self.selected_paths()] else: - result = Path(self.native.get_filename()) + result = Path(self.selected_path()) else: result = None - self.native.destroy() - - if self.on_result: - self.on_result(self, result) - + self.on_result(self, result) self.interface.future.set_result(result) + self.native.destroy() + class SaveFileDialog(FileDialog): def __init__( @@ -178,7 +240,7 @@ def __init__( filename=filename, initial_directory=initial_directory, file_types=file_types, - multiselect=False, + multiple_select=False, action=Gtk.FileChooserAction.SAVE, ok_icon=Gtk.STOCK_SAVE, on_result=on_result, @@ -192,7 +254,7 @@ def __init__( title, initial_directory, file_types, - multiselect, + multiple_select, on_result=None, ): super().__init__( @@ -201,7 +263,7 @@ def __init__( filename=None, initial_directory=initial_directory, file_types=file_types, - multiselect=multiselect, + multiple_select=multiple_select, action=Gtk.FileChooserAction.OPEN, ok_icon=Gtk.STOCK_OPEN, on_result=on_result, @@ -214,7 +276,7 @@ def __init__( interface, title, initial_directory, - multiselect, + multiple_select, on_result=None, ): super().__init__( @@ -223,7 +285,7 @@ def __init__( filename=None, initial_directory=initial_directory, file_types=None, - multiselect=multiselect, + multiple_select=multiple_select, action=Gtk.FileChooserAction.SELECT_FOLDER, ok_icon=Gtk.STOCK_OPEN, on_result=on_result, diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index a9404932b9..dad77145f0 100644 --- a/gtk/src/toga_gtk/widgets/base.py +++ b/gtk/src/toga_gtk/widgets/base.py @@ -152,6 +152,8 @@ def set_alignment(self, alignment): def set_hidden(self, hidden): self.native.set_visible(not hidden) + if self.container: + self.container.make_dirty() def set_color(self, color): self.apply_css("color", get_color_css(color)) diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 5771ebdd73..2898b4cd6d 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -1,3 +1,5 @@ +import weakref + from toga.command import GROUP_BREAK, SECTION_BREAK from toga.handlers import wrapped_handler @@ -10,7 +12,6 @@ class Window: def __init__(self, interface, title, position, size): self.interface = interface - self.interface._impl = self self._is_closing = False @@ -26,12 +27,12 @@ def __init__(self, interface, title, position, size): self.set_title(title) self.set_position(position) - # Set the window deletable/closeable. - self.native.set_deletable(self.interface.closeable) + # Set the window deletable/closable. + self.native.set_deletable(self.interface.closable) # Added to set Window Resizable - removes Window Maximize button from # Window Decorator when resizable == False - self.native.set_resizable(self.interface.resizeable) + self.native.set_resizable(self.interface.resizable) self.toolbar_native = None self.toolbar_items = None @@ -47,6 +48,14 @@ def __init__(self, interface, title, position, size): self.layout.pack_end(self.container, expand=True, fill=True, padding=0) self.native.add(self.layout) + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def get_title(self): return self.native.get_title() @@ -103,10 +112,8 @@ def get_visible(self): def gtk_delete_event(self, widget, data): if self._is_closing: should_close = True - elif self.interface.on_close._raw: - should_close = self.interface.on_close(self.interface.app) else: - should_close = True + should_close = self.interface.on_close(self.interface.app) # Return value of the GTK on_close handler indicates # whether the event has been fully handled. Returning diff --git a/gtk/tests/widgets/test_window.py b/gtk/tests/widgets/test_window.py deleted file mode 100644 index 0d67dd65c7..0000000000 --- a/gtk/tests/widgets/test_window.py +++ /dev/null @@ -1,59 +0,0 @@ -import unittest - -try: - import gi - - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk -except ImportError: - import sys - - # If we're on Linux, Gtk *should* be available. If it isn't, make - # Gtk an object... but in such a way that every test will fail, - # because the object isn't actually the Gtk interface. - if sys.platform == "linux": - Gtk = object() - else: - Gtk = None - -import toga - - -def handle_events(): - while Gtk.events_pending(): - Gtk.main_iteration_do(blocking=False) - - -@unittest.skipIf( - Gtk is None, "Can't run GTK implementation tests on a non-Linux platform" -) -class TogaAppForWindowDemo(toga.App): - pass - - -class TestGtkWindow(unittest.TestCase): - def setUp(self): - self.box1 = toga.Box() - self.box2 = toga.Box() - self.app = TogaAppForWindowDemo("Test", "org.beeware.toga-gtk-tests") - self.app.main_window = toga.MainWindow("test window") - self.window = self.app.main_window - - def test_set_content_visibility_effects(self): - # Window is not showing, boxes cannot be drawn - self.assertEqual(self.window._impl.get_visible(), False) - self.assertEqual(self.box1._impl.native.is_drawable(), False) - self.assertEqual(self.box2._impl.native.is_drawable(), False) - - self.window.content = self.box1 - self.assertEqual(self.window.content._impl.native.is_drawable(), False) - - self.window.content = self.box2 - self.assertEqual(self.window.content._impl.native.is_drawable(), False) - - self.window.show() - self.assertEqual(self.window._impl.get_visible(), True) - self.assertEqual(self.window.content._impl.native.is_drawable(), True) - - self.window.content = self.box1 - self.assertEqual(self.window._impl.get_visible(), True) diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py new file mode 100644 index 0000000000..44fc078214 --- /dev/null +++ b/gtk/tests_backend/window.py @@ -0,0 +1,238 @@ +import asyncio +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from toga_gtk.libs import Gdk, Gtk + +from .probe import BaseProbe + + +class WindowProbe(BaseProbe): + # GTK defers a lot of window behavior to the window manager, which means some features + # either don't exist, or we can't guarantee they behave the way Toga would like. + # 1. No way to create a window without a minimize button + supports_minimize_control = False + # 2. Window manager may not honor changes in position while the window isn't visible + supports_move_while_hidden = False + # 3. Deiconify (i.e., unminimize) isn't guaranteed to actually unminimize the window + supports_unminimize = False + + def __init__(self, app, window): + super().__init__() + self.app = app + self.window = window + self.impl = window._impl + self.native = window._impl.native + assert isinstance(self.native, Gtk.Window) + + async def wait_for_window(self, message, minimize=False, full_screen=False): + await self.redraw(message, delay=0.5 if full_screen or minimize else 0.1) + + def close(self): + if self.is_closable: + self.native.close() + + @property + def content_size(self): + content_allocation = self.impl.container.get_allocation() + return (content_allocation.width, content_allocation.height) + + @property + def is_full_screen(self): + return bool(self.native.get_window().get_state() & Gdk.WindowState.FULLSCREEN) + + @property + def is_resizable(self): + return self.native.get_resizable() + + @property + def is_closable(self): + return self.native.get_deletable() + + @property + def is_minimizable(self): + pytest.xfail("GTK doesn't support disabling minimization") + + @property + def is_minimized(self): + return bool(self.native.get_window().get_state() & Gdk.WindowState.ICONIFIED) + + def minimize(self): + self.native.iconify() + + def unminimize(self): + self.native.deiconify() + + async def wait_for_dialog(self, dialog, message): + # It can take a moment for the dialog to disappear and the response to be + # handled. However, the delay can be variable; use the completion of the future + # as a proxy for "the dialog is done", with a safety catch that will prevent an + # indefinite wait. + await self.redraw(message, delay=0.1) + count = 0 + while dialog.native.get_visible() and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert not dialog.native.get_visible(), "Dialog didn't close" + + async def close_info_dialog(self, dialog): + dialog.native.response(Gtk.ResponseType.OK) + await self.wait_for_dialog(dialog, "Info dialog dismissed") + + async def close_question_dialog(self, dialog, result): + if result: + dialog.native.response(Gtk.ResponseType.YES) + else: + dialog.native.response(Gtk.ResponseType.NO) + + await self.wait_for_dialog( + dialog, + f"Question dialog ({'YES' if result else 'NO'}) dismissed", + ) + + async def close_confirm_dialog(self, dialog, result): + if result: + dialog.native.response(Gtk.ResponseType.OK) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + + await self.wait_for_dialog( + dialog, + f"Question dialog ({'OK' if result else 'CANCEL'}) dismissed", + ) + + async def close_error_dialog(self, dialog): + dialog.native.response(Gtk.ResponseType.CANCEL) + await self.wait_for_dialog(dialog, "Error dialog dismissed") + + async def close_stack_trace_dialog(self, dialog, result): + if result is None: + dialog.native.response(Gtk.ResponseType.OK) + await self.wait_for_dialog(dialog, "Stack trace dialog dismissed") + else: + if result: + dialog.native.response(Gtk.ResponseType.OK) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + + await self.wait_for_dialog( + dialog, + f"Stack trace dialog ({'RETRY' if result else 'QUIT'}) dismissed", + ) + + async def close_save_file_dialog(self, dialog, result): + assert isinstance(dialog.native, Gtk.FileChooserDialog) + + if result: + dialog.native.response(Gtk.ResponseType.OK) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + + await self.wait_for_dialog( + dialog, + f"Save file dialog ({'SAVE' if result else 'CANCEL'}) dismissed", + ) + + async def close_open_file_dialog(self, dialog, result, multiple_select): + assert isinstance(dialog.native, Gtk.FileChooserDialog) + + # GTK's file dialog shows folders first; but if a folder is selected when the + # "open" button is pressed, it opens that folder. To prevent this, if we're + # expecting this dialog to return a result, ensure a file is selected. We don't + # care which file it is, as we're mocking the return value of the dialog. + if result: + dialog.native.select_filename(__file__) + # We don't know how long it will take for the GUI to update, so iterate + # for a while until the change has been applied. + await self.redraw("Selected a single (arbitrary) file") + count = 0 + while dialog.native.get_filename() != __file__ and count < 10: + await asyncio.sleep(0.1) + count += 1 + assert ( + dialog.native.get_filename() == __file__ + ), "Dialog didn't select dummy file" + + if result is not None: + if multiple_select: + if result: + # Since we are mocking selected_path(), it's never actually invoked + # under test conditions. Call it just to confirm that it returns the + # type we think it does. + assert isinstance(dialog.selected_paths(), list) + + dialog.selected_paths = Mock( + return_value=[str(path) for path in result] + ) + else: + dialog.selected_path = Mock(return_value=str(result)) + + # If there's nothing selected, you can't press OK. + if result: + dialog.native.response(Gtk.ResponseType.OK) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + + await self.wait_for_dialog( + dialog, + ( + f"Open {'multiselect ' if multiple_select else ''}file dialog " + f"({'OPEN' if result else 'CANCEL'}) dismissed" + ), + ) + + async def close_select_folder_dialog(self, dialog, result, multiple_select): + assert isinstance(dialog.native, Gtk.FileChooserDialog) + + # GTK's file dialog might open on default location that doesn't have anything + # that can be selected, which alters closing behavior. To provide consistent + # test conditions, select an arbitrary folder that we know has subfolders. We + # don't care which folder it is, as we're mocking the return value of the + # dialog. + if result: + folder = str(Path(__file__).parent.parent) + dialog.native.set_current_folder(folder) + # We don't know how long it will take for the GUI to update, so iterate + # for a while until the change has been applied. + await self.redraw("Selected a single (arbitrary) folder") + count = 0 + while dialog.native.get_current_folder() != folder and count < 10: + await asyncio.sleep(0.1) + count += 1 + assert ( + dialog.native.get_current_folder() == folder + ), "Dialog didn't select dummy folder" + + if result is not None: + if multiple_select: + if result: + # Since we are mocking selected_path(), it's never actually invoked + # under test conditions. Call it just to confirm that it returns the + # type we think it does. + assert isinstance(dialog.selected_paths(), list) + + dialog.selected_paths = Mock( + return_value=[str(path) for path in result] + ) + else: + dialog.selected_path = Mock(return_value=str(result)) + + # If there's nothing selected, you can't press OK. + if result: + dialog.native.response(Gtk.ResponseType.OK) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + else: + dialog.native.response(Gtk.ResponseType.CANCEL) + + await self.wait_for_dialog( + dialog, + ( + f"{'Multiselect' if multiple_select else ' Select'} folder dialog " + f"({'OPEN' if result else 'CANCEL'}) dismissed" + ), + ) diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index c137e2e07c..8fa0f55e88 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -1,4 +1,5 @@ import asyncio +import weakref from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle @@ -8,7 +9,7 @@ class MainWindow(Window): - pass + _is_main_window = True class PythonAppDelegate(UIResponder): @@ -53,7 +54,7 @@ def application_didChangeStatusBarOrientation_( class App: def __init__(self, interface): self.interface = interface - self.interface._impl = self + # Native instance doesn't exist until the lifecycle completes. self.native = None @@ -63,6 +64,14 @@ def __init__(self, interface): asyncio.set_event_loop_policy(EventLoopPolicy()) self.loop = asyncio.new_event_loop() + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create(self): """Calls the startup method on the interface.""" self.interface._startup() diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index d23e6c4b73..9f0f260a3d 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -1,3 +1,5 @@ +import weakref + from .libs import ( UIApplication, UINavigationController, @@ -15,15 +17,15 @@ class BaseContainer: - def __init__(self, content=None, on_refresh=None): + def __init__(self, content=None, parent=None): """A base class for iOS containers. :param content: The widget impl that is the container's initial content. - :param on_refresh: The callback to be notified when this container's layout is - refreshed. + :param parent: The parent of this container; this is the object that will be + notified when this container's layout is refreshed. """ self._content = content - self.on_refresh = on_refresh + self.parent = weakref.ref(parent) @property def content(self): @@ -39,7 +41,7 @@ def content(self): @content.setter def content(self, widget): - if self._content: + if self.content: self._content.container = None self._content = widget @@ -47,12 +49,11 @@ def content(self, widget): widget.container = self def refreshed(self): - if self.on_refresh: - self.on_refresh(self) + self.parent().content_refreshed(self) class Container(BaseContainer): - def __init__(self, content=None, layout_native=None, on_refresh=None): + def __init__(self, content=None, layout_native=None, parent=None): """ :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 @@ -60,10 +61,10 @@ def __init__(self, content=None, layout_native=None, on_refresh=None): 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 parent: The parent of this container; this is the object that will be + notified when this container's layout is refreshed. """ - super().__init__(content=content, on_refresh=on_refresh) + super().__init__(content=content, parent=parent) self.native = UIView.alloc().init() self.native.translatesAutoresizingMaskIntoConstraints = True @@ -87,7 +88,7 @@ def __init__( self, content=None, layout_native=None, - on_refresh=None, + parent=None, ): """ :param content: The widget impl that is the container's initial content. @@ -96,13 +97,13 @@ def __init__( 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 parent: The parent of this container; this is the object that will be + notified when this container's layout is refreshed. """ super().__init__( content=content, layout_native=layout_native, - on_refresh=on_refresh, + parent=parent, ) # Construct a NavigationController that provides a navigation bar, and diff --git a/iOS/src/toga_iOS/dialogs.py b/iOS/src/toga_iOS/dialogs.py index 1448bf8fa6..300a52e76a 100644 --- a/iOS/src/toga_iOS/dialogs.py +++ b/iOS/src/toga_iOS/dialogs.py @@ -1,4 +1,4 @@ -from abc import ABC +from abc import ABC, abstractmethod from rubicon.objc import Block from rubicon.objc.runtime import objc_id @@ -14,7 +14,7 @@ class BaseDialog(ABC): def __init__(self, interface): self.interface = interface - self.interface.impl = self + self.interface._impl = self class AlertDialog(BaseDialog): @@ -34,8 +34,9 @@ def __init__(self, interface, title, message, on_result=None): completion=None, ) + @abstractmethod def populate_dialog(self, native): - pass + ... def response(self, value): self.on_result(self, value) @@ -139,7 +140,7 @@ def __init__( title, initial_directory, file_types, - multiselect, + multiple_select, on_result=None, ): super().__init__(interface=interface) @@ -152,7 +153,7 @@ def __init__( interface, title, initial_directory, - multiselect, + multiple_select, on_result=None, ): super().__init__(interface=interface) diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index cb32c84c66..4dcf78a567 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -34,10 +34,7 @@ def create(self): self._allow_horizontal = True self._allow_vertical = True - self.document_container = Container( - layout_native=self.native, - on_refresh=self.content_refreshed, - ) + self.document_container = Container(layout_native=self.native, parent=self) self.native.addSubview(self.document_container.native) self.add_constraints() diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 5efbb66099..c39e2515c9 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -1,3 +1,5 @@ +import weakref + from toga_iOS.container import RootContainer from toga_iOS.libs import ( UIColor, @@ -7,15 +9,21 @@ class Window: + _is_main_window = False + def __init__(self, interface, title, position, size): self.interface = interface - self.interface._impl = self + + if not self._is_main_window: + raise RuntimeError( + "Secondary windows cannot be created on mobile platforms" + ) self.native = UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds) # Set up a container for the window's content # RootContainer provides a titlebar for the window. - self.container = RootContainer(on_refresh=self.content_refreshed) + self.container = RootContainer(parent=self) # Set the size of the content to the size of the window self.container.native.frame = self.native.bounds @@ -33,6 +41,14 @@ def __init__(self, interface, title, position, size): self.set_title(title) + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def set_content(self, widget): self.container.content = widget @@ -84,5 +100,9 @@ def get_visible(self): # The window is always visible return True + def set_full_screen(self, is_full_screen): + # Windows are always full screen + pass + def close(self): pass diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py new file mode 100644 index 0000000000..199353464c --- /dev/null +++ b/iOS/tests_backend/window.py @@ -0,0 +1,71 @@ +import pytest + +from toga_iOS.libs import UIWindow + +from .probe import BaseProbe + + +class WindowProbe(BaseProbe): + def __init__(self, app, window): + super().__init__() + self.app = app + self.window = window + self.impl = window._impl + self.native = window._impl.native + assert isinstance(self.native, UIWindow) + + async def wait_for_window(self, message, minimize=False, full_screen=False): + await self.redraw(message) + + @property + def content_size(self): + return ( + self.native.contentView.frame.size.width, + self.native.contentView.frame.size.height, + ) + + async def close_info_dialog(self, dialog): + self.native.rootViewController.dismissViewControllerAnimated( + False, completion=None + ) + dialog.native.actions[0].handler(dialog.native) + await self.redraw("Info dialog dismissed") + + async def close_question_dialog(self, dialog, result): + self.native.rootViewController.dismissViewControllerAnimated( + False, completion=None + ) + if result: + dialog.native.actions[0].handler(dialog.native) + else: + dialog.native.actions[1].handler(dialog.native) + await self.redraw(f"Question dialog ({'YES' if result else 'NO'}) dismissed") + + async def close_confirm_dialog(self, dialog, result): + self.native.rootViewController.dismissViewControllerAnimated( + False, completion=None + ) + if result: + dialog.native.actions[0].handler(dialog.native) + else: + dialog.native.actions[1].handler(dialog.native) + await self.redraw(f"Question dialog ({'OK' if result else 'CANCEL'}) dismissed") + + async def close_error_dialog(self, dialog): + self.native.rootViewController.dismissViewControllerAnimated( + False, completion=None + ) + dialog.native.actions[0].handler(dialog.native) + await self.redraw("Error dialog dismissed") + + async def close_stack_trace_dialog(self, dialog, result): + pytest.skip("Stack Trace dialog not implemented on iOS") + + async def close_save_file_dialog(self, dialog, result): + pytest.skip("Save File dialog not implemented on iOS") + + async def close_open_file_dialog(self, dialog, result, multiple_select): + pytest.skip("Open File dialog not implemented on iOS") + + async def close_select_folder_dialog(self, dialog, result, multiple_select): + pytest.skip("Select Folder dialog not implemented on iOS") diff --git a/pyproject.toml b/pyproject.toml index 5cccb3446e..aa3fb03160 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ exclude_lines = [ "@(abc\\.)?abstractmethod", "NotImplementedError\\(\\)", "if TYPE_CHECKING:", - "class .+?\\(Protocol\\):", + "class .+?\\(Protocol.*\\):", + "@overload", ] [tool.isort] diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index bce5ac1eda..20ffd097fe 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -3,10 +3,6 @@ class Testbed(toga.App): def startup(self): - # A flag that controls whether the test suite should slow down - # so that changes are observable - self.run_slow = False - # Set a default return code for the app, so that a value is # available if the app exits for a reason other than the test # suite exiting/crashing. diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py new file mode 100644 index 0000000000..6b29778b85 --- /dev/null +++ b/testbed/tests/test_window.py @@ -0,0 +1,698 @@ +import gc +import io +import traceback +import weakref +from importlib import import_module +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE +from toga.style.pack import COLUMN, Pack + + +def window_probe(app, window): + module = import_module("tests_backend.window") + return getattr(module, "WindowProbe")(app, window) + + +@pytest.fixture +async def second_window(second_window_kwargs): + yield toga.Window(**second_window_kwargs) + + +@pytest.fixture +async def second_window_probe(app, second_window): + second_window.show() + probe = window_probe(app, second_window) + await probe.wait_for_window(f"Window ({second_window.title}) has been created") + yield probe + if second_window in app.windows: + second_window.close() + + +@pytest.fixture +async def main_window_probe(app, main_window): + yield window_probe(app, main_window) + + +async def test_title(main_window, main_window_probe): + """The title of a window can be changed""" + original_title = main_window.title + assert original_title == "Toga Testbed" + await main_window_probe.wait_for_window("Window title can be retrieved") + + try: + main_window.title = "A Different Title" + assert main_window.title == "A Different Title" + await main_window_probe.wait_for_window("Window title can be changed") + finally: + main_window.title = original_title + assert main_window.title == "Toga Testbed" + await main_window_probe.wait_for_window("Window title can be reverted") + + +# Mobile platforms have different windowing characterics, so they have different tests. +if toga.platform.current_platform in {"iOS", "android"}: + #################################################################################### + # Mobile platform tests + #################################################################################### + + async def test_visibility(main_window, main_window_probe): + """Hide and close are no-ops on mobile""" + assert main_window.visible + + main_window.hide() + await main_window_probe.wait_for_window("Window.hide is a no-op") + assert main_window.visible + + main_window.close() + await main_window_probe.wait_for_window("Window.close is a no-op") + assert main_window.visible + + async def test_secondary_window(): + """A secondary window cannot be created""" + with pytest.raises( + RuntimeError, + match=r"Secondary windows cannot be created on mobile platforms", + ): + toga.Window() + + async def test_move_and_resize(main_window, main_window_probe, capsys): + """Move and resize are no-ops on mobile.""" + initial_size = main_window.size + content_size = main_window_probe.content_size + assert initial_size[0] > 300 + assert initial_size[1] > 500 + + assert main_window.position == (0, 0) + + main_window.position = (150, 50) + await main_window_probe.wait_for_window("Main window can't be moved") + assert main_window.size == initial_size + assert main_window.position == (0, 0) + + main_window.size = (200, 150) + await main_window_probe.wait_for_window("Main window cannot be resized") + assert main_window.size == initial_size + assert main_window.position == (0, 0) + + try: + orig_content = main_window.content + + box1 = toga.Box( + style=Pack(background_color=REBECCAPURPLE, width=10, height=10) + ) + box2 = toga.Box(style=Pack(background_color=GOLDENROD, width=10, height=20)) + main_window.content = toga.Box( + children=[box1, box2], + style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), + ) + await main_window_probe.wait_for_window("Main window content has been set") + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + + # Alter the content width to exceed window width + box1.style.width = 1000 + await main_window_probe.wait_for_window( + "Content is too wide for the window" + ) + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + + assert ( + "**WARNING** Window content exceeds available space" + in capsys.readouterr().out + ) + + # Resize content to fit + box1.style.width = 100 + await main_window_probe.wait_for_window("Content fits in window") + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + + assert ( + "**WARNING** Window content exceeds available space" + not in capsys.readouterr().out + ) + + # Alter the content width to exceed window height + box1.style.height = 2000 + await main_window_probe.wait_for_window( + "Content is too tall for the window" + ) + assert main_window.size == initial_size + assert main_window_probe.content_size == content_size + + assert ( + "**WARNING** Window content exceeds available space" + in capsys.readouterr().out + ) + finally: + main_window.content = orig_content + + async def test_full_screen(main_window, main_window_probe): + """Window can be made full screen""" + main_window.full_screen = True + await main_window_probe.wait_for_window("Full screen is a no-op") + + main_window.full_screen = False + await main_window_probe.wait_for_window("Full screen is a no-op") + +else: + #################################################################################### + # Desktop platform tests + #################################################################################### + + @pytest.mark.parametrize("second_window_kwargs", [{}]) + async def test_secondary_window(app, second_window, second_window_probe): + """A secondary window can be created""" + assert second_window.app == app + assert second_window in app.windows + + assert second_window.title == "Toga" + assert second_window.size == (640, 480) + assert second_window.position == (100, 100) + assert second_window_probe.is_resizable + assert second_window_probe.is_closable + if second_window_probe.supports_minimize_control: + assert second_window_probe.is_minimizable + + second_window.close() + await second_window_probe.wait_for_window("Secondary window has been closed") + + assert second_window not in app.windows + + 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 + # because the fixture will retain a reference, preventing garbage collection. + second_window = toga.Window() + second_window.content = toga.Box() + second_window.show() + await app_probe.redraw("Secondary Window has been created") + + # Retain a weak reference to the window to check garbage collection + window_ref = weakref.ref(second_window) + impl_ref = weakref.ref(second_window._impl) + + second_window.close() + await app_probe.redraw("Secondary window has been closed") + + # Clear the local reference to the window (which should be the last reference), + # and force a garbage collection pass. This should cause deletion of both the + # interface and impl of the window. + second_window = None + gc.collect() + + # Assert that the weak references are now dead. + assert window_ref() is None + assert impl_ref() is None + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 300), size=(300, 200))], + ) + async def test_secondary_window_with_args(app, second_window, second_window_probe): + """A secondary window can be created with a specific size and position.""" + on_close_handler = Mock(return_value=False) + second_window.on_close = on_close_handler + + second_window.show() + await second_window_probe.wait_for_window("Secondary window has been shown") + + assert second_window.app == app + assert second_window in app.windows + + assert second_window.title == "Secondary Window" + assert second_window.size == (300, 200) + assert second_window.position == (200, 300) + + second_window_probe.close() + await second_window_probe.wait_for_window( + "Attempt to close second window that is rejected" + ) + on_close_handler.assert_called_once_with(second_window) + + assert second_window in app.windows + + # Reset, and try again, this time allowing the + on_close_handler.reset_mock() + on_close_handler.return_value = True + + second_window_probe.close() + await second_window_probe.wait_for_window( + "Attempt to close second window that succeeds" + ) + on_close_handler.assert_called_once_with(second_window) + + assert second_window not in app.windows + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Not Resizable", resizable=False, position=(200, 150))], + ) + async def test_non_resizable(second_window, second_window_probe): + """A non-resizable window can be created""" + assert second_window.visible + assert not second_window_probe.is_resizable + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Not Closeable", closable=False, position=(200, 150))], + ) + async def test_non_closable(second_window, second_window_probe): + """A non-closable window can be created""" + assert second_window.visible + assert not second_window_probe.is_closable + + # Do a UI close on the window + second_window_probe.close() + await second_window_probe.wait_for_window("Close request was ignored") + assert second_window.visible + + # Do an explicit close on the window + second_window.close() + await second_window_probe.wait_for_window("Explicit close was honored") + + assert not second_window.visible + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Not Minimizable", minimizable=False, position=(200, 150))], + ) + async def test_non_minimizable(second_window, second_window_probe): + """A non-minimizable window can be created""" + assert second_window.visible + assert not second_window_probe.is_minimizable + + second_window_probe.minimize() + await second_window_probe.wait_for_window("Minimize request has been ignored") + assert not second_window_probe.is_minimized + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 150))], + ) + async def test_visibility(app, second_window, second_window_probe): + """Visibility of a window can be controlled""" + assert second_window.app == app + assert second_window in app.windows + + assert second_window.visible + assert second_window.size == (640, 480) + assert second_window.position == (200, 150) + + # Move the window + second_window.position = (250, 200) + + await second_window_probe.wait_for_window("Secondary window has been moved") + assert second_window.size == (640, 480) + assert second_window.position == (250, 200) + + # Resize the window + second_window.size = (300, 250) + + await second_window_probe.wait_for_window( + "Secondary window has been resized; position has not changed" + ) + + assert second_window.size == (300, 250) + # We can't confirm position here, because it may have changed. macOS rescales + # windows relative to the bottom-left corner, which means the position of the + # window has changed relative to the Toga coordinate frame. + + second_window.hide() + await second_window_probe.wait_for_window("Secondary window has been hidden") + + assert not second_window.visible + + # Move and resize the window while offscreen + second_window.size = (250, 200) + second_window.position = (300, 150) + + second_window.show() + await second_window_probe.wait_for_window( + "Secondary window has been made visible again; window has moved" + ) + + assert second_window.visible + assert second_window.size == (250, 200) + if second_window_probe.supports_move_while_hidden: + assert second_window.position == (300, 150) + + second_window_probe.minimize() + # Delay is required to account for "genie" animations + await second_window_probe.wait_for_window( + "Window has been minimized", + minimize=True, + ) + + assert second_window_probe.is_minimized + + if second_window_probe.supports_unminimize: + second_window_probe.unminimize() + # Delay is required to account for "genie" animations + await second_window_probe.wait_for_window( + "Window has been unminimized", + minimize=True, + ) + + assert not second_window_probe.is_minimized + + second_window_probe.close() + await second_window_probe.wait_for_window("Secondary window has been closed") + + assert second_window not in app.windows + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 150))], + ) + async def test_move_and_resize(second_window, second_window_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] + + second_window.position = (150, 50) + await second_window_probe.wait_for_window("Secondary window has been moved") + assert second_window.position == (150, 50) + + second_window.size = (200, 150) + await second_window_probe.wait_for_window("Secondary window has been resized") + assert second_window.size == (200, 150) + assert second_window_probe.content_size == ( + 200 - extra_width, + 150 - extra_height, + ) + + box1 = toga.Box(style=Pack(background_color=REBECCAPURPLE, width=10, height=10)) + box2 = toga.Box(style=Pack(background_color=GOLDENROD, width=10, height=200)) + second_window.content = toga.Box( + children=[box1, box2], + style=Pack(direction=COLUMN, background_color=CORNFLOWERBLUE), + ) + await second_window_probe.wait_for_window( + "Secondary window has had height adjusted due to content" + ) + assert second_window.size == (200 + extra_width, 210 + extra_height) + assert second_window_probe.content_size == (200, 210) + + # Alter the content width to exceed window size + box1.style.width = 250 + await second_window_probe.wait_for_window( + "Secondary window has had width adjusted due to content" + ) + assert second_window.size == (250 + extra_width, 210 + extra_height) + assert second_window_probe.content_size == (250, 210) + + # Try to resize to a size less than the content size + second_window.size = (200, 150) + await second_window_probe.wait_for_window( + "Secondary window forced resize fails" + ) + assert second_window.size == (250 + extra_width, 210 + extra_height) + assert second_window_probe.content_size == (250, 210) + + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 150))], + ) + async def test_full_screen(second_window, second_window_probe): + """Window can be made full screen""" + assert not second_window_probe.is_full_screen + initial_content_size = second_window_probe.content_size + + second_window.full_screen = True + # A longer delay to allow for genie animations + await second_window_probe.wait_for_window( + "Secondary window is full screen", + full_screen=True, + ) + assert second_window_probe.is_full_screen + assert second_window_probe.content_size[0] > initial_content_size[0] + assert second_window_probe.content_size[1] > initial_content_size[1] + + second_window.full_screen = True + await second_window_probe.wait_for_window( + "Secondary window is still full screen" + ) + assert second_window_probe.is_full_screen + assert second_window_probe.content_size[0] > initial_content_size[0] + assert second_window_probe.content_size[1] > initial_content_size[1] + + second_window.full_screen = False + # A longer delay to allow for genie animations + await second_window_probe.wait_for_window( + "Secondary window is not full screen", + full_screen=True, + ) + assert not second_window_probe.is_full_screen + assert second_window_probe.content_size == initial_content_size + + second_window.full_screen = False + await second_window_probe.wait_for_window( + "Secondary window is still not full screen" + ) + assert not second_window_probe.is_full_screen + assert second_window_probe.content_size == initial_content_size + + +######################################################################################## +# Dialog tests +######################################################################################## + + +async def test_info_dialog(main_window, main_window_probe): + """An info dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.info_dialog( + "Info", "Some info", on_result=on_result_handler + ) + await main_window_probe.redraw("Info dialog displayed") + await main_window_probe.close_info_dialog(dialog_result._impl) + + on_result_handler.assert_called_once_with(main_window, None) + assert await dialog_result is None + + +@pytest.mark.parametrize("result", [False, True]) +async def test_question_dialog(main_window, main_window_probe, result): + """An question dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.question_dialog( + "Question", + "Some question", + on_result=on_result_handler, + ) + await main_window_probe.redraw("Question dialog displayed") + await main_window_probe.close_question_dialog(dialog_result._impl, result) + + on_result_handler.assert_called_once_with(main_window, result) + assert await dialog_result is result + + +@pytest.mark.parametrize("result", [False, True]) +async def test_confirm_dialog(main_window, main_window_probe, result): + """A confirmation dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.confirm_dialog( + "Confirm", + "Some confirmation", + on_result=on_result_handler, + ) + await main_window_probe.redraw("Confirmation dialog displayed") + await main_window_probe.close_confirm_dialog(dialog_result._impl, result) + + on_result_handler.assert_called_once_with(main_window, result) + assert await dialog_result is result + + +async def test_error_dialog(main_window, main_window_probe): + """An error dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.error_dialog( + "Error", "Some error", on_result=on_result_handler + ) + await main_window_probe.redraw("Error dialog displayed") + await main_window_probe.close_error_dialog(dialog_result._impl) + + on_result_handler.assert_called_once_with(main_window, None) + assert await dialog_result is None + + +@pytest.mark.parametrize("result", [None, False, True]) +async def test_stack_trace_dialog(main_window, main_window_probe, result): + """A confirmation dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + stack = io.StringIO() + traceback.print_stack(file=stack) + dialog_result = main_window.stack_trace_dialog( + "Stack Trace", + "Some stack trace", + stack.getvalue(), + retry=result is not None, + on_result=on_result_handler, + ) + await main_window_probe.redraw( + f"Stack trace dialog (with{'out' if result is None else ''} retry) displayed" + ) + await main_window_probe.close_stack_trace_dialog(dialog_result._impl, result) + + on_result_handler.assert_called_once_with(main_window, result) + assert await dialog_result is result + + +@pytest.mark.parametrize( + "filename, file_types, result", + [ + ("/path/to/file.txt", None, Path("/path/to/file.txt")), + ("/path/to/file.txt", None, None), + ("/path/to/file.txt", [".txt", ".doc"], Path("/path/to/file.txt")), + ("/path/to/file.txt", [".txt", ".doc"], None), + ], +) +async def test_save_file_dialog( + main_window, + main_window_probe, + filename, + file_types, + result, +): + """A file open dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.save_file_dialog( + "Save file", + suggested_filename=filename, + file_types=file_types, + on_result=on_result_handler, + ) + await main_window_probe.redraw("Save File dialog displayed") + await main_window_probe.close_save_file_dialog(dialog_result._impl, result) + + if result: + # The directory where the file dialog is opened can't be 100% predicted + # so we need to modify the check to only inspect the filename. + on_result_handler.call_count == 1 + assert on_result_handler.mock_calls[0].args[0] == main_window + assert on_result_handler.mock_calls[0].args[1].name == Path(filename).name + assert (await dialog_result).name == Path(filename).name + else: + on_result_handler.assert_called_once_with(main_window, None) + assert await dialog_result is None + + +@pytest.mark.parametrize( + "initial_directory, file_types, multiple_select, result", + [ + # Successful single select + (Path(__file__).parent, None, False, Path("/path/to/file1.txt")), + # Cancelled single select + (Path(__file__).parent, None, False, None), + # Successful single select with no initial directory + (None, None, False, Path("/path/to/file1.txt")), + # Successful single select with file types + (Path(__file__).parent, [".txt", ".doc"], False, Path("/path/to/file1.txt")), + # Successful multiple selection + ( + Path(__file__).parent, + None, + True, + [Path("/path/to/file1.txt"), Path("/path/to/file2.txt")], + ), + # Successful multiple selection of no items + (Path(__file__).parent, None, True, []), + # Cancelled multiple selection + (Path(__file__).parent, None, True, None), + # Successful multiple selection with no initial directory + (None, None, True, [Path("/path/to/file1.txt"), Path("/path/to/file2.txt")]), + # Successful multiple selection with file types + ( + Path(__file__).parent, + [".txt", ".doc"], + True, + [Path("/path/to/file1.txt"), Path("/path/to/file2.txt")], + ), + ], +) +async def test_open_file_dialog( + main_window, + main_window_probe, + initial_directory, + file_types, + multiple_select, + result, +): + """A file open dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.open_file_dialog( + "Open file", + initial_directory=initial_directory, + file_types=file_types, + multiple_select=multiple_select, + on_result=on_result_handler, + ) + await main_window_probe.redraw("Open File dialog displayed") + await main_window_probe.close_open_file_dialog( + dialog_result._impl, + result, + multiple_select=multiple_select, + ) + + if result: + on_result_handler.assert_called_once_with(main_window, result) + assert await dialog_result == result + else: + on_result_handler.assert_called_once_with(main_window, None) + assert await dialog_result is None + + +@pytest.mark.parametrize( + "initial_directory, multiple_select, result", + [ + # Successful single select + (Path(__file__).parent, False, Path("/path/to/dir1")), + # Cancelled single select + (Path(__file__).parent, False, None), + # Successful single select with no initial directory + (None, False, Path("/path/to/dir1")), + # Successful multiple selection + (Path(__file__).parent, True, [Path("/path/to/dir1"), Path("/path/to/dir2")]), + # Successful multiple selection with no items + (Path(__file__).parent, True, []), + # Cancelled multiple selection + (Path(__file__).parent, True, None), + ], +) +async def test_select_folder_dialog( + main_window, + main_window_probe, + initial_directory, + multiple_select, + result, +): + """A folder selection dialog can be displayed and acknowledged.""" + on_result_handler = Mock() + dialog_result = main_window.select_folder_dialog( + "Select folder", + initial_directory=initial_directory, + multiple_select=multiple_select, + on_result=on_result_handler, + ) + await main_window_probe.redraw("Select Folder dialog displayed") + await main_window_probe.close_select_folder_dialog( + dialog_result._impl, + result, + multiple_select=multiple_select, + ) + + if result: + on_result_handler.assert_called_once_with(main_window, result) + assert await dialog_result == result + else: + on_result_handler.assert_called_once_with(main_window, None) + assert await dialog_result is None diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index fd8da6b187..244294b5e4 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -2,6 +2,7 @@ import os import sys import tempfile +import time import traceback from functools import partial from pathlib import Path @@ -15,7 +16,7 @@ def run_tests(app, cov, args, report_coverage, run_slow): try: - # Control the run speed of the + # Control the run speed of the test app. app.run_slow = run_slow project_path = Path(__file__).parent.parent @@ -75,6 +76,9 @@ def run_tests(app, cov, args, report_coverage, run_slow): traceback.print_exc() app.returncode = 1 finally: + print(f">>>>>>>>>> EXIT {app.returncode} <<<<<<<<<<") + # Add a short pause to make sure any log tailing gets a chance to flush + time.sleep(0.5) app.add_background_task(lambda app, **kwargs: app.exit()) @@ -147,14 +151,7 @@ def get_terminal_size(*args, **kwargs): report_coverage=report_coverage, ) ) - app.add_background_task(lambda app, *kwargs: thread.start()) - - # Add an on_exit handler that will terminate the test suite. - def exit_suite(app, **kwargs): - print(f">>>>>>>>>> EXIT {app.returncode} <<<<<<<<<<") - return True - - app.on_exit = exit_suite + thread.start() # Start the test app. app.main_loop() diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index fdb71cba10..9f7ce025cc 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,3 +1,5 @@ +import weakref + import toga from toga_web.libs import create_element, js from toga_web.window import Window @@ -11,7 +13,14 @@ def on_close(self, *args): class App: def __init__(self, interface): self.interface = interface - self.interface._impl = self + + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) def create(self): # self.resource_path = os.path.dirname(os.path.dirname(NSBundle.mainBundle.bundlePath)) diff --git a/web/src/toga_web/window.py b/web/src/toga_web/window.py index 6e437cda4e..aeca0922c3 100644 --- a/web/src/toga_web/window.py +++ b/web/src/toga_web/window.py @@ -1,10 +1,11 @@ +import weakref + from toga_web.libs import create_element, js class Window: def __init__(self, interface, title, position, size): self.interface = interface - self.interface._impl = self self.native = create_element( "main", @@ -18,6 +19,14 @@ def __init__(self, interface, title, position, size): self.set_title(title) + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def get_title(self): return js.document.title diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 7a50715fc0..78a6df22bb 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -2,6 +2,7 @@ import re import sys import threading +import weakref import toga from toga import Key @@ -39,7 +40,6 @@ class App: def __init__(self, interface): self.interface = interface - self.interface._impl = self # Winforms app exit is tightly bound to the close of the MainWindow. # The FormClosing message on MainWindow triggers the "on_exit" handler @@ -58,6 +58,14 @@ def __init__(self, interface): self.loop = WinformsProactorEventLoop() asyncio.set_event_loop(self.loop) + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create(self): self.native = WinForms.Application self.app_context = WinForms.ApplicationContext() diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 8d762ba2cc..af7c01e46d 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -1,3 +1,5 @@ +import weakref + from toga import GROUP_BREAK, SECTION_BREAK from .container import Container @@ -8,7 +10,6 @@ class Window(Container, Scalable): def __init__(self, interface, title, position, size): self.interface = interface - self.interface._impl = self # Winforms close handling is caught on the FormClosing handler. To allow # for async close handling, we need to be able to abort this close @@ -37,10 +38,18 @@ def __init__(self, interface, title, position, size): self.native.Resize += lambda sender, args: self.resize_content() self.resize_content() # Store initial size - if not self.native.interface.resizeable: + if not self.native.interface.resizable: self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False + @property + def interface(self): + return self._interface() + + @interface.setter + def interface(self, value): + self._interface = weakref.ref(value) + def create_toolbar(self): if self.interface.toolbar: if self.toolbar_native: @@ -132,7 +141,7 @@ def winforms_FormClosing(self, sender, event): # If the app is exiting, or a manual close has been requested, # don't get confirmation; just close. if not self.interface.app._impl._is_exiting and not self._is_closing: - if not self.interface.closeable: + if not self.interface.closable: # Closeability is implemented by shortcutting the close handler. event.Cancel = True elif self.interface.on_close._raw: