diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 69de82d110b..25df375fd4d 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -208,9 +208,10 @@ struct WindowingApp: App { .commands { CommandMenu("Demo menu") { Button("Menu item") {} - Toggle("Toggle", active: $toggle) + Divider() + Menu("Submenu") { Button("Item 1") {} Button("Item 2") {} diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 4e8177c6d4f..e1f8712051c 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -167,6 +167,8 @@ public final class AppKitBackend: AppBackend { renderedItem.target = wrappedAction return renderedItem + case .separator: + return NSCustomMenuItem.separator() case .submenu(let submenu): return renderSubmenu(submenu) } diff --git a/Sources/Gtk/Utility/GMenu.swift b/Sources/Gtk/Utility/GMenu.swift index 00f8d35e8d6..6fe6f1c9c65 100644 --- a/Sources/Gtk/Utility/GMenu.swift +++ b/Sources/Gtk/Utility/GMenu.swift @@ -18,4 +18,12 @@ public class GMenu { UnsafeMutablePointer(content.pointer) ) } + + public func appendSection(label: String?, content: GMenu) { + g_menu_append_section( + pointer, + label, + UnsafeMutablePointer(content.pointer) + ) + } } diff --git a/Sources/Gtk3/Utility/GMenu.swift b/Sources/Gtk3/Utility/GMenu.swift index fc4369211d3..f1dc5a157de 100644 --- a/Sources/Gtk3/Utility/GMenu.swift +++ b/Sources/Gtk3/Utility/GMenu.swift @@ -18,4 +18,12 @@ public class GMenu { UnsafeMutablePointer(content.pointer) ) } + + public func appendSection(label: String?, content: GMenu) { + g_menu_append_section( + pointer, + label, + UnsafeMutablePointer(content.pointer) + ) + } } diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 542476e1ea8..c436f50f8fc 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -228,7 +228,9 @@ public final class Gtk3Backend: AppBackend { actionNamespace: String, actionPrefix: String? ) -> GMenu { - let model = GMenu() + var currentSection = GMenu() + var previousSections: [GMenu] = [] + for (i, item) in menu.items.enumerated() { let actionName = if let actionPrefix { @@ -243,15 +245,27 @@ public final class Gtk3Backend: AppBackend { actionMap.addAction(GSimpleAction(name: actionName, action: action)) } - model.appendItem(label: label, actionName: "\(actionNamespace).\(actionName)") + currentSection.appendItem( + label: label, + actionName: "\(actionNamespace).\(actionName)" + ) case .toggle(let label, let value, let onChange): actionMap.addAction( GSimpleAction(name: actionName, state: value, action: onChange) ) - model.appendItem(label: label, actionName: "\(actionNamespace).\(actionName)") + currentSection.appendItem( + label: label, + actionName: "\(actionNamespace).\(actionName)" + ) + case .separator: + // GTK[3] doesn't have explicit separators per se, but instead deals with + // sections (actually quite similar to what you can do in SwiftUI with the + // Section view). It'll automatically draw separators between sections. + previousSections.append(currentSection) + currentSection = GMenu() case .submenu(let submenu): - model.appendSubmenu( + currentSection.appendSubmenu( label: submenu.label, content: renderMenu( submenu.content, @@ -262,7 +276,17 @@ public final class Gtk3Backend: AppBackend { ) } } - return model + + if previousSections.isEmpty { + // There are no dividers; just return the current section to keep the menu tree flat. + return currentSection + } else { + let model = GMenu() + for section in previousSections + [currentSection] { + model.appendSection(label: nil, content: section) + } + return model + } } private func renderMenuBar(_ submenus: [ResolvedMenu.Submenu]) -> GMenu { diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index d074dc532b7..650cbbf743d 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -263,7 +263,9 @@ public final class GtkBackend: AppBackend { actionNamespace: String, actionPrefix: String? ) -> GMenu { - let model = GMenu() + var currentSection = GMenu() + var previousSections: [GMenu] = [] + for (i, item) in menu.items.enumerated() { let actionName = if let actionPrefix { @@ -278,15 +280,27 @@ public final class GtkBackend: AppBackend { actionMap.addAction(GSimpleAction(name: actionName, action: action)) } - model.appendItem(label: label, actionName: "\(actionNamespace).\(actionName)") + currentSection.appendItem( + label: label, + actionName: "\(actionNamespace).\(actionName)" + ) case .toggle(let label, let value, let onChange): actionMap.addAction( GSimpleAction(name: actionName, state: value, action: onChange) ) - model.appendItem(label: label, actionName: "\(actionNamespace).\(actionName)") + currentSection.appendItem( + label: label, + actionName: "\(actionNamespace).\(actionName)" + ) + case .separator: + // GTK[3] doesn't have explicit separators per se, but instead deals with + // sections (actually quite similar to what you can do in SwiftUI with the + // Section view). It'll automatically draw separators between sections. + previousSections.append(currentSection) + currentSection = GMenu() case .submenu(let submenu): - model.appendSubmenu( + currentSection.appendSubmenu( label: submenu.label, content: renderMenu( submenu.content, @@ -297,7 +311,17 @@ public final class GtkBackend: AppBackend { ) } } - return model + + if previousSections.isEmpty { + // There are no dividers; just return the current section to keep the menu tree flat. + return currentSection + } else { + let model = GMenu() + for section in previousSections + [currentSection] { + model.appendSection(label: nil, content: section) + } + return model + } } private func renderMenuBar(_ submenus: [ResolvedMenu.Submenu]) -> GMenu { diff --git a/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift b/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift index 981415443aa..a9ee4dbbb36 100644 --- a/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift +++ b/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift @@ -25,6 +25,8 @@ public struct ResolvedMenu { /// - value: The toggle's current state. /// - onChange: Called whenever the user changes the toggle's state. case toggle(_ label: String, _ value: Bool, onChange: @MainActor (Bool) -> Void) + /// A section separator. + case separator /// A named submenu. case submenu(Submenu) } diff --git a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift index fc14fd0422a..583b0bc9823 100644 --- a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift @@ -17,6 +17,10 @@ public struct MenuItemsBuilder { [.toggle(first)] } + public static func buildPartialBlock(first: Divider) -> [MenuItem] { + [.separator(first)] + } + public static func buildPartialBlock(first: Menu) -> [MenuItem] { [.submenu(first)] } @@ -52,6 +56,13 @@ public struct MenuItemsBuilder { accumulated + buildPartialBlock(first: next) } + public static func buildPartialBlock( + accumulated: [MenuItem], + next: Divider + ) -> [MenuItem] { + accumulated + buildPartialBlock(first: next) + } + public static func buildPartialBlock( accumulated: [MenuItem], next: Menu diff --git a/Sources/SwiftCrossUI/Views/Divider.swift b/Sources/SwiftCrossUI/Views/Divider.swift index f57182703b8..f4453bb1c8d 100644 --- a/Sources/SwiftCrossUI/Views/Divider.swift +++ b/Sources/SwiftCrossUI/Views/Divider.swift @@ -1,7 +1,7 @@ /// A divider that expands along the minor axis of the containing stack layout /// (or horizontally otherwise). In dark mode it's white with 10% opacity, and /// in light mode it's black with 10% opacity. -public struct Divider: View { +public struct Divider: View, Sendable { @Environment(\.colorScheme) var colorScheme @Environment(\.layoutOrientation) var layoutOrientation diff --git a/Sources/SwiftCrossUI/Views/Menu.swift b/Sources/SwiftCrossUI/Views/Menu.swift index 4454175a589..3939a9ed1d2 100644 --- a/Sources/SwiftCrossUI/Views/Menu.swift +++ b/Sources/SwiftCrossUI/Views/Menu.swift @@ -38,6 +38,8 @@ public struct Menu: Sendable { toggle.active.wrappedValue, onChange: { toggle.active.wrappedValue = $0 } ) + case .separator: + .separator case .submenu(let submenu): .submenu(submenu.resolve()) } diff --git a/Sources/SwiftCrossUI/Views/MenuItem.swift b/Sources/SwiftCrossUI/Views/MenuItem.swift index 0d1db8a775a..406f565be70 100644 --- a/Sources/SwiftCrossUI/Views/MenuItem.swift +++ b/Sources/SwiftCrossUI/Views/MenuItem.swift @@ -3,5 +3,6 @@ public enum MenuItem: Sendable { case button(Button) case text(Text) case toggle(Toggle) + case separator(Divider) case submenu(Menu) } diff --git a/Sources/UIKitBackend/UIKitBackend+Menu.swift b/Sources/UIKitBackend/UIKitBackend+Menu.swift index 7f0ad57b66d..62d0381cce0 100644 --- a/Sources/UIKitBackend/UIKitBackend+Menu.swift +++ b/Sources/UIKitBackend/UIKitBackend+Menu.swift @@ -16,20 +16,43 @@ extension UIKitBackend { label: String, identifier: UIMenu.Identifier? = nil ) -> UIMenu { - let children = content.items.map { (item) -> UIMenuElement in + var currentSection: [UIMenuElement] = [] + var previousSections: [[UIMenuElement]] = [] + + for item in content.items { switch item { case .button(let label, let action): - if let action { + let uiAction = if let action { UIAction(title: label) { _ in action() } } else { UIAction(title: label, attributes: .disabled) { _ in } } + currentSection.append(uiAction) case .toggle(let label, let value, let onChange): - UIAction(title: label, state: value ? .on : .off) { action in - onChange(!action.state.isOn) - } + currentSection.append( + UIAction(title: label, state: value ? .on : .off) { action in + onChange(!action.state.isOn) + } + ) + case .separator: + // UIKit doesn't have explicit separators per se, but instead deals with + // sections (actually quite similar to what you can do in SwiftUI with the + // Section view). It'll automatically draw separators between sections. + previousSections.append(currentSection) + currentSection = [] case .submenu(let submenu): - buildMenu(content: submenu.content, label: submenu.label) + currentSection.append(buildMenu(content: submenu.content, label: submenu.label)) + } + } + + let children = if previousSections.isEmpty { + // There are no dividers; just return the current section to keep the menu tree flat. + currentSection + } else { + // Create a list of submenus, each with the displayInline option set so that they + // display as sections with separators. + (previousSections + [currentSection]).map { + UIMenu(title: "", options: .displayInline, children: $0) } } @@ -37,7 +60,9 @@ extension UIKitBackend { } public func updatePopoverMenu( - _ menu: Menu, content: ResolvedMenu, environment _: EnvironmentValues + _ menu: Menu, + content: ResolvedMenu, + environment _: EnvironmentValues ) { if #available(iOS 14, macCatalyst 14, tvOS 17, *) { menu.uiMenu = UIKitBackend.buildMenu(content: content, label: "") diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 87ca71a9d4e..6f7ae40aabf 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -276,6 +276,8 @@ public final class WinUIBackend: AppBackend { onChange(widget.isChecked) } return widget + case .separator: + return MenuFlyoutSeparator() case .submenu(let submenu): let widget = MenuFlyoutSubItem() widget.text = submenu.label