Skip to content
3 changes: 2 additions & 1 deletion Examples/Sources/WindowingExample/WindowingApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,10 @@ struct WindowingApp: App {
.commands {
CommandMenu("Demo menu") {
Button("Menu item") {}

Toggle("Toggle", active: $toggle)

Divider()

Menu("Submenu") {
Button("Item 1") {}
Button("Item 2") {}
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/Gtk/Utility/GMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
8 changes: 8 additions & 0 deletions Sources/Gtk3/Utility/GMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
39 changes: 30 additions & 9 deletions Sources/Gtk3Backend/Gtk3Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,27 +228,38 @@ 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 {
"\(actionPrefix)_\(i)"
} else {
"\(i)"
}
if let actionPrefix {
"\(actionPrefix)_\(i)"
} else {
"\(i)"
}

switch item {
case .button(let label, let action):
if let action {
actionMap.addAction(named: 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):
// FIXME: Implement
logger.warning("menu toggles not implemented")
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,
Expand All @@ -259,7 +270,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 {
Expand Down
29 changes: 25 additions & 4 deletions Sources/GtkBackend/GtkBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
return 0
#else
if window.showMenuBar {
// TODO: Don't hardcode this (if possible), because some Gtk

Check warning on line 166 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Don't hardcode this (if possib...) (todo)
// themes may affect the height of the menu bar.
25
} else {
Expand All @@ -179,7 +179,7 @@
}

public func isWindowProgrammaticallyResizable(_ window: Window) -> Bool {
// TODO: Detect whether window is fullscreen

Check warning on line 182 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Detect whether window is fulls...) (todo)
return true
}

Expand Down Expand Up @@ -263,7 +263,9 @@
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 {
Expand All @@ -278,12 +280,21 @@
actionMap.addAction(named: 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):
// FIXME: Implement

Check warning on line 288 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: FIXMEs should be resolved (Implement) (todo)
logger.warning("menu toggles not implemented")
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,
Expand All @@ -294,7 +305,17 @@
)
}
}
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 {
Expand Down Expand Up @@ -386,14 +407,14 @@
}

public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) {
// TODO: React to theme changes

Check warning on line 410 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (React to theme changes) (todo)
}

public func computeWindowEnvironment(
window: Window,
rootEnvironment: EnvironmentValues
) -> EnvironmentValues {
// TODO: Record window scale factor in here

Check warning on line 417 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Record window scale factor in ...) (todo)
rootEnvironment
}

Expand All @@ -401,7 +422,7 @@
of window: Window,
to action: @escaping () -> Void
) {
// TODO: Notify when window scale factor changes

Check warning on line 425 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Notify when window scale facto...) (todo)
}

public func setIncomingURLHandler(to action: @escaping (URL) -> Void) {
Expand Down Expand Up @@ -686,7 +707,7 @@

// private let tables = Tables()

// TODO: Implement tables

Check warning on line 710 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Implement tables) (todo)
// public func createTable(rows: Int, columns: Int) -> Widget {
// let widget = Grid()

Expand Down Expand Up @@ -763,7 +784,7 @@
environment: EnvironmentValues,
action: @escaping () -> Void
) {
// TODO: Update button label color using environment

Check warning on line 787 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (Update button label color usin...) (todo)
let button = button as! Gtk.Button
button.sensitive = environment.isEnabled
button.label = label
Expand Down Expand Up @@ -1179,7 +1200,7 @@
}
},
window: window ?? windows[0]
) { result in

Check warning on line 1203 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Multiple Closures with Trailing Closure Violation: Trailing closure syntax should not be used when passing more than one closure argument (multiple_closures_with_trailing_closure)
switch result {
case .success(let urls):
handleResult(.success(urls[0]))
Expand Down Expand Up @@ -1212,7 +1233,7 @@
configure(chooser)

chooser.registerSignals()
chooser.response = { (_: NativeDialog, response: Int) -> Void in

Check warning on line 1236 in Sources/GtkBackend/GtkBackend.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Redundant Void Return Violation: Returning Void in a function declaration is redundant (redundant_void_return)
// Release our intentional retain cycle which ironically only exists
// because of this line. The retain cycle keeps the file chooser
// around long enough for the user to respond (it gets released
Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftCrossUI/Backend/ResolvedMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftCrossUI/Views/Divider.swift
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftCrossUI/Views/Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftCrossUI/Views/MenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
public enum MenuItem: Sendable {
case button(Button)
case text(Text)
case toggle(Toggle)

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit-catalyst

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit-catalyst

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit-catalyst

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (iPad)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (iPad)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (TV)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (TV)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (Vision)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (Vision)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (Vision)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (iPhone)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (iPhone)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (iPhone)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'

Check warning on line 5 in Sources/SwiftCrossUI/Views/MenuItem.swift

View workflow job for this annotation

GitHub Actions / uikit (iPhone)

associated value 'toggle' of 'Sendable'-conforming enum 'MenuItem' has non-sendable type 'Toggle'
case separator(Divider)
case submenu(Menu)
}
22 changes: 15 additions & 7 deletions Sources/UIKitBackend/UIKitBackend+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,36 @@ extension UIKitBackend {
label: String,
identifier: UIMenu.Identifier? = nil
) -> UIMenu {
let children = content.items.map { (item) -> UIMenuElement in
var children: [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 }
}
children.append(uiAction)
case .toggle(let label, let value, let onChange):
UIAction(title: label, state: value ? .on : .off) { action in
onChange(!action.state.isOn)
}
children.append(
UIAction(title: label, state: value ? .on : .off) { action in
onChange(!action.state.isOn)
}
)
case .separator:
children = [UIMenu(title: "", options: .displayInline, children: children)]
case .submenu(let submenu):
buildMenu(content: submenu.content, label: submenu.label)
children.append(buildMenu(content: submenu.content, label: submenu.label))
}
}

return UIMenu(title: label, identifier: identifier, children: children)
}

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: "")
Expand Down
2 changes: 2 additions & 0 deletions Sources/WinUIBackend/WinUIBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading