Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions Sources/UntoldEditor/Editor/Window/EditorMainMenuKeeper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// EditorMainMenuKeeper.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//

import AppKit
import UntoldEngine

/// Keeps the editor's menus in the menu bar. The editor is a SwiftUI `App`,
/// for the space the Apple Vision Pro preview shows, with its menus built by
/// its AppKit delegate. SwiftUI installs a menu bar of its own, from its
/// default commands, and on macOS 26 it does so after the delegate has set
/// the editor's, and again whenever it updates: the bar then reads Edit,
/// View, Window and Help, without the editor's File menu and View items.
///
/// The keeper does not fight over which `NSMenu` is the main menu: it moves
/// the editor's menus into whatever menu bar is installed and takes that
/// bar's own items out, so SwiftUI keeps the object it installed and the
/// user sees the editor's menus. It looks again whenever the main menu
/// changes and after every event the application handles. Made and used on
/// the main thread, where AppKit sets the menu bar.
final class EditorMainMenuKeeper: @unchecked Sendable {
/// The editor's menus, in order: the app menu first.
let items: [NSMenuItem]
/// The editor's Window menu, which lists the open windows.
let windowsMenu: NSMenu?
/// How many times the editor's menus were put back; for the tests and the log.
private(set) var restorations = 0
private let application: NSApplication
private var observation: NSKeyValueObservation?
private var observers: [NSObjectProtocol] = []
private var checkIsScheduled = false

init(menu: NSMenu, windowsMenu: NSMenu? = nil, application: NSApplication = .shared) {
items = menu.items
self.windowsMenu = windowsMenu
self.application = application
install(into: application.mainMenu ?? menu)
observation = application.observe(\.mainMenu, options: [.new]) { [weak self] _, _ in
self?.scheduleCheck()
}
let center = NotificationCenter.default
for name in [NSApplication.didUpdateNotification, NSApplication.didBecomeActiveNotification, NSWindow.didBecomeKeyNotification] {
observers.append(center.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in
self?.checkNow()
})
}
}

deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}

/// True while the menu bar shows the editor's menus and nothing else.
var isInPlace: Bool {
guard let bar = application.mainMenu else { return false }
return bar.items.count == items.count && zip(bar.items, items).allSatisfy { $0 === $1 }
}

/// Outside the setter that is replacing the menu bar.
private func scheduleCheck() {
guard checkIsScheduled == false else { return }
checkIsScheduled = true
DispatchQueue.main.async { [weak self] in
self?.checkIsScheduled = false
self?.checkNow()
}
}

/// Puts the editor's menus back when the menu bar shows something else.
func checkNow() {
guard isInPlace == false else { return }
restorations += 1
if restorations == 1 {
Logger.log(message: "The menu bar was replaced; the editor's menus are put back.")
}
install(into: application.mainMenu ?? NSMenu())
}

/// Makes `bar` the menu bar, with the editor's menus and nothing else.
private func install(into bar: NSMenu) {
// A menu bar of SwiftUI's fills itself in through its delegate.
bar.delegate = nil
for item in items {
item.menu?.removeItem(item)
}
bar.removeAllItems()
for item in items {
bar.addItem(item)
}
if application.mainMenu !== bar {
application.mainMenu = bar
}
if let windowsMenu, application.windowsMenu !== windowsMenu {
application.windowsMenu = windowsMenu
}
}
}
4 changes: 3 additions & 1 deletion Sources/UntoldEditor/UntoldEditorApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var splatBlendCapItems: [SplatBlendCapOption: NSMenuItem] = [:]
private var splatWorkingSetItems: [EditorSplatWorkingSet: NSMenuItem] = [:]
private var splatLevelModeItems: [SplatLevelModeOption: NSMenuItem] = [:]
/// Puts the editor's menus in the menu bar and keeps them there (SwiftUI installs its own bar).
private var mainMenuKeeper: EditorMainMenuKeeper?

func applicationDidFinishLaunching(_: Notification) {
Logger.log(message: "Launching \(appName) v\(Self.editorVersion) as \(Bundle.main.bundleIdentifier ?? "an executable without an identity")")
Expand Down Expand Up @@ -344,7 +346,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "")
NSApp.windowsMenu = windowMenu

NSApp.mainMenu = mainMenu
mainMenuKeeper = EditorMainMenuKeeper(menu: mainMenu, windowsMenu: windowMenu)
}

/// A menu item that shows or hides a panel of the docking layout; its
Expand Down
202 changes: 202 additions & 0 deletions Tests/UntoldEditorTests/EditorMainMenuKeeperTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
//
// EditorMainMenuKeeperTests.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
import AppKit
@testable import UntoldEditor
import XCTest

/// The editor's menus stay in the menu bar when SwiftUI, or anything else,
/// installs another one or fills the installed one with its own items.
@MainActor
final class EditorMainMenuKeeperTests: XCTestCase {
private var menuBefore: NSMenu?
private var windowsMenuBefore: NSMenu?

override func setUp() {
super.setUp()
menuBefore = NSApplication.shared.mainMenu
windowsMenuBefore = NSApplication.shared.windowsMenu
}

override func tearDown() {
NSApplication.shared.mainMenu = menuBefore
NSApplication.shared.windowsMenu = windowsMenuBefore
super.tearDown()
}

/// Turns the run loop until the keeper has looked (two seconds at most, for a
/// busy machine), or for a moment when nothing is expected to happen.
private func turnTheRunLoop(until done: (() -> Bool)? = nil) {
guard let done else {
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
return
}
let deadline = Date().addingTimeInterval(2)
repeat {
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
} while done() == false && Date() < deadline
}

/// A menu bar as the editor builds it: three menus, the last the Window menu.
private func editorsMenu() -> (menu: NSMenu, items: [NSMenuItem], windows: NSMenu) {
let menu = NSMenu(title: "Editor")
let windows = NSMenu(title: "Window")
for title in ["Untold Engine Editor", "File", "View"] {
let item = NSMenuItem()
item.submenu = NSMenu(title: title)
menu.addItem(item)
}
let windowItem = NSMenuItem()
windowItem.submenu = windows
menu.addItem(windowItem)
return (menu, menu.items, windows)
}

/// A menu bar as SwiftUI installs it: its own menus, filled in by a delegate.
private final class FillingDelegate: NSObject, NSMenuDelegate {
func menuNeedsUpdate(_ menu: NSMenu) {
menu.addItem(withTitle: "Help", action: nil, keyEquivalent: "")
}
}

private func swiftUIsMenu() -> NSMenu {
let menu = NSMenu(title: "SwiftUI")
for title in ["Untold Engine Studio", "Edit", "View", "Window", "Help"] {
let item = NSMenuItem()
item.submenu = NSMenu(title: title)
menu.addItem(item)
}
return menu
}

private func titles(_ menu: NSMenu?) -> [String] {
menu?.items.map { $0.submenu?.title ?? $0.title } ?? []
}

func test_withNoMenuBar_theEditorsMenuIsInstalled() {
NSApplication.shared.mainMenu = nil
let editors = editorsMenu()

let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

XCTAssertTrue(NSApplication.shared.mainMenu === editors.menu)
XCTAssertTrue(keeper.isInPlace)
XCTAssertTrue(NSApplication.shared.windowsMenu === editors.windows)
XCTAssertEqual(keeper.restorations, 0)
}

func test_aMenuBarInstalledBefore_getsTheEditorsMenusAndKeepsItsIdentity() {
let swiftUIs = swiftUIsMenu()
NSApplication.shared.mainMenu = swiftUIs
let editors = editorsMenu()

let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

XCTAssertTrue(NSApplication.shared.mainMenu === swiftUIs)
XCTAssertEqual(titles(NSApplication.shared.mainMenu), ["Untold Engine Editor", "File", "View", "Window"])
XCTAssertTrue(keeper.isInPlace)
XCTAssertEqual(editors.menu.items.count, 0, "the editor's items moved into the installed bar")
}

func test_aMenuBarInstalledLater_getsTheEditorsMenus() {
NSApplication.shared.mainMenu = nil
let editors = editorsMenu()
let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

let swiftUIs = swiftUIsMenu()
NSApplication.shared.mainMenu = swiftUIs
turnTheRunLoop { keeper.isInPlace }

XCTAssertTrue(NSApplication.shared.mainMenu === swiftUIs)
XCTAssertEqual(titles(NSApplication.shared.mainMenu), ["Untold Engine Editor", "File", "View", "Window"])
XCTAssertEqual(keeper.restorations, 1)
}

func test_aMenuBarRefilledWithoutBeingReplaced_isPutBackAfterTheNextEvent() throws {
NSApplication.shared.mainMenu = nil
let editors = editorsMenu()
let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)
let bar = try XCTUnwrap(NSApplication.shared.mainMenu)

// What a menu bar's owner may do on an update of its own: no setter runs.
bar.removeAllItems()
for title in ["Edit", "Help"] {
bar.addItem(withTitle: title, action: nil, keyEquivalent: "")
}
XCTAssertFalse(keeper.isInPlace)
NotificationCenter.default.post(name: NSApplication.didUpdateNotification, object: NSApplication.shared)

XCTAssertEqual(titles(NSApplication.shared.mainMenu), ["Untold Engine Editor", "File", "View", "Window"])
XCTAssertEqual(keeper.restorations, 1)
}

func test_aMenuBarThatFillsItselfInThroughItsDelegate_isStopped() {
let swiftUIs = swiftUIsMenu()
let filling = FillingDelegate()
swiftUIs.delegate = filling
NSApplication.shared.mainMenu = swiftUIs
let editors = editorsMenu()

_ = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

XCTAssertNil(swiftUIs.delegate)
}

func test_theWindowsMenu_staysTheEditors() {
NSApplication.shared.mainMenu = nil
let editors = editorsMenu()
let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

let swiftUIs = swiftUIsMenu()
NSApplication.shared.windowsMenu = swiftUIs.items[3].submenu
NSApplication.shared.mainMenu = swiftUIs
turnTheRunLoop { keeper.isInPlace }

XCTAssertTrue(NSApplication.shared.windowsMenu === editors.windows)
}

func test_theEditorsMenusInPlace_countAsNothing() {
NSApplication.shared.mainMenu = nil
let editors = editorsMenu()
let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

NotificationCenter.default.post(name: NSApplication.didUpdateNotification, object: NSApplication.shared)
NotificationCenter.default.post(name: NSApplication.didBecomeActiveNotification, object: NSApplication.shared)
NSApplication.shared.mainMenu = editors.menu
turnTheRunLoop()

XCTAssertEqual(keeper.restorations, 0)
}

func test_everyReplacement_isUndone() {
NSApplication.shared.mainMenu = nil
let editors = editorsMenu()
let keeper = EditorMainMenuKeeper(menu: editors.menu, windowsMenu: editors.windows)

for _ in 0 ..< 3 {
NSApplication.shared.mainMenu = swiftUIsMenu()
turnTheRunLoop { keeper.isInPlace }
}

XCTAssertEqual(titles(NSApplication.shared.mainMenu), ["Untold Engine Editor", "File", "View", "Window"])
XCTAssertEqual(keeper.restorations, 3)
}

func test_withoutAKeeper_anotherMenuBarStays() {
let editors = editorsMenu()
NSApplication.shared.mainMenu = editors.menu
let swiftUIs = swiftUIsMenu()

NSApplication.shared.mainMenu = swiftUIs
turnTheRunLoop()

XCTAssertEqual(titles(NSApplication.shared.mainMenu), ["Untold Engine Studio", "Edit", "View", "Window", "Help"])
}
}
Loading