diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 29056b06053..438bd7b72d8 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -162,6 +162,7 @@ struct SheetDemo: View { struct WindowingApp: App { @State var title = "My window" @State var resizable = false + @State var toggle = false var body: some Scene { WindowGroup(title) { @@ -204,13 +205,15 @@ struct WindowingApp: App { CommandMenu("Demo menu") { Button("Menu item") {} + Toggle("Toggle", active: $toggle) + Menu("Submenu") { Button("Item 1") {} Button("Item 2") {} } } } - #if !os(iOS) && !os(tvOS) + #if !(os(iOS) || os(tvOS) || os(Windows)) WindowGroup("Secondary window") { #hotReloadable { Text("This a secondary window!") diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index d102da43ad1..49e9eb250a3 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -148,6 +148,24 @@ public final class AppKitBackend: AppBackend { renderedItem.action = #selector(wrappedAction.run) renderedItem.target = wrappedAction } + return renderedItem + case .toggle(let label, let value, let onChange): + // Custom subclass is used to keep strong reference to action + // wrapper. + let renderedItem = NSCustomMenuItem( + title: label, + action: nil, + keyEquivalent: "" + ) + renderedItem.isOn = value + + let wrappedAction = Action { + onChange(!renderedItem.isOn) + } + renderedItem.actionWrapper = wrappedAction + renderedItem.action = #selector(wrappedAction.run) + renderedItem.target = wrappedAction + return renderedItem case .submenu(let submenu): return renderSubmenu(submenu) @@ -1910,6 +1928,11 @@ final class NSCustomMenuItem: NSMenuItem { /// This property's only purpose is to keep a strong reference to the wrapped /// action so that it sticks around for long enough to be useful. var actionWrapper: Action? + + var isOn: Bool { + get { state == .on } + set { state = newValue ? .on : .off } + } } // TODO: Update all controls to use this style of action passing, seems way nicer diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 1563d046f91..a0e977aafb9 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -244,6 +244,9 @@ public final class Gtk3Backend: AppBackend { } model.appendItem(label: label, actionName: "\(actionNamespace).\(actionName)") + case .toggle(let label, let value, let onChange): + // FIXME: Implement + logger.warning("menu toggles not implemented") case .submenu(let submenu): model.appendSubmenu( label: submenu.label, diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 964aeb359ce..af4e733ce91 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -279,6 +279,9 @@ public final class GtkBackend: AppBackend { } model.appendItem(label: label, actionName: "\(actionNamespace).\(actionName)") + case .toggle(let label, let value, let onChange): + // FIXME: Implement + logger.warning("menu toggles not implemented") case .submenu(let submenu): model.appendSubmenu( label: submenu.label, diff --git a/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift b/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift index 36cb95474d7..981415443aa 100644 --- a/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift +++ b/Sources/SwiftCrossUI/Backend/ResolvedMenu.swift @@ -17,6 +17,14 @@ public struct ResolvedMenu { public enum Item { /// A button. A `nil` action means that the button is disabled. case button(_ label: String, _ action: (@MainActor () -> Void)?) + /// A toggle that manages boolean state. + /// + /// Usually appears as a checkbox. + /// - Parameters: + /// - label: The toggle's label. + /// - 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 named submenu. case submenu(Submenu) } diff --git a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift index a3ac5ac659b..fc14fd0422a 100644 --- a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift @@ -13,6 +13,10 @@ public struct MenuItemsBuilder { [.text(first)] } + public static func buildPartialBlock(first: Toggle) -> [MenuItem] { + [.toggle(first)] + } + public static func buildPartialBlock(first: Menu) -> [MenuItem] { [.submenu(first)] } @@ -41,6 +45,13 @@ public struct MenuItemsBuilder { accumulated + buildPartialBlock(first: next) } + public static func buildPartialBlock( + accumulated: [MenuItem], + next: Toggle + ) -> [MenuItem] { + accumulated + buildPartialBlock(first: next) + } + public static func buildPartialBlock( accumulated: [MenuItem], next: Menu diff --git a/Sources/SwiftCrossUI/Scenes/CommandMenu.swift b/Sources/SwiftCrossUI/Scenes/CommandMenu.swift index f15989db790..5460b788a59 100644 --- a/Sources/SwiftCrossUI/Scenes/CommandMenu.swift +++ b/Sources/SwiftCrossUI/Scenes/CommandMenu.swift @@ -16,6 +16,7 @@ public struct CommandMenu: Sendable { } /// Resolves the menu to a representation used by backends. + @MainActor func resolve() -> ResolvedMenu.Submenu { ResolvedMenu.Submenu( label: name, diff --git a/Sources/SwiftCrossUI/Scenes/Commands.swift b/Sources/SwiftCrossUI/Scenes/Commands.swift index adfc728bfda..2d9fccc0540 100644 --- a/Sources/SwiftCrossUI/Scenes/Commands.swift +++ b/Sources/SwiftCrossUI/Scenes/Commands.swift @@ -36,6 +36,7 @@ public struct Commands: Sendable { } /// Resolves the menus to a representation used by backends. + @MainActor func resolve() -> [ResolvedMenu.Submenu] { menus.map { menu in menu.resolve() diff --git a/Sources/SwiftCrossUI/Views/Menu.swift b/Sources/SwiftCrossUI/Views/Menu.swift index cf9a78b80c1..04386b92033 100644 --- a/Sources/SwiftCrossUI/Views/Menu.swift +++ b/Sources/SwiftCrossUI/Views/Menu.swift @@ -14,6 +14,7 @@ public struct Menu: Sendable { } /// Resolves the menu to a representation used by backends. + @MainActor func resolve() -> ResolvedMenu.Submenu { ResolvedMenu.Submenu( label: label, @@ -22,6 +23,7 @@ public struct Menu: Sendable { } /// Resolves the menu's items to a representation used by backends. + @MainActor static func resolveItems(_ items: [MenuItem]) -> ResolvedMenu { ResolvedMenu( items: items.map { item in @@ -30,6 +32,12 @@ public struct Menu: Sendable { .button(button.label, button.action) case .text(let text): .button(text.string, nil) + case .toggle(let toggle): + .toggle( + toggle.label, + toggle.active.wrappedValue, + onChange: { toggle.active.wrappedValue = $0 } + ) case .submenu(let submenu): .submenu(submenu.resolve()) } diff --git a/Sources/SwiftCrossUI/Views/MenuItem.swift b/Sources/SwiftCrossUI/Views/MenuItem.swift index 11d47e25b7a..0d1db8a775a 100644 --- a/Sources/SwiftCrossUI/Views/MenuItem.swift +++ b/Sources/SwiftCrossUI/Views/MenuItem.swift @@ -2,5 +2,6 @@ public enum MenuItem: Sendable { case button(Button) case text(Text) + case toggle(Toggle) case submenu(Menu) } diff --git a/Sources/UIKitBackend/UIKitBackend+Menu.swift b/Sources/UIKitBackend/UIKitBackend+Menu.swift index bb91344972b..9bb4cd72116 100644 --- a/Sources/UIKitBackend/UIKitBackend+Menu.swift +++ b/Sources/UIKitBackend/UIKitBackend+Menu.swift @@ -24,6 +24,10 @@ extension UIKitBackend { } else { UIAction(title: label, attributes: .disabled) { _ in } } + case .toggle(let label, let value, let onChange): + UIAction(title: label, state: value ? .on : .off) { action in + onChange(!action.state.isOn) + } case .submenu(let submenu): buildMenu(content: submenu.content, label: submenu.label) } @@ -70,3 +74,10 @@ extension UIKitBackend { #endif } } + +extension UIMenuElement.State { + var isOn: Bool { + get { self == .on } + set { self = newValue ? .on : .off } + } +} diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index ea9dbe577ff..16fb34bf010 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -267,6 +267,15 @@ public final class WinUIBackend: AppBackend { action?() } return widget + case .toggle(let label, let value, let onChange): + let widget = ToggleMenuFlyoutItem() + widget.text = label + widget.isChecked = value + widget.click.addHandler { [weak widget] _, _ in + guard let widget else { return } + onChange(widget.isChecked) + } + return widget case .submenu(let submenu): let widget = MenuFlyoutSubItem() widget.text = submenu.label