Skip to content

Implementation of Scaffolds layer for Apple platforms, with related fixes - #4605

Open
johnzhou721 wants to merge 63 commits into
beeware:scaffoldsfrom
johnzhou721:apple-scaffolds
Open

Implementation of Scaffolds layer for Apple platforms, with related fixes#4605
johnzhou721 wants to merge 63 commits into
beeware:scaffoldsfrom
johnzhou721:apple-scaffolds

Conversation

@johnzhou721

@johnzhou721 johnzhou721 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

I'm back on Scaffolds! Sorry for not getting this done before the Q2 deadline.

What's changed

This PR implements scaffolds for iOS and macOS. All the design and extra fixes are documented in #4605 (review) — lots of things only make sense when pointed to concrete code inline, so I figured I might post a separate comment with all the documentation of the design decisions made.

Most notably I moved toolbar handling to Scaffolds because future types of scaffolds will have more complex forms of toolbar declaration and moving toolbars to scaffolds will remove a lot more coordination logic in the future.

The changes in this PR make toga-core incompatible with the rest of the backends, but I will be fixing them later.

Validation

(This section is not written by AI. I figure it might be humorous to use these stereotypical title names, as in this case there really are additional manual testing needed and additional headings make navigation easier.)

Make sure these things work:

  • CI passing (core, iOS and macOS only testbed — rest can fail)
  • window and simpleapp example apps on macOS 26, iPhone and iPad 18 and 26.
    • On iPadOS 26, remove the Requires Full Screen key from Info.Plist of generated Xcode projects and rebuild to test that resizing window works, and that the contents are only inset if the window actually overlaps with the top status bar.
    • Make sure initial window size for window example app is set properly; window size should be retained even after changing content; changing content in PRESENTATION mode works.

PR Checklist:

  • I will abide by the BeeWare Code of Conduct
  • I have read and have followed the CONTRIBUTING.md file
  • This PR was generated or assisted using an AI tool

Assisted-by: GitHub Copilot, ChatGPT,

@johnzhou721
johnzhou721 marked this pull request as draft July 31, 2026 20:11
@johnzhou721

Copy link
Copy Markdown
Contributor Author

Pausing this for a bit as I'm working on #4628 at this moment.

@johnzhou721 johnzhou721 left a comment

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.

Some design decisions / small driveby cleanups I made that I wanted to flag for discussion. Most of the driveby cleanups are co-morbid issues that happens while testing scaffolds, and I've documented all the causes from my investigations to the best of my abilities.

I've taken my best effort to explain these below, but there's a chance I might mis-explain or miss something. Feel free to flag all other geneeral inconsistencies.

I chose to put iOS and macOS implementations together in 1 PR because it's logical since they're both Apple, and to also avoid the possibility of making any desktop/mobile-specific assumptions in the first few implementations of scaffolds. If you'd prefer to split this into iOS and macOS pieces, let me know.

Comment thread cocoa/src/toga_cocoa/scaffolds/base.py Outdated
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.

Comment thread iOS/src/toga_iOS/window.py
Comment on lines -297 to -302
if frame.size.width < min_width and frame.size.height < min_height:
self.set_size((min_width, min_height))
elif frame.size.width < min_width:
self.set_size((min_width, frame.size.height))
elif frame.size.height < min_height:
self.set_size((frame.size.width, min_height))

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.

FWIW: This piece of code was gotten rid of when it was moved to scaffolds/base because the constraints set on the container will enforce this automatically if the existing window size is small, so this was just unneccessary.

Comment thread cocoa/src/toga_cocoa/window.py
Comment on lines +201 to +204
if self.get_window_state() == WindowState.PRESENTATION:
restore_presentation = True
# This is instaneous so yay!!!
self.set_window_state(WindowState.NORMAL)

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.

This (along with restoring back to PRESENTAITON) at the end was required because PRESENTATION operates on the underlying container, not on the window object itself. A scaffold assignment is a change in container, so we must operate in non-PRESENTATION states when we set scaffold, and then restore PRESENTATION later using the newer container.

Fortunately the way we implement PRESENTATION implies that it's synchronous so saves a lot of headaches here.

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.

Or... we could prohibit changing the scaffold while in presentation mode...

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.

The logic here isn't extremely complex, and this is more of a macOS-specific quirk of how we implement PRESENTATION. So I'm inclined to just allow this possibility.

I'll leave the final decision to you on this.

Comment on lines +144 to +149
self.native.bind(
NSTitleBinding,
toObject=self.native,
withKeyPath="contentViewController.title",
options=None,
)

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.

Some controllers like NSTabViewController will make the controller's title property that of the currently selected tab, so making controllers manage window content will be convenient when we implement other scaffolds later. This also makes controller/title behavior more symmetric with iOS backend.

Comment thread iOS/src/toga_iOS/constraints.py Outdated
Comment on lines +49 to +68
def __del__(self): # pragma: nocover
self._remove_constraints()
# If this gets called on the other threads than hilarity ensues.
# So we delegate cleanup to another non-self-bound function and
# use Weakrefs for everything.
try:
self.widget.interface.app.loop.call_soon_threadsafe(
partial(
_remove_constraints,
ref(self.container),
self.constraints_created,
[
ref(self.width_constraint),
ref(self.height_constraint),
ref(self.left_constraint),
ref(self.top_constraint),
],
)
)
except Exception:
pass

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 have no idea why _remove_constraints used to be reliable, but now it seems like with scaffolds introduced this del happens on the test thread extremely often, so I've changed the cleanup flow here to async call into the main thread.

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.

This makes me very nervous. Having core logic be substantially more complex so that the testbed doesn't crash is a bit of an anti pattern.

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 understand and am too concerned about the fact that this is significant complexity, esp. given the fact that Constraints is used everywhere and breaks a lot of things if mishandled.

Hrm... but Python itself does not guarantee that del cleanup logic only runs on a certain thread, so this seems like a safer approach to me.

I had tried to work out why this del was called on the test thread more often before adding this workaround, but at this point I'm out of ideas. Suggestions to simplify this would be helpful here.

Comment thread iOS/tests_backend/hardware/camera.py
Comment on lines 729 to +732
# Alter both height and width to exceed window size at once
box3 = toga.Box(style=Pack(background_color=LIGHTBLUE, width=300, height=90))
second_window.content.add(box3)
with box1.style.batch_apply():
box1.style.width = 300
box2.style.height = 290

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.

Minor unrelated simplification.

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.

Not sure I understand how this is a simplification... if only because it's one more line... but also it appears to be doing something completely different, with different box sizes.

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.

So before this piece of code was changed, we used another subbox of second_window.content in order to increase both height and width at the same time. The intent was that the window's content size will have both its width and height increased.

I used batch_apply here to do this explicitly, so it's conceptually simplified as we do not have to add anoteher widget into hte window to force the content size to expand.

If you'd rather not do this, I can revert this small change and there'll be few impact.

Comment on lines 226 to 231
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_input.alignment = NSTextAlignment(value)

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.

Setting a contentViewController on window now causes Cocoa to refocus the first focussable widget on the window when a new scaffold is set. I think this is the correct behavior; the same occured previously when one showed a window, and the initial widget is focusseed.

Now, this causes some complications with testing, and the result was that when we test input widgets, the input widgets are now focused even on alignment tests. This revealed a bug in the Cocoa backend, since Cocoa does not allow focussed widgets to change alignment. I've thus made set_text_align defocus first if neccessary across the input widgets we have in Toga. I made the choice to defocus because changing text alignment when someone is editing in an input is likely not good UI anyways, and so there's no good expectation that when we change display settings of the widget it should stay focussed.

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.

This strikes me more as a feature/bug of the testbed, rather than the widget.

When you say that changing alignment "doesn't work" - do you mean that the new alignment isn't applied at all? Or it isn't applied until focus is lost? Does it refuse to apply the property value? Raise an error?

My main concern here is that it isn't at all intuitive to me that from an external user's perspective, changing alignment on a text widget would cause focus to be lost. If we're going to drop focus, it seems to me like we should be reclaiming it once the alignment has been set.

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.

When you say that changing alignment "doesn't work" - do you mean that the new alignment isn't applied at all? Or it isn't applied until focus is lost? Does it refuse to apply the property value? Raise an error?

Yep, new alignment isn't applied at all, and the alignment value remains the old one.

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've added and pushed hte code to reapply focus.

Refactor _remove_constraints function for clarity and reliability.
@johnzhou721

Copy link
Copy Markdown
Contributor Author

Sorry for the extra noise in pushing commits; I'm trying to track down an intermittent CI issue, and I'm experimenting with various ways to fix it. My iOS simulator environment also got borked locally today and I've only managed to fix it now, so there were some extra commits fixing issues in my various attempts.

@johnzhou721

Copy link
Copy Markdown
Contributor Author

I’m asking for another review here… having finally debugged the CI instabilities, I’d like for y’all to run the macOS and iOS test beds each about at least 5 times to rule out to possibility or them reoccurring.

You may see that there’s a lot of irrelevant changes in this PR; this is because the branch scaffolds it out of date. Therefore. Please merge main into scaffolds on the BeeWare upstream repo before doing anything else here.

@phildini phildini left a comment

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.

Thank you for your time in trying to move the Scaffold discussion forward.

The combination of:

  • CI script changes
  • towncrier .md changes
  • overall lint / typo fixes

makes this PR extremely hard to review properly.

A clean rebase with just your changes makes this PR far more likely to get reviewed, although currently there's so much extra that it's hard to tell if your changes meet the spirit of #4271.

Rebase drift is a hard thing to manage; it is in fact acceptable for you to move your changes to a clean branch, close this PR, and open a new one, if that's what you would prefer.

@johnzhou721

johnzhou721 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for your time in trying to move the Scaffold discussion forward.

The combination of:

  • CI script changes
  • towncrier .md changes
  • overall lint / typo fixes

makes this PR extremely hard to review properly.

A clean rebase with just your changes makes this PR far more likely to get reviewed, although currently there's so much extra that it's hard to tell if your changes meet the spirit of #4271.

Rebase drift is a hard thing to manage; it is in fact acceptable for you to move your changes to a clean branch, close this PR, and open a new one, if that's what you would prefer.

Hey @phildini,

Thanks for taking the time to look at the PR. This stack of changes on here is intentional, and I apologize for not explaining so clearly.

This PR is not made to the default branch of Toga. This PR is made to a branch named scaffolds in beeware/toga, which is for a larger-scale refactor in Toga to introduce the Scaffold layer as seen in #4271.

The scaffolds branch is currently outdated, and strays behind of the latest commits in main.

Now, I merged the default branch of Toga, main, into this PR because there are newer things in main that conflicts with this PR. That is what introduced the extra commits here, and from my understanding it is not caused by rebasing, and I think it is because of the fact that the scaffolds branch is outdated against main. Merging main into scaffolds in this upstream repository beeware/toga will make the scaffolds branch up to date, which implies that these commits will go out of the diff between this PR and the scaffolds branch.

In conclusion, I suggest merging the branch main into the branch scaffolds on upstream Toga, as the scaffolds branch is eventually going to be merged into main anyways. I'd preferrably have Russ confirm this if we want to move forward by directly basing on top of the scaffolds branch rather than merging main into scaffolds before we proceed with further potentially conflicting changes, as he was the one who created the scaffolds brnach to coordinate this work.

Thank you. Let me know if you need any clarifications.

@johnzhou721

Copy link
Copy Markdown
Contributor Author

(FWIW: Having Russ confirm what to do with the merging/rebasing is merely a preferrance. If any core team member deems that in this situation merging into the current scaffolds branch and then catching up with the drift from main later is more helpful, then I will follow their requests. I undestand that I am not in an authority to decide that a specific core team member needs to response, do not intend to imply so, and apologizes for any wording in the previous messages that appears to do so.)

@johnzhou721

johnzhou721 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@phildini,

[Edited to heavily simplify]

I've followed through with your original requests and rebased on top of the older branch scaffolds for now, and has re-requested a review from you (If your original review was solely to flag the rebase issue, no need to feel obliged to leave another full review, though).

I thought about this again and now agrees with your approach. So sorry for the previous noise.

Thank you!

EDIT: Still ready for review after additional commits; those were commits I made after the merge cherry-picked here.

@freakboy3742

Copy link
Copy Markdown
Member

I've followed through with your original requests and rebased on top of the older branch scaffolds for now, and has re-requested a review from you (If your original review was solely to flag the rebase issue, no need to feel obliged to leave another full review, though).

I merged the scaffold branch with main late last week; I've just updated again following the weekly dependabot updates.

Generally speaking, we prefer merge commits over rebasing because rebasing loses the context for any historical review comments.

@johnzhou721

Copy link
Copy Markdown
Contributor Author

I've followed through with your original requests and rebased on top of the older branch scaffolds for now, and has re-requested a review from you (If your original review was solely to flag the rebase issue, no need to feel obliged to leave another full review, though).

I merged the scaffold branch with main late last week; I've just updated again following the weekly dependabot updates.

Generally speaking, we prefer merge commits over rebasing because rebasing loses the context for any historical review comments.

Yep, I did use merge commits but because scaffolds was not up to date the extra commits showed.

I've repushed the state of the PR when I made the request; now the diff should still be clean, so @freakboy3742 when you get a chance another look at this would be super appreciated.

@johnzhou721

Copy link
Copy Markdown
Contributor Author

Hmm... seems like scaffolds still shows 85 commits behind main on https://github.com/beeware/toga/tree/scaffolds. Have I missed something?

@freakboy3742

Copy link
Copy Markdown
Member

Hmm... seems like scaffolds still shows 85 commits behind main on https://github.com/beeware/toga/tree/scaffolds. Have I missed something?

You haven't missed anything - the problem exists between the keyboard and chair at my end. I merged the branch, and pushed it to freakboy3742/scaffolds, not beeware/scaffolds.

I've just pushed to beeware/scaffolds.

@johnzhou721

johnzhou721 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@freakboy3742,

Thanks for the clarification, and no worries about the problem!

For reference, I had to merge 3 times before getting upstream main merged into this PR 🤦, and even then something went astray as it conflicted with the new scaffolds branch you merged... not your fault, though, because I've already merged 3 different hashes somehow I probably messed up somewhere, but things will be squashed anyways, so I assumed if the diff is fine the intermediate commits does not matter. But if I'm wrong at this end, feel free to point out any issues.

But if there are no such issues, to be explicit, this is still ready for you to take a look at at your convenince, as the conflict resolution did not change any funcitonal content of this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants