Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Limit toolbars to MainWindow instances, and allow for MainWindow menus. #2646

Merged
merged 22 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e4d61dc
Refactor handling of toolbar so that only main windows have a toolbar.
freakboy3742 Jun 13, 2024
b549604
Fix Cocoa implementation of toolbars.
freakboy3742 Jun 13, 2024
3766a81
Update iOS and Android backends.
freakboy3742 Jun 13, 2024
3ea7ba9
Update web and textual backends.
freakboy3742 Jun 13, 2024
705d162
Add changenote.
freakboy3742 Jun 13, 2024
b2d65a7
Correct test edge cases on macOS, iOS, Android.
freakboy3742 Jun 13, 2024
8216837
Correct toolbar reference in docs.
freakboy3742 Jun 13, 2024
dca894c
Fix implementation on GTK and Winforms.
freakboy3742 Jun 13, 2024
24df998
Replicate menu-on-window creation behavior.
freakboy3742 Jun 13, 2024
63f122e
Catch some final coverage cases.
freakboy3742 Jun 13, 2024
586b4e8
Merge branch 'main' into mainwindow-toolbar
mhsmith Jun 18, 2024
a2429e5
Ensure that window-level toolbars and menus are created on secondary …
freakboy3742 Jun 18, 2024
d3915bf
Use the app name for the title of simple windows.
freakboy3742 Jun 19, 2024
d11ce05
Cluster menu and toolbar handling together.
freakboy3742 Jun 19, 2024
3184814
Tweaks to MainWindow documentation.
freakboy3742 Jun 19, 2024
a596626
Correct some testbed failures.
freakboy3742 Jun 19, 2024
77b2283
Remove a stray docstring experiment.
freakboy3742 Jun 19, 2024
3ae5823
Add changenote for title change.
freakboy3742 Jun 19, 2024
84ecf04
Correct docstring for window title.
freakboy3742 Jun 19, 2024
b68c365
Update changes/2646.removal.2.rst
freakboy3742 Jun 19, 2024
016cb25
Check for the existence of an app before creating window impl.
freakboy3742 Jun 19, 2024
4e1770e
Simplify the docstring for MainWindow.
freakboy3742 Jun 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions android/src/toga_android/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,12 @@ def onGlobalLayout(self):


class Window(Container):
_is_main_window = False

def __init__(self, interface, title, position, size):
super().__init__()
self.interface = interface
self.interface._impl = self
self._initial_title = title

if not self._is_main_window:
raise RuntimeError(
"Secondary windows cannot be created on mobile platforms"
)

######################################################################
# Window properties
######################################################################
Expand All @@ -63,12 +56,12 @@ def close(self): # pragma: no cover
# closed, so the platform-specific close handling is never triggered.
pass

def create_toolbar(self):
self.app.native.invalidateOptionsMenu()

def set_app(self, app):
if len(app.interface.windows) > 1:
raise RuntimeError("Secondary windows cannot be created on Android")

self.app = app
native_parent = app.native.findViewById(R.id.content)
native_parent = self.app.native.findViewById(R.id.content)
self.init_container(native_parent)
native_parent.getViewTreeObserver().addOnGlobalLayoutListener(
LayoutListener(self)
Expand Down Expand Up @@ -162,4 +155,5 @@ def get_image_data(self):


class MainWindow(Window):
_is_main_window = True
def create_toolbar(self):
self.app.native.invalidateOptionsMenu()
1 change: 1 addition & 0 deletions changes/2646.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GTK apps no longer have extra padding between the menubar and the window content when the app does not have a toolbar.
1 change: 1 addition & 0 deletions changes/2646.removal.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
It is no longer possible to create a toolbar on a ``Window`` instance. Toolbars can only be added to ``MainWindow`` (or subclass).
1 change: 1 addition & 0 deletions changes/2646.removal.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The default title of a ``toga.Window`` is now the name of the app, rather than ``"Toga"`.
108 changes: 60 additions & 48 deletions cocoa/src/toga_cocoa/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,7 @@ def __init__(self, interface, title, position, size):
self.native.wantsLayer = True
self.container.native.backgroundColor = self.native.backgroundColor

# By default, no toolbar
self._toolbar_items = {}
self.native_toolbar = None

def __del__(self):
self.purge_toolbar()
self.native.release()

######################################################################
Expand All @@ -204,49 +199,6 @@ def set_title(self, title):
def close(self):
self.native.close()

def create_toolbar(self):
# Purge any existing toolbar items
self.purge_toolbar()

# Create the new toolbar items.
if self.interface.toolbar:
for cmd in self.interface.toolbar:
if isinstance(cmd, Command):
self._toolbar_items[toolbar_identifier(cmd)] = cmd

self.native_toolbar = NSToolbar.alloc().initWithIdentifier(
"Toolbar-%s" % id(self)
)
self.native_toolbar.setDelegate(self.native)
else:
self.native_toolbar = None

self.native.setToolbar(self.native_toolbar)

# Adding/removing a toolbar changes the size of the content window.
if self.interface.content:
self.interface.content.refresh()

def purge_toolbar(self):
while self._toolbar_items:
dead_items = []
_, cmd = self._toolbar_items.popitem()
# The command might have toolbar representations on multiple window
# toolbars, and may have other representations (at the very least, a menu
# item). Only clean up the representation pointing at *this* window. Do this
# in 2 passes so that we're not modifying the set of native objects while
# iterating over it.
for item_native in cmd._impl.native:
if (
isinstance(item_native, NSToolbarItem)
and item_native.target == self.native
):
dead_items.append(item_native)

for item_native in dead_items:
cmd._impl.native.remove(item_native)
item_native.release()

def set_app(self, app):
pass

Expand Down Expand Up @@ -365,4 +317,64 @@ def get_image_data(self):


class MainWindow(Window):
def __init__(self, interface, title, position, size):
super().__init__(interface, title, position, size)

# By default, no toolbar
self._toolbar_items = {}
self.native_toolbar = None

def __del__(self):
self.purge_toolbar()
super().__del__()

def create_menus(self):
# macOS doesn't have window-level menus
pass

def create_toolbar(self):
# Purge any existing toolbar items
self.purge_toolbar()

# Create the new toolbar items.
if self.interface.toolbar:
for cmd in self.interface.toolbar:
if isinstance(cmd, Command):
self._toolbar_items[toolbar_identifier(cmd)] = cmd

self.native_toolbar = NSToolbar.alloc().initWithIdentifier(
"Toolbar-%s" % id(self)
)
self.native_toolbar.setDelegate(self.native)
else:
self.native_toolbar = None

self.native.setToolbar(self.native_toolbar)

# Adding/removing a toolbar changes the size of the content window.
if self.interface.content:
self.interface.content.refresh()

def purge_toolbar(self):
while self._toolbar_items:
dead_items = []
_, cmd = self._toolbar_items.popitem()
# The command might have toolbar representations on multiple window
# toolbars, and may have other representations (at the very least, a menu
# item). Only clean up the representation pointing at *this* window. Do this
# in 2 passes so that we're not modifying the set of native objects while
# iterating over it.
for item_native in cmd._impl.native:
if (
isinstance(item_native, NSToolbarItem)
and item_native.target == self.native
):
dead_items.append(item_native)

for item_native in dead_items:
cmd._impl.native.remove(item_native)
item_native.release()


class DocumentMainWindow(Window):
pass
14 changes: 11 additions & 3 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,12 +541,20 @@ def _startup(self) -> None:
self.startup()
self._verify_startup()

# Manifest the initial state of the menus.
# Manifest the initial state of the menus. This will cascade down to all
# open windows if the platform has window-based menus. Then install the
# on-change handler for menus to respond to any future changes.
self._impl.create_menus()

# Now that we have a finalized impl, set the on_change handler for commands
self.commands.on_change = self._impl.create_menus

# Manifest the initial state of toolbars (on the windows that have
# them), then install a change listener so that any future changes to
# the toolbar cause a change in toolbar items.
mhsmith marked this conversation as resolved.
Show resolved Hide resolved
for window in self.windows:
if hasattr(window, "toolbar"):
window._impl.create_toolbar()
window.toolbar.on_change = window._impl.create_toolbar

def startup(self) -> None:
"""Create and show the main window for the application.

Expand Down
90 changes: 33 additions & 57 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def __init__(

:param id: A unique identifier for the window. If not provided, one will be
automatically generated.
:param title: Title for the window. Defaults to "Toga".
:param title: Title for the window. Defaults to the formal name of the app.
:param position: Position of the window, as a :any:`toga.Position` or tuple of
``(x, y)`` coordinates, in :ref:`CSS pixels <css-units>`.
:param size: Size of the window, as a :any:`toga.Size` or tuple of ``(width,
Expand Down Expand Up @@ -202,6 +202,12 @@ def __init__(
self._closable = closable
self._minimizable = minimizable

# The app needs to exist before windows are created. _app will only be None
# until the window is added to the app below.
self._app: App = None
if App.app is None:
raise RuntimeError("Cannot create a Window before creating an App")

self.factory = get_platform_factory()
self._impl = getattr(self.factory, self._WINDOW_CLASS)(
interface=self,
Expand All @@ -211,19 +217,12 @@ def __init__(
)

# Add the window to the app
# _app will only be None until the window is added to the app below
self._app: App = None
if App.app is None:
raise RuntimeError("Cannot create a Window before creating an App")
App.app.windows.add(self)

# If content has been provided, set it
if content:
self.content = content

# Create a toolbar that is linked to the app
self._toolbar = CommandSet(on_change=self._impl.create_toolbar, app=self._app)

self.on_close = on_close

def __lt__(self, other: Window) -> bool:
Expand Down Expand Up @@ -270,7 +269,7 @@ def resizable(self) -> bool:

@property
def _default_title(self) -> str:
return "Toga"
return toga.App.app.formal_name

@property
def title(self) -> str:
Expand Down Expand Up @@ -353,11 +352,6 @@ def content(self, widget: Widget) -> None:
# Update the geometry of the widget
widget.refresh()

@property
def toolbar(self) -> CommandSet:
"""Toolbar for the window."""
return self._toolbar

@property
def widgets(self) -> FilteredWidgetRegistry:
"""The widgets contained in the window.
Expand Down Expand Up @@ -952,56 +946,38 @@ def closeable(self) -> bool:
class MainWindow(Window):
_WINDOW_CLASS = "MainWindow"

def __init__(
self,
id: str | None = None,
title: str | None = None,
position: PositionT | None = None,
size: SizeT = Size(640, 480),
resizable: bool = True,
minimizable: bool = True,
on_close: OnCloseHandler | None = None,
content: Widget | None = None,
resizeable: None = None, # DEPRECATED
closeable: None = None, # DEPRECATED
):
"""Create a new main window.
def __init__(self, *args, **kwargs):
"""Create a new Main Window.

:param id: A unique identifier for the window. If not provided, one will be
automatically generated.
:param title: Title for the window. Defaults to the formal name of the app.
:param position: Position of the window, as a :any:`toga.Position` or tuple of
``(x, y)`` coordinates, in :ref:`CSS pixels <css-units>`.
:param size: Size of the window, as a :any:`toga.Size` or tuple of ``(width,
height)``, in :ref:`CSS pixels <css-units>`.
:param resizable: Can the window be resized by the user?
:param minimizable: Can the window be minimized by the user?
:param content: The initial content for the window.
:param on_close: The initial :any:`on_close` handler.
:param resizeable: **DEPRECATED** - Use ``resizable``.
:param closeable: **DEPRECATED** - Use ``closable``.
Accepts the same arguments as :class:`~toga.Window`.
"""
super().__init__(
id=id,
title=title,
position=position,
size=size,
resizable=resizable,
closable=True,
minimizable=minimizable,
content=content,
on_close=on_close,
# Deprecated arguments
resizeable=resizeable,
closeable=closeable,
)
super().__init__(*args, **kwargs)

# Create a toolbar that is linked to the app.
self._toolbar = CommandSet(app=self.app)

# If the window has been created during startup(), we don't want to
# install a change listener yet, as the startup process may install
# additional commands - we want to wait until startup is complete,
# create the initial state of the menus and toolbars, and then add a
# change listener. However, if startup *has* completed, we can install a
# change listener immediately, and trigger the creation of menus and
# toolbars.
if self.app.commands.on_change:
self._toolbar.on_change = self._impl.create_toolbar

self._impl.create_menus()
self._impl.create_toolbar()

@property
def _default_title(self) -> str:
return toga.App.app.formal_name
def toolbar(self) -> CommandSet:
"""Toolbar for the window."""
return self._toolbar


class DocumentMainWindow(Window):
_WINDOW_CLASS = "DocumentMainWindow"

def __init__(
self,
doc: Document,
Expand Down
8 changes: 8 additions & 0 deletions core/tests/app/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,13 @@ def startup_assertions(app):
startup=startup,
)

# Menus, commands and toolbars have been created
assert_action_performed(app, "create App commands")
startup.assert_called_once_with(app)
assert_action_performed(app, "create App menus")
assert_action_performed(app.main_window, "create Window menus")
assert_action_performed(app.main_window, "create toolbar")

# 4 menu items have been created
assert len(app.commands) == 4

Expand All @@ -502,8 +506,12 @@ def startup(self):
# The main window will exist, and will have the app's formal name.
assert app.main_window.title == "Test App"

# Menus, commands and toolbars have been created
assert_action_performed(app, "create App commands")
assert_action_performed(app, "create App menus")
assert_action_performed(app.main_window, "create Window menus")
assert_action_performed(app.main_window, "create toolbar")

# 5 menu items have been created
assert app._impl.n_menu_items == 5

Expand Down
6 changes: 6 additions & 0 deletions core/tests/app/test_customized_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
class CustomizedApp(toga.App):
def startup(self):
self.main_window = toga.MainWindow()
# Create a secondary simple window as part of app startup to verify
# that toolbar handling is skipped.
self.other_window = toga.Window()

self._preferences = Mock()

Expand Down Expand Up @@ -44,6 +47,9 @@ def test_create(event_loop, AppClass):
assert toga.Command.PREFERENCES in custom_app.commands
assert custom_app.commands[toga.Command.PREFERENCES].enabled

# A change handler has been added to the MainWindow's toolbar CommandSet
assert custom_app.main_window.toolbar.on_change is not None


@pytest.mark.parametrize(
"AppClass",
Expand Down
Loading
Loading