From c5535683dca7343a15a3a9967716cfc541281f11 Mon Sep 17 00:00:00 2001 From: William Baker Date: Tue, 18 Mar 2025 19:01:45 -0400 Subject: [PATCH 1/2] Scaffold out different menu implementations --- Sources/AppKitBackend/AppKitBackend.swift | 1 + Sources/Gtk3Backend/Gtk3Backend.swift | 1 + Sources/GtkBackend/GtkBackend.swift | 1 + Sources/SwiftCrossUI/Backend/AppBackend.swift | 21 ++++++++- .../Backend/MenuImplementationStyle.swift | 18 ++++++++ Sources/SwiftCrossUI/Views/Menu.swift | 46 +++++++++++++------ Sources/WinUIBackend/WinUIBackend.swift | 1 + 7 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 Sources/SwiftCrossUI/Backend/MenuImplementationStyle.swift diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index b808d392..1ded5ede 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -21,6 +21,7 @@ public final class AppKitBackend: AppBackend { public let requiresToggleSwitchSpacer = false public let defaultToggleStyle = ToggleStyle.button public let requiresImageUpdateOnScaleFactorChange = false + public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public var scrollBarWidth: Int { // We assume that all scrollers have their controlSize set to `.regular` by default. diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 900000f4..9b96522e 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -30,6 +30,7 @@ public final class Gtk3Backend: AppBackend { public let scrollBarWidth = 0 public let defaultToggleStyle = ToggleStyle.button public let requiresImageUpdateOnScaleFactorChange = true + public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover var gtkApp: Application diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 51490788..60d7f968 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -30,6 +30,7 @@ public final class GtkBackend: AppBackend { public let requiresToggleSwitchSpacer = false public let defaultToggleStyle = ToggleStyle.button public let requiresImageUpdateOnScaleFactorChange = false + public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover var gtkApp: Application diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index a3b79d07..8847a559 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -84,6 +84,8 @@ public protocol AppBackend { /// manually rescale the image meaning that it must get rescaled when the /// scale factor changes. var requiresImageUpdateOnScaleFactorChange: Bool { get } + /// How the backend handles menu rendering. Affects which menu-related methods are called. + var menuImplementationStyle: MenuImplementationStyle { get } /// Often in UI frameworks (such as Gtk), code is run in a callback /// after starting the app, and hence this generic root window creation @@ -343,6 +345,14 @@ public protocol AppBackend { action: @escaping () -> Void, environment: EnvironmentValues ) + /// Sets a button's label and menu. May not be called depending on the value of + /// ``menuImplementationStyle``. + func updateButton( + _ button: Widget, + label: String, + menu: Menu, + environment: EnvironmentValues + ) /// Creates a labelled toggle that is either on or off. Predominantly used by /// ``Toggle``. @@ -441,7 +451,8 @@ public protocol AppBackend { content: ResolvedMenu, environment: EnvironmentValues ) - /// Shows the popover menu at a position relative to the given widget. + /// Shows the popover menu at a position relative to the given widget. May not be called + /// depending on the value of ``menuImplementationStyle``. func showPopoverMenu( _ menu: Menu, at position: SIMD2, @@ -668,6 +679,14 @@ extension AppBackend { ) { todo() } + public func updateButton( + _ button: Widget, + label: String, + menu: Menu, + environment: EnvironmentValues + ) { + todo() + } public func createToggle() -> Widget { todo() diff --git a/Sources/SwiftCrossUI/Backend/MenuImplementationStyle.swift b/Sources/SwiftCrossUI/Backend/MenuImplementationStyle.swift new file mode 100644 index 00000000..c165363f --- /dev/null +++ b/Sources/SwiftCrossUI/Backend/MenuImplementationStyle.swift @@ -0,0 +1,18 @@ +/// How a backend implements popover menus. +/// +/// Regardless of implementation style, backends are expected to implement +/// ``AppBackend/createPopoverMenu()``, ``AppBackend/updatePopoverMenu(_:content:environment:)``, +/// and ``AppBackend/updateButton(_:label:action:environment:)``. +public enum MenuImplementationStyle { + /// The backend can show popover menus arbitrarily. + /// + /// Backends that use this style must implement + /// ``AppBackend/showPopoverMenu(_:at:relativeTo:closeHandler:)``. For these backends, + /// ``AppBackend/createPopoverMenu()`` is not called until after the button is tapped. + case dynamicPopover + /// The backend requires menus to be constructed and passed to buttons ahead-of-time. + /// + /// Backends that use this style must implement + /// ``AppBackend/updateButton(_:label:menu:environment:)``. + case menuButton +} diff --git a/Sources/SwiftCrossUI/Views/Menu.swift b/Sources/SwiftCrossUI/Views/Menu.swift index 0401740f..9cb1e80a 100644 --- a/Sources/SwiftCrossUI/Views/Menu.swift +++ b/Sources/SwiftCrossUI/Views/Menu.swift @@ -71,27 +71,45 @@ public struct Menu: TypeSafeView { size.x = buttonWidth ?? size.x let content = resolve().content - backend.updateButton( - widget, - label: label, - action: { - let menu = backend.createPopoverMenu() + switch backend.menuImplementationStyle { + case .dynamicPopover: + backend.updateButton( + widget, + label: label, + action: { + let menu = backend.createPopoverMenu() + children.menu = menu + backend.updatePopoverMenu( + menu, + content: content, + environment: environment + ) + backend.showPopoverMenu(menu, at: SIMD2(0, size.y + 2), relativeTo: widget) + { + children.menu = nil + } + }, + environment: environment + ) + + if !dryRun { + backend.setSize(of: widget, to: size) + children.updateMenuIfShown( + content: content, environment: environment, backend: backend) + } + case .menuButton: + let menu = children.menu as? Backend.Menu ?? backend.createPopoverMenu() children.menu = menu backend.updatePopoverMenu( menu, content: content, environment: environment ) - backend.showPopoverMenu(menu, at: SIMD2(0, size.y + 2), relativeTo: widget) { - children.menu = nil - } - }, - environment: environment - ) + backend.updateButton(widget, label: label, menu: menu, environment: environment) - if !dryRun { - backend.setSize(of: widget, to: size) - children.updateMenuIfShown(content: content, environment: environment, backend: backend) + if !dryRun { + backend.setSize(of: widget, to: size) + } } return ViewUpdateResult.leafView(size: ViewSize(fixedSize: size)) diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 9dfd0472..3365e46a 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -38,6 +38,7 @@ public final class WinUIBackend: AppBackend { public let requiresToggleSwitchSpacer = false public let defaultToggleStyle = ToggleStyle.button public let requiresImageUpdateOnScaleFactorChange = false + public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public var scrollBarWidth: Int { 12 From ebffe32132d6c50193964818b98df73e6055ddc2 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Tue, 18 Mar 2025 20:36:52 -0400 Subject: [PATCH 2/2] Implement Menu in UIKitBackend --- Sources/SwiftCrossUI/Views/Menu.swift | 9 ++- .../UIKitBackend/UIKitBackend+Control.swift | 32 ++++++--- Sources/UIKitBackend/UIKitBackend+Menu.swift | 66 +++++++++++++++++++ Sources/UIKitBackend/UIKitBackend.swift | 51 ++++++++++++-- 4 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 Sources/UIKitBackend/UIKitBackend+Menu.swift diff --git a/Sources/SwiftCrossUI/Views/Menu.swift b/Sources/SwiftCrossUI/Views/Menu.swift index 9cb1e80a..22fa3178 100644 --- a/Sources/SwiftCrossUI/Views/Menu.swift +++ b/Sources/SwiftCrossUI/Views/Menu.swift @@ -1,11 +1,9 @@ -public struct Menu: TypeSafeView { +public struct Menu { public var label: String public var items: [MenuItem] var buttonWidth: Int? - public var body = EmptyView() - public init(_ label: String, @MenuItemsBuilder items: () -> [MenuItem]) { self.label = label self.items = items() @@ -34,6 +32,11 @@ public struct Menu: TypeSafeView { } ) } +} + +@available(iOS 14, macCatalyst 14, tvOS 17, *) +extension Menu: TypeSafeView { + public var body: EmptyView { return EmptyView() } func children( backend: Backend, diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index e37fa81a..cd5ccfe2 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -2,7 +2,15 @@ import SwiftCrossUI import UIKit final class ButtonWidget: WrapperWidget { - var onTap: (() -> Void)? + private let event: UIControl.Event + + var onTap: (() -> Void)? { + didSet { + if oldValue == nil { + child.addTarget(self, action: #selector(buttonTapped), for: event) + } + } + } @objc func buttonTapped() { @@ -11,7 +19,6 @@ final class ButtonWidget: WrapperWidget { init() { let type: UIButton.ButtonType - let event: UIControl.Event #if os(tvOS) type = .system event = .primaryActionTriggered @@ -20,7 +27,6 @@ final class ButtonWidget: WrapperWidget { event = .touchUpInside #endif super.init(child: UIButton(type: type)) - child.addTarget(self, action: #selector(buttonTapped), for: event) } } @@ -177,14 +183,11 @@ extension UIKitBackend { ButtonWidget() } - public func updateButton( - _ button: Widget, - label: String, - action: @escaping () -> Void, + func setButtonTitle( + _ buttonWidget: ButtonWidget, + _ label: String, environment: EnvironmentValues ) { - let buttonWidget = button as! ButtonWidget - // tvOS's buttons change foreground color when focused. If we set an // attributed string for `.normal` we also have to set another for // `.focused` with a colour that's readable on a white background. @@ -204,6 +207,17 @@ extension UIKitBackend { for: .normal ) #endif + } + + public func updateButton( + _ button: Widget, + label: String, + action: @escaping () -> Void, + environment: EnvironmentValues + ) { + let buttonWidget = button as! ButtonWidget + + setButtonTitle(buttonWidget, label, environment: environment) buttonWidget.onTap = action } diff --git a/Sources/UIKitBackend/UIKitBackend+Menu.swift b/Sources/UIKitBackend/UIKitBackend+Menu.swift new file mode 100644 index 00000000..e952e74b --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+Menu.swift @@ -0,0 +1,66 @@ +import SwiftCrossUI +import UIKit + +extension UIKitBackend { + public final class Menu { + var uiMenu: UIMenu? + } + + public func createPopoverMenu() -> Menu { + return Menu() + } + + static func transformMenu( + content: ResolvedMenu, + label: String, + identifier: UIMenu.Identifier? = nil + ) -> UIMenu { + let children = content.items.map { (item) -> UIMenuElement in + switch item { + case let .button(label, action): + if let action { + UIAction(title: label) { _ in action() } + } else { + UIAction(title: label, attributes: .disabled) { _ in } + } + case let .submenu(submenu): + transformMenu(content: submenu.content, label: submenu.label) + } + } + + return UIMenu(title: label, identifier: identifier, children: children) + } + + public func updatePopoverMenu( + _ menu: Menu, content: ResolvedMenu, environment _: EnvironmentValues + ) { + menu.uiMenu = UIKitBackend.transformMenu(content: content, label: "") + } + + public func updateButton( + _ button: Widget, + label: String, + menu: Menu, + environment: EnvironmentValues + ) { + if #available(iOS 14, macCatalyst 14, tvOS 17, *) { + let buttonWidget = button as! ButtonWidget + setButtonTitle(buttonWidget, label, environment: environment) + buttonWidget.child.menu = menu.uiMenu + buttonWidget.child.showsMenuAsPrimaryAction = true + } else { + preconditionFailure("Current OS is too old to support menu buttons.") + } + } + + public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) { + #if targetEnvironment(macCatalyst) + let appDelegate = UIApplication.shared.delegate as! ApplicationDelegate + appDelegate.menu = submenus + #else + // Once keyboard shortcuts are implemented, it might be possible to do them on more + // platforms than just Mac Catalyst. For now, this is a no-op. + print("UIKitBackend: ignoring \(#function) call") + #endif + } +} diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index 0b14498a..cdcdce59 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -14,6 +14,7 @@ public final class UIKitBackend: AppBackend { public let defaultPaddingAmount = 15 public let requiresToggleSwitchSpacer = true public let defaultToggleStyle = ToggleStyle.switch + public let menuImplementationStyle = MenuImplementationStyle.menuButton // TODO: When tables are supported, update these public let defaultTableRowContentHeight = -1 @@ -110,11 +111,6 @@ public final class UIKitBackend: AppBackend { public func show(widget: Widget) { } - - // TODO: Menus - public typealias Menu = Never - public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) { - } } extension App { @@ -160,6 +156,8 @@ open class ApplicationDelegate: UIResponder, UIApplicationDelegate { } } + var menu: [ResolvedMenu.Submenu] = [] + public required override init() { super.init() } @@ -215,6 +213,49 @@ open class ApplicationDelegate: UIResponder, UIApplicationDelegate { return true } + + /// Map a menu's label to its identifier. + /// + /// The commands API only gives control over the label of each menu. Override this method if + /// you also need to control the menus' identifiers. + /// + /// This method is only used on Mac Catalyst. + open func mapMenuIdentifier(_ label: String) -> UIMenu.Identifier { + switch label { + case "File": .file + case "Edit": .edit + case "View": .view + case "Window": .window + case "Help": .help + default: + if let bundleId = Bundle.main.bundleIdentifier { + .init(rawValue: "\(bundleId).\(label)") + } else { + .init(rawValue: label) + } + } + } + + /// Asks the receiving responder to add and remove items from a menu system. + /// + /// When targeting Mac Catalyst, you should call `super.buildMenu(with: builder)` at some + /// point in your implementation. If you do not, then calls to + /// ``SwiftCrossUI/Scene/commands(_:)`` will have no effect. + open override func buildMenu(with builder: any UIMenuBuilder) { + guard builder.system == .main else { return } + + for submenu in menu { + let menuIdentifier = mapMenuIdentifier(submenu.label) + let menu = UIKitBackend.transformMenu( + content: submenu.content, label: submenu.label, identifier: menuIdentifier) + + if builder.menu(for: menuIdentifier) == nil { + builder.insertChild(menu, atEndOfMenu: .root) + } else { + builder.replace(menu: menuIdentifier, with: menu) + } + } + } } /// The root class for scene delegates of SwiftCrossUI apps.