Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
89c90b7
Initial pass at implementing Scaffolds for iOS
johnzhou721 Jul 31, 2026
e815479
Core tests passing with refactors.
johnzhou721 Aug 2, 2026
d8b442c
Add more tests
johnzhou721 Aug 2, 2026
795ab0a
Rerun commit
johnzhou721 Aug 2, 2026
a4220a2
Update window.py
johnzhou721 Aug 2, 2026
622047b
Update base.py
johnzhou721 Aug 2, 2026
2c5cc8b
Empty
johnzhou721 Aug 2, 2026
d8f7f99
Rerun CI again
johnzhou721 Aug 2, 2026
a875a76
Bump timeout as workaround
johnzhou721 Aug 3, 2026
c9b6dab
wip
johnzhou721 Aug 3, 2026
c339fd5
Everything except Cocoa toolbars and tests working
johnzhou721 Aug 3, 2026
5bc2347
Fix things but presentation mode is now broken
johnzhou721 Aug 4, 2026
46bbfe3
Put a band-aid over presentation mode
johnzhou721 Aug 4, 2026
0fcd4bd
Cocoa fixes
johnzhou721 Aug 4, 2026
ea22cca
small additions
johnzhou721 Aug 4, 2026
6b9edba
fixes window (i guess?)
johnzhou721 Aug 4, 2026
1ccaa2d
Unrelated fixes for test suite to run properly
johnzhou721 Aug 4, 2026
8547f6a
Fix coverage
johnzhou721 Aug 4, 2026
57da14c
Fix typo in destructor method name
johnzhou721 Aug 4, 2026
a87b57d
Update base.py
johnzhou721 Aug 5, 2026
e2cdee8
Update window.py
johnzhou721 Aug 5, 2026
43190a9
use proper api
johnzhou721 Aug 5, 2026
31e6658
refactor
johnzhou721 Aug 5, 2026
2d91c98
Minor fix to constraint removals
johnzhou721 Aug 5, 2026
559e059
Update numberinput.py
johnzhou721 Aug 5, 2026
c451112
cleanup
johnzhou721 Aug 5, 2026
f771f5c
Refactor _remove_constraints function
johnzhou721 Aug 5, 2026
638a842
Use an LLM to do some preliminary reversal
johnzhou721 Aug 7, 2026
d898218
readd docstring removed by AI
johnzhou721 Aug 7, 2026
af25698
Update window.py
johnzhou721 Aug 7, 2026
2d87273
revert other changes
johnzhou721 Aug 7, 2026
5bdaa52
more reversals
johnzhou721 Aug 7, 2026
c5aaebc
fix
johnzhou721 Aug 7, 2026
3500df8
Refocus input widgets after done
johnzhou721 Aug 7, 2026
520e956
Cleanup vestigial objc syntax
johnzhou721 Aug 7, 2026
4e115d7
add back elif
johnzhou721 Aug 7, 2026
c70170d
Document
johnzhou721 Aug 7, 2026
148751b
get rid of cache
johnzhou721 Aug 7, 2026
354be05
Cache scaffold probing
johnzhou721 Aug 7, 2026
6cd0fec
Fix (hopefully)
johnzhou721 Aug 8, 2026
2a6795a
Fix again!
johnzhou721 Aug 8, 2026
85b8df2
Fix III
johnzhou721 Aug 8, 2026
a3fc6e8
Fix
johnzhou721 Aug 8, 2026
38be61c
rerun CI
johnzhou721 Aug 8, 2026
52be318
rerun ios ci
johnzhou721 Aug 8, 2026
d9ef0c5
Update window.py
johnzhou721 Aug 8, 2026
5e6d578
iOS please work
johnzhou721 Aug 8, 2026
3ec08c7
Update window.py
johnzhou721 Aug 8, 2026
e6e8b80
Update ci.yml
johnzhou721 Aug 8, 2026
566cdb8
fix
johnzhou721 Aug 8, 2026
b66d507
add probe
johnzhou721 Aug 8, 2026
e0319b5
if plug
johnzhou721 Aug 8, 2026
9c03fc9
Enable instafail plugin.
johnzhou721 Aug 8, 2026
9189aa2
Merge pull request #15 from beeware/scaffolds
johnzhou721 Aug 8, 2026
ed3c4d6
Merge branch 'scaffolds' into apple-scaffolds
johnzhou721 Aug 8, 2026
49feb37
Merge branch 'main' into apple-scaffolds
johnzhou721 Aug 8, 2026
536ecb1
Update constraints.py
johnzhou721 Aug 8, 2026
c51995f
Update base.py
johnzhou721 Aug 8, 2026
9030782
seems like CI is stable now
johnzhou721 Aug 8, 2026
f543b35
Update pyproject.toml
johnzhou721 Aug 8, 2026
2cbe1de
Update pyproject.toml
johnzhou721 Aug 8, 2026
898e48f
Ensure container width and height are greater than zero
johnzhou721 Aug 8, 2026
3257570
Merge branch 'scaffolds' into apple-scaffolds
johnzhou721 Aug 10, 2026
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
3 changes: 3 additions & 0 deletions cocoa/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ TimeInput = "toga_cocoa.widgets.timeinput:TimeInput"
Tree = "toga_cocoa.widgets.tree:Tree"
WebView = "toga_cocoa.widgets.webview:WebView"

# Scaffolds
Scaffold = "toga_cocoa.scaffolds.base:Scaffold"

# Windows
MainWindow = "toga_cocoa.window:MainWindow"
Window = "toga_cocoa.window:Window"
Expand Down
35 changes: 35 additions & 0 deletions cocoa/src/toga_cocoa/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
NSLayoutConstraint,
NSLayoutRelationGreaterThanOrEqual,
NSView,
NSViewController,
)

#######################################################################################
Expand Down Expand Up @@ -131,3 +132,37 @@ def min_height(self):
@min_height.setter
def min_height(self, height):
self._min_height_constraint.constant = height


class ControlledContainer(Container):
def __init__(
self,
min_width=100,
min_height=100,
layout_native=None,
on_refresh=None,
):
"""A container for layouts, wrapped in an NSViewController.

Creates and enforces minimum size constraints on the container widget.
In addition, wrapped in NSViewController to facilitate attachment to various
other things like an NSSplitViewItem.

:param min_width: The minimum width to enforce on the container
:param min_height: The minimum height to enforce on the container
:param layout_native: The native widget that should be used to provide size
hints to the layout. By default, this will usually be the container widget
itself; however, for widgets like ScrollContainer where the layout needs to
be computed based on a different size to what will be rendered, the source
of the size can be different.
:param on_refresh: The callback to be notified when this container's layout is
refreshed.
"""
super().__init__(
min_width=min_width,
min_height=min_height,
layout_native=layout_native,
on_refresh=on_refresh,
)
self.controller = NSViewController.alloc().init()
self.controller.view = self.native
9 changes: 9 additions & 0 deletions cocoa/src/toga_cocoa/libs/appkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,10 @@ def NSTextAlignment(alignment):
NSBezelBorder = 2
NSGrooveBorder = 3

######################################################################
# NSViewController.h
NSViewController = ObjCClass("NSViewController")

######################################################################
# NSWindow.h
NSWindow = ObjCClass("NSWindow")
Expand Down Expand Up @@ -834,3 +838,8 @@ class NSDatePickerStyle(IntEnum):
# NSDatePicker.h

NSDatePicker = ObjCClass("NSDatePicker")

######################################################################
# NSKeyValueBinding.h

NSTitleBinding = objc_const(appkit, "NSTitleBinding")
Empty file.
181 changes: 181 additions & 0 deletions cocoa/src/toga_cocoa/scaffolds/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
from rubicon.objc import SEL, NSObject, objc_method, objc_property

from toga.command import Command, Separator
from toga_cocoa.container import ControlledContainer
from toga_cocoa.libs import NSMutableArray, NSToolbar, NSToolbarItem


def toolbar_identifier(cmd):
return f"Toolbar-{type(cmd).__name__}-{id(cmd)}"


class ToolbarDelegate(NSObject):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving all the toolbar handling into scaffold implementation was a hard choice, since there's some additional bookeeping. But since we're already refactoring stuff here, I think we should do it now so we aren't scrambling to refactor the architecture when things like SidebarScaffold or OptionScaffold starts to declare their own toolbar items.

On macOS, when an app has a sidebar, the toolbar is displayed inside the right pane, and the actions can depend on sidebar selections. The toolbar belongs to the scaffold's content pane visually and funcitonally, despite there only being a single native toolbar at the level of the window. This is also not a Liquid Glass quirk, and has been present for many versions of macOS.

But also, #4298 established that certain scaffold types can contribute items to the window toolbar, so Scaffolds will need to manage and create toolbar directly on macOS.

The alternative would be to for OptionScaffold or SidebarScaffold to hook into the Window-level toolbar instance instead, but then we'd have to handle the scaffold signaling the window to modify its toolbar items, which gets messy fast. So I've made this decision here. Is this appropriate?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow why it's more messy. There's a Window-Scaffold communication issue either way.

In the macOS case specifically, it sounds like you're concerned that the Sidebar scaffold has/can have a toolbar that isn't the full width. However, AFAICT, that's a separate entity to the window's toolbar. For several releases, macOS has put "toolbar" items in the titlebar of the app.

The key detail for me - even in the SidebarScaffold or OptionScaffold world, the API for adding a toolbar in macOS is going to be window.setToolbar(). Looking at the API for NSSplitViewController - there's no toolbar properties that I can see; the toolbar is still being set on the Window.

It feels to me like you're convolving "how is the toolbar implemented" with "where are the toolbar items defined". In the case of macOS, the toolbar implementation is bound to the Window. It may ultimately need to interrogate the scaffold to determine some or all of the toolbar items - but that's more of a "get the initial toolbar contents on creation, update on notable UI event" task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I thought was that each scaffold could own one instance of the toolbar and the Window will just use its scaffold's toolbar instance. But turns out that we were recreating the toolbar instance each time we have an update, so yes, cross-signaling is still required.

It feels to me like you're convolving "how is the toolbar implemented" with "where are the toolbar items defined".

Most definitely yes. Thanks for catching my conceptual misunderstanding.

@johnzhou721 johnzhou721 Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll revert the placement of code here.

EDIT:: Sorry, typo. I meant I had reversed the placement of code here, but haven't pushed yet. Treat this as a done comment.

interface = objc_property(object, weak=True)
impl = objc_property(object, weak=True)

@objc_method
def toolbarAllowedItemIdentifiers_(self, toolbar): # pragma: no cover
"""Determine the list of available toolbar items."""
allowed = NSMutableArray.alloc().init()
for item in self.impl.toolbar_commands:
allowed.addObject_(toolbar_identifier(item))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should take this opportunity to clear up any vestigial method invocations - once upon a time, the trailing underscore was required, but we fixed that many years ago.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done; will push

return allowed

@objc_method
def toolbarDefaultItemIdentifiers_(self, toolbar):
"""Determine the list of toolbar items that will display by default."""
default = NSMutableArray.alloc().init()
prev_group = None
for item in self.impl.toolbar_commands:
if (
prev_group is not None
and item.group != prev_group
and not isinstance(item, Separator)
):
default.addObject_(toolbar_identifier(prev_group))
default.addObject_(toolbar_identifier(item))
prev_group = item.group

return default

@objc_method
def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_(
self,
toolbar,
identifier,
insert: bool,
):
"""Create the requested toolbar button."""
native = NSToolbarItem.alloc().initWithItemIdentifier_(identifier)
try:
item = self.impl._toolbar_items[str(identifier)]
native.setLabel(item.text)
native.setPaletteLabel(item.text)
if item.tooltip:
native.setToolTip(item.tooltip)
if item.icon:
native.setImage(item.icon._impl.native)

item._impl.native.add(native)

native.setTarget_(self)
native.setAction_(SEL("onToolbarButtonPress:"))
except KeyError: # Separator items
pass

return native

@objc_method
def validateToolbarItem_(self, item) -> bool:
"""Confirm if the toolbar item should be enabled."""
try:
return self.impl._toolbar_items[str(item.itemIdentifier)].enabled
except KeyError: # pragma: nocover
return False

@objc_method
def onToolbarButtonPress_(self, obj) -> None:
"""Invoke the action tied to the toolbar button."""
item = self.impl._toolbar_items[str(obj.itemIdentifier)]
item.action()


class Scaffold:
def __init__(self, interface):
self.interface = interface
self.container = ControlledContainer(on_refresh=self.content_refreshed)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why break this into a separate class, rather than having Scaffold have both a container and a controller? I'm not seeing any particular benefits, other than longer attribute access chains...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementing SidebarScaffold and OptionScaffold requires the use of controllers for each individual 'tab' of content. So, if we have a class that conceptually bundles a view and a controller together, we can reuse it in future places, and when we do so we no longer have to keep track of content/controller separately when there's multiple content/controller pairs required. Doing ControlledContainer also maintains parity with iOS.

Would you prefer this type of refactor to be done in a later PR, though?

self.root_controller = self.container.controller
self._toolbar_items = {}
self._toolbar_commands = []
self.native_toolbar = None
self.toolbar_delegate = ToolbarDelegate.alloc().init()
self.toolbar_delegate.impl = self
self.toolbar_delegate.interface = self.interface

def __del__(self):
self.purge_toolbar()

@property
def current_container(self):
return self.container

def set_content(self, widget):
self.container.content = widget

@property
def title(self):
return self.container.controller.title

@title.setter
def title(self, value):
self.container.controller.title = value

def refresh(self):
if self.container.content:
self.container.content.interface.refresh()

@property
def toolbar_commands(self):
return self._toolbar_commands

# def notify_toolbar_change(self):
# window = self.interface.window
# if window is not None and getattr(window, "_impl", None) is not None:
# window._impl.update_toolbar(self)

def create_toolbar(self):
window = self.interface.window
self.purge_toolbar()

# Shouldn't happen in normal operations, but just in case
if window is None: # pragma: no cover
self.native_toolbar = None
self._toolbar_commands = []
return

self._toolbar_commands = []
if hasattr(window, "toolbar"):
self._toolbar_commands.extend(window.toolbar)

self._toolbar_items = {}
for cmd in self._toolbar_commands:
if isinstance(cmd, Command):
self._toolbar_items[toolbar_identifier(cmd)] = cmd

if self._toolbar_commands:
self.native_toolbar = NSToolbar.alloc().initWithIdentifier(
f"Toolbar-{id(self)}"
)
self.native_toolbar.setDelegate(self.toolbar_delegate)
else:
self.native_toolbar = None

if window.content:
window.content.refresh()

def purge_toolbar(self):
window = self.interface.window

# Defensive measure
if window is None: # pragma: no cover
return

while self._toolbar_items:
dead_items = []
_, cmd = self._toolbar_items.popitem()
# Only purge items associated with the current scaffold's
# toolbar delegate. This ensures proper cleanup.
for item_native in cmd._impl.native:
if (
isinstance(item_native, NSToolbarItem)
and item_native.target == self.toolbar_delegate
):
dead_items.append(item_native)

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

def content_refreshed(self, container):
# Apply the minimum size. This will autoresize the window if needed.
self.container.min_width = self.interface.content.layout.min_width
self.container.min_height = self.interface.content.layout.min_height
6 changes: 5 additions & 1 deletion cocoa/src/toga_cocoa/widgets/numberinput.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ def set_max_value(self, value):
self.native_stepper.maxValue = float(value)

def set_text_align(self, value):
self.native_input.alignment = NSTextAlignment(value)
if self.interface.window and self.has_focus():
# Drop focus if we're currently focussed, or else alignment setting
# will not work properly with Cocoa
self.interface.window._impl.native.makeFirstResponder(None)
self.native_input.setAlignment(NSTextAlignment(value))

def set_font(self, font):
self.native_input.font = font._impl.native
Expand Down
4 changes: 4 additions & 0 deletions cocoa/src/toga_cocoa/widgets/textinput.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ def set_placeholder(self, value):
self.native.cell.placeholderString = value

def set_text_align(self, value):
if self.interface.window and self.has_focus:
# Drop focus if we're currently focussed, or else alignment setting
# will not work properly with Cocoa
self.interface.window._impl.native.makeFirstResponder(None)
self.native.alignment = NSTextAlignment(value)
# The alert label should be on the trailing edge
if value == RIGHT:
Expand Down
Loading
Loading