diff --git a/Sources/UntoldEditor/Editor/Window/EditorMainMenuKeeper.swift b/Sources/UntoldEditor/Editor/Window/EditorMainMenuKeeper.swift new file mode 100644 index 0000000..d130c62 --- /dev/null +++ b/Sources/UntoldEditor/Editor/Window/EditorMainMenuKeeper.swift @@ -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 + } + } +} diff --git a/Sources/UntoldEditor/UntoldEditorApp.swift b/Sources/UntoldEditor/UntoldEditorApp.swift index b820dda..0ad5d81 100644 --- a/Sources/UntoldEditor/UntoldEditorApp.swift +++ b/Sources/UntoldEditor/UntoldEditorApp.swift @@ -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")") @@ -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 diff --git a/Tests/UntoldEditorTests/EditorMainMenuKeeperTests.swift b/Tests/UntoldEditorTests/EditorMainMenuKeeperTests.swift new file mode 100644 index 0000000..bbb4843 --- /dev/null +++ b/Tests/UntoldEditorTests/EditorMainMenuKeeperTests.swift @@ -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"]) + } +}