Skip to content

Commit 7dd337a

Browse files
committed
Implement Scene.commands modifier for configuring menu bar items
On macOS, menu bar items are displayed in the native macOS menu bar, and on Linux they're displayed within the app under the title bar.
1 parent eeaa898 commit 7dd337a

35 files changed

+852
-44
lines changed

Examples/Sources/CounterExample/CounterApp.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,30 @@ struct CounterApp: App {
3030
}
3131
}
3232
.defaultSize(width: 400, height: 200)
33+
.commands {
34+
CommandMenu("Help") {
35+
Text("Need help?")
36+
Button("User guide") {
37+
print("User guide")
38+
}
39+
Menu("Export as...") {
40+
Button("PNG") {
41+
print("Export as PNG")
42+
}
43+
Button("JPEG") {
44+
print("Export as JPEG")
45+
}
46+
}
47+
}
48+
49+
CommandMenu("Window") {
50+
Button("Maximize") {
51+
print("Maximize")
52+
}
53+
Button("Minimize") {
54+
print("Minimize")
55+
}
56+
}
57+
}
3358
}
3459
}

Sources/AppKitBackend/AppKitBackend.swift

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public final class AppKitBackend: AppBackend {
5252
defer: true
5353
)
5454
window.delegate = window.resizeDelegate
55+
5556
return window
5657
}
5758

@@ -99,6 +100,71 @@ public final class AppKitBackend: AppBackend {
99100
window.makeKeyAndOrderFront(nil)
100101
}
101102

103+
private static func renderSubmenu(_ submenu: ResolvedMenu.Submenu) -> NSMenuItem {
104+
let renderedMenu = NSMenu()
105+
for item in submenu.content.items {
106+
switch item {
107+
case .button(let label, let action):
108+
// Custom subclass is used to keep strong reference to action
109+
// wrapper.
110+
let renderedItem = NSCustomMenuItem(
111+
title: label,
112+
action: nil,
113+
keyEquivalent: ""
114+
)
115+
if let action {
116+
let wrappedAction = Action(action)
117+
renderedItem.actionWrapper = wrappedAction
118+
renderedItem.action = #selector(wrappedAction.run)
119+
renderedItem.target = wrappedAction
120+
}
121+
renderedMenu.addItem(renderedItem)
122+
case .submenu(let submenu):
123+
renderedMenu.addItem(renderSubmenu(submenu))
124+
}
125+
}
126+
127+
let menuItem = NSMenuItem()
128+
menuItem.title = submenu.label
129+
menuItem.submenu = renderedMenu
130+
return menuItem
131+
}
132+
133+
/// The submenu pointed to by `helpMenu` still appears in `menuBar`. It's
134+
/// whichever submenu has the name 'Help'.
135+
private static func renderMenuBar(
136+
_ submenus: [ResolvedMenu.Submenu]
137+
) -> (menuBar: NSMenu, helpMenu: NSMenu?) {
138+
let menuBar = NSMenu()
139+
140+
// The first menu item is special and always takes on the name of the
141+
// app. For now just create a dummy item for it.
142+
let dummy = NSMenuItem()
143+
dummy.submenu = NSMenu()
144+
menuBar.addItem(dummy)
145+
146+
var helpMenu: NSMenu?
147+
for submenu in submenus {
148+
let renderedSubmenu = renderSubmenu(submenu)
149+
menuBar.addItem(renderedSubmenu)
150+
151+
if submenu.label == "Help" {
152+
helpMenu = renderedSubmenu.submenu
153+
}
154+
}
155+
156+
return (menuBar, helpMenu)
157+
}
158+
159+
public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) {
160+
let (menuBar, helpMenu) = Self.renderMenuBar(submenus)
161+
NSApplication.shared.mainMenu = menuBar
162+
163+
// We point the app's `helpMenu` at whichever submenu is named 'Help'
164+
// (if any) so that AppKit can install its help menu search function.
165+
NSApplication.shared.helpMenu = helpMenu
166+
}
167+
102168
public func runInMainThread(action: @escaping () -> Void) {
103169
DispatchQueue.main.async {
104170
action()
@@ -826,10 +892,8 @@ final class Action: NSObject {
826892
}
827893

828894
@objc func run() {
829-
print("Running action")
830895
action()
831896
}
832-
833897
}
834898

835899
class NSCustomTableView: NSTableView {

Sources/Gtk/Application.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,29 @@
44

55
import CGtk
66

7-
public class Application {
8-
let applicationPointer: UnsafeMutablePointer<GtkApplication>
7+
public class Application: GActionMap {
8+
public let applicationPointer: UnsafeMutablePointer<GtkApplication>
99
private(set) var applicationWindow: ApplicationWindow?
1010
private var windowCallback: ((ApplicationWindow) -> Void)?
1111

12+
public var actionMapPointer: OpaquePointer {
13+
OpaquePointer(applicationPointer)
14+
}
15+
16+
private var _menuBarModel: GMenu?
17+
public var menuBarModel: GMenu? {
18+
get {
19+
_menuBarModel
20+
}
21+
set {
22+
gtk_application_set_menubar(
23+
applicationPointer,
24+
(newValue?.pointer).map(UnsafeMutablePointer.init)
25+
)
26+
_menuBarModel = newValue
27+
}
28+
}
29+
1230
public init(applicationId: String) {
1331
// Ignore the deprecation warning, making the change breaks support for platforms such as
1432
// Ubuntu (before Lunar). This is due to Ubuntu coming with an older version of Gtk in apt.

Sources/Gtk/Utility/GAction.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import CGtk
2+
3+
public protocol GAction {
4+
var actionPointer: OpaquePointer { get }
5+
}

Sources/Gtk/Utility/GActionMap.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import CGtk
2+
3+
public protocol GActionMap {
4+
var actionMapPointer: OpaquePointer { get }
5+
}
6+
7+
extension GActionMap {
8+
public func addAction(_ action: any GAction) {
9+
g_action_map_add_action(actionMapPointer, action.actionPointer)
10+
}
11+
12+
public func addAction(named name: String, action: @escaping () -> Void) {
13+
let simpleAction = GSimpleAction(name: name, action: action)
14+
addAction(simpleAction)
15+
}
16+
}

Sources/Gtk/Utility/GMenu.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import CGtk
2+
3+
public class GMenu {
4+
var pointer: OpaquePointer
5+
var actionMap: any GActionMap
6+
7+
public init(actionMap: any GActionMap) {
8+
pointer = g_menu_new()
9+
self.actionMap = actionMap
10+
}
11+
12+
public func appendItem(label: String, actionName: String) {
13+
g_menu_append(pointer, label, actionName)
14+
}
15+
16+
public func appendSubmenu(label: String, content: GMenu) {
17+
g_menu_append_submenu(
18+
pointer,
19+
label,
20+
UnsafeMutablePointer(content.pointer)
21+
)
22+
}
23+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import CGtk
2+
3+
public class GSimpleAction: GAction, GObjectRepresentable {
4+
public var actionPointer: OpaquePointer
5+
6+
public var gobjectPointer: UnsafeMutablePointer<GObject> {
7+
UnsafeMutablePointer<GObject>(actionPointer)
8+
}
9+
10+
@GObjectProperty(named: "enabled") var enabled: Bool
11+
12+
private class Action {
13+
var run: () -> Void
14+
15+
init(_ action: @escaping () -> Void) {
16+
run = action
17+
}
18+
}
19+
20+
public init(name: String, action: @escaping () -> Void) {
21+
actionPointer = g_simple_action_new(name, nil)
22+
23+
let wrappedAction = Action(action)
24+
25+
let handler:
26+
@convention(c) (
27+
UnsafeMutableRawPointer,
28+
OpaquePointer,
29+
UnsafeMutableRawPointer
30+
) -> Void =
31+
{ _, _, data in
32+
let action = Unmanaged<Action>.fromOpaque(data)
33+
.takeUnretainedValue()
34+
action.run()
35+
}
36+
37+
g_signal_connect_data(
38+
UnsafeMutableRawPointer(actionPointer),
39+
"activate",
40+
gCallback(handler),
41+
Unmanaged<Action>.passRetained(wrappedAction).toOpaque(),
42+
{ data, _ in
43+
Unmanaged<Action>.fromOpaque(data!).release()
44+
},
45+
G_CONNECT_AFTER
46+
)
47+
}
48+
}

Sources/Gtk/Widgets/ApplicationWindow.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ public class ApplicationWindow: Window {
99
super.init()
1010
widgetPointer = gtk_application_window_new(application.applicationPointer)
1111
}
12+
13+
@GObjectProperty(named: "show-menubar") public var showMenuBar: Bool
1214
}

Sources/Gtk/Widgets/Widget.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Foundation
77

88
open class Widget: GObjectRepresentable {
99
private var signals: [(UInt, Any)] = []
10-
var widgetPointer: UnsafeMutablePointer<GtkWidget>?
10+
public var widgetPointer: UnsafeMutablePointer<GtkWidget>?
1111

1212
public var gobjectPointer: UnsafeMutablePointer<GObject> {
1313
return widgetPointer!.cast()

Sources/Gtk/Widgets/Window.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,6 @@ public class Window: Widget {
5151

5252
@GObjectProperty(named: "resizable") public var resizable: Bool
5353

54-
private var _titleBar: Widget?
55-
public var titlebar: Widget? {
56-
get { return _titleBar }
57-
set { gtk_window_set_titlebar(castedPointer(), newValue?.widgetPointer) }
58-
}
59-
6054
public func setMinimumSize(to minimumSize: Size) {
6155
gtk_widget_set_size_request(
6256
castedPointer(),

0 commit comments

Comments
 (0)