diff --git a/changes/4048.feature.md b/changes/4048.feature.md new file mode 100644 index 0000000000..adb7efec25 --- /dev/null +++ b/changes/4048.feature.md @@ -0,0 +1 @@ +iOS MainWindows now support actions in its toolbar. diff --git a/docs/en/reference/api/mainwindow.md b/docs/en/reference/api/mainwindow.md index 327673b583..cdfff212d1 100644 --- a/docs/en/reference/api/mainwindow.md +++ b/docs/en/reference/api/mainwindow.md @@ -19,6 +19,18 @@ self.toga.App.main_window = main_window main_window.show() ``` +## Notes + +- On iOS: + - The system only utilizes the alpha channel for icons in toolbars. + - Toga does not currently provide mechanisms to access system icons, which iOS recommends, so icons should be iOS-compatible and used consistently within all scenes of your app. + - On iOS 26+, icons in different sections will have their "liquid glass" panes separated; a short distance will be inserted in previous versions. + - On iOS 26+, icons may also be moved to an overflow menu if there is not enough space to fit. It is best to avoid overflowing menus, which do not exist automatically on older systems. + - Icons are preferred to text in toolbar menus; an icon will be shown if it is provided, else the text. Icon and text will always have separated glass sections on iOS 26+. + - The iOS Human Interface Guidelines differentiate between normal and prominent actions. Any command with a text of Done, Save, or Submit will be automatically promoted to a prominent action, which has a separate glass pane on iOS 26+. + - iOS toolbar commands cannot be [disabled][toga.Command.enabled]; setting that property has no effect on toolbar behavior. + + ## Reference ::: toga.MainWindow diff --git a/iOS/src/toga_iOS/command.py b/iOS/src/toga_iOS/command.py index 6c906ab348..cb516ad15b 100644 --- a/iOS/src/toga_iOS/command.py +++ b/iOS/src/toga_iOS/command.py @@ -1,10 +1,36 @@ +from rubicon.objc import NSObject, objc_method, objc_property + from toga import Command as StandardCommand +# IMPLEMENTATION NOTES: +# iOS commands are represented differently in different +# contexts, e.g. created directly in the toolbar but using +# a UICommand in the menu bar on iPad. Therefore, this +# class does not expose the creation of a native object, +# which is handled in the context of each class where +# Commands are used. + +# However, places often hook up commands with a target +# and an action. Thus, an "invoker" class is defined, +# and an instance of it is exposed with each command. +# The "invoker" object should be used to hook up command +# handlers in appropriate locations. + + +class TogaCommandInvoker(NSObject): + interface = objc_property(object, weak=True) + + @objc_method + def executeCommand_(self, sender) -> None: + self.interface.action() + class Command: def __init__(self, interface): self.interface = interface self.native = [] + self.invoker = TogaCommandInvoker.alloc().init() + self.invoker.interface = self.interface @classmethod def standard(cls, app, id): diff --git a/iOS/src/toga_iOS/libs/uikit.py b/iOS/src/toga_iOS/libs/uikit.py index 6759b48f11..be54e9c255 100644 --- a/iOS/src/toga_iOS/libs/uikit.py +++ b/iOS/src/toga_iOS/libs/uikit.py @@ -156,6 +156,16 @@ class UIInterfaceOrientation(Enum): UIBarButtonItem = ObjCClass("UIBarButtonItem") +class UIBarButtonItemStyle(IntEnum): + Plain = 0 + # iOS 26+ + Prominent = 2 + # Deprecated in iOS 8, replace by Plain + Bordered = 1 + # Deprecated in iOS 26, replace by Prominent + Done = Prominent + + class UIBarButtonSystemItem(Enum): Done = 0 Cancel = 1 diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 79858b119b..a34ac7cbd7 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -1,4 +1,5 @@ from rubicon.objc import ( + SEL, Block, NSPoint, NSRect, @@ -6,12 +7,16 @@ objc_id, ) +from toga.command import Separator from toga.constants import WindowState from toga.types import Position, Size from toga_iOS.container import NavigationContainer, RootContainer from toga_iOS.images import nsdata_to_bytes from toga_iOS.libs import ( NSData, + UIBarButtonItem, + UIBarButtonItemStyle, + UIBarButtonSystemItem, UIColor, UIGraphicsImageRenderer, UIImage, @@ -236,6 +241,66 @@ def create_container(self): # NavigationContainer provides a titlebar for the window. self.container = NavigationContainer(on_refresh=self.content_refreshed) + def _create_separator(self): + return UIBarButtonItem.alloc().initWithBarButtonSystemItem( + UIBarButtonSystemItem.FixedSpace, + target=None, + action=None, + ) + def create_toolbar(self): - # No toolbar handling at present - pass + bar_items = [] + PROMINENT_COMMANDS = {"Done", "Save", "Submit"} + prev_group = None + for cmd in self.interface.toolbar: + if isinstance(cmd, Separator): + bar_items.append(self._create_separator()) + prev_group = None + else: + if prev_group is not None and prev_group != cmd.group: + bar_items.append(self._create_separator()) + prev_group = None + else: + prev_group = cmd.group + + command_style = ( + UIBarButtonItemStyle.Prominent + if cmd.text in PROMINENT_COMMANDS + else UIBarButtonItemStyle.Plain + ) + if cmd.icon: + # 2025-01-02: The documented size for a bar button item + # is 20x20, however, that results in the icons being + # displayed too small. If you render an icon in Apple's + # SF Symbols that is fairly square using native APIs, + # and then compare them to PNG exports of SF Symbols + # rendered using _as_size(30), you'll find that it is only + # then that they render a matching size. (notes: 1, this is + # illegal for production but permitted by markups per SF + # Symbols' license, and 2, our icon handling doesn't scale pro- + # portionally, so the icon chosen for this experiment must be + # fairly square.) + # The scaling part should also be handled by the system according + # to the documentation; but it is, in fact, not, and when + # large icons are supplied, they widen the button and only + # shows the centre of the image in a 20x20 region. + bar_item = UIBarButtonItem.alloc().initWithImage( + cmd.icon._impl._as_size(30), + style=command_style, + target=cmd._impl.invoker, + action=SEL("executeCommand:"), + ) + else: + bar_item = UIBarButtonItem.alloc().initWithTitle( + cmd.text, + style=command_style, + target=cmd._impl.invoker, + action=SEL("executeCommand:"), + ) + bar_item.title = cmd.text + bar_items.append(bar_item) + + # iOS displays in reverse to added order. + self.container.content_controller.navigationItem.rightBarButtonItems = ( + bar_items[::-1] + ) diff --git a/iOS/tests_backend/window.py b/iOS/tests_backend/window.py index 5ed05275b6..a8fc622620 100644 --- a/iOS/tests_backend/window.py +++ b/iOS/tests_backend/window.py @@ -1,8 +1,6 @@ import asyncio -import pytest - -from toga_iOS.libs import UIApplication, UIWindow +from toga_iOS.libs import UIApplication, UIBarButtonSystemItem, UIWindow from .dialogs import DialogsMixin from .probe import BaseProbe @@ -71,5 +69,30 @@ def top_bar_height(self): def instantaneous_state(self): return self.impl.get_window_state(in_progress_state=False) + def _toolbar_items(self): + navigation_item = self.impl.container.content_controller.navigationItem + return ( + navigation_item.rightBarButtonItems.reverseObjectEnumerator().allObjects() + ) + def has_toolbar(self): - pytest.skip("Toolbars not implemented on iOS") + return len(self._toolbar_items()) > 0 + + def assert_is_toolbar_separator(self, index, section=False): + assert ( + self._toolbar_items()[index].systemItem + == UIBarButtonSystemItem.FixedSpace.value + ) + + def assert_toolbar_item(self, index, label, tooltip, has_icon, enabled): + item = self._toolbar_items()[index] + assert item.title == label + # # UIKit does not expose tooltips; no assertion possible. + assert (item.image is not None) == has_icon + # # No way to disable things on UIKit; do not check for it. + + def press_toolbar_button(self, index): + item = self._toolbar_items()[index] + item.target.performSelectorOnMainThread( + item.action, withObject=item, waitUntilDone=True + ) diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 0c8b32c6df..32e390e25f 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -83,7 +83,6 @@ async def test_main_window_toolbar(app, main_window, main_window_probe): has_icon=True, enabled=True, ) - # Remove the toolbar main_window.toolbar.clear() await main_window_probe.redraw("Main window has no toolbar")