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 @@ -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") {}
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)
)
}
}
34 changes: 29 additions & 5 deletions Sources/Gtk3Backend/Gtk3Backend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
34 changes: 29 additions & 5 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,15 +280,27 @@
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,
Expand All @@ -297,7 +311,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 @@ -389,14 +413,14 @@
}

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

Check warning on line 416 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 423 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 @@ -404,7 +428,7 @@
of window: Window,
to action: @escaping () -> Void
) {
// TODO: Notify when window scale factor changes

Check warning on line 431 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 @@ -689,7 +713,7 @@

// private let tables = Tables()

// TODO: Implement tables

Check warning on line 716 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 @@ -766,7 +790,7 @@
environment: EnvironmentValues,
action: @escaping () -> Void
) {
// TODO: Update button label color using environment

Check warning on line 793 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 @@ -1182,7 +1206,7 @@
}
},
window: window ?? windows[0]
) { result in

Check warning on line 1209 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 @@ -1215,7 +1239,7 @@
configure(chooser)

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

Check warning on line 1242 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 Expand Up @@ -1587,7 +1611,7 @@
}

public func createSheet(content: Widget) -> Sheet {
// TODO: dismissing a sheet with nested sheets doesn't trigger the onDismiss handlers of the nested sheets

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

View workflow job for this annotation

GitHub Actions / swift-lint

Todo Violation: TODOs should be resolved (dismissing a sheet with nested...) (todo)
// TODO: dismissing a sheet with nested sheets causes the app to freeze/deadlock or something along those lines...

let sheet = Sheet()
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 @@ -3,5 +3,6 @@ public enum MenuItem: Sendable {
case button(Button)
case text(Text)
case toggle(Toggle)
case separator(Divider)
case submenu(Menu)
}
39 changes: 32 additions & 7 deletions Sources/UIKitBackend/UIKitBackend+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,53 @@
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)
}
}

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 All @@ -58,7 +83,7 @@
setButtonTitle(buttonWidget, label, environment: environment)
buttonWidget.child.menu = menu.uiMenu
buttonWidget.child.showsMenuAsPrimaryAction = true
if #available(iOS 16, tvOS 17, macCatalyst 16, *) {

Check warning on line 86 in Sources/UIKitBackend/UIKitBackend+Menu.swift

View workflow job for this annotation

GitHub Actions / uikit (TV)

unnecessary check for 'tvOS'; enclosing scope ensures guard will always be true
buttonWidget.child.preferredMenuElementOrder =
switch environment.menuOrder {
case .automatic: .automatic
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