3

My MacOS app doesn't have any text editing possibilities. How can I hide the Edit menu which is added to my app automatically? I'd prefer to do this in SwiftUI.

I would expect the code below should work, but it doesn't.

@main
struct MyApp: App {

var body: some Scene {
    WindowGroup {
        ContentView()
    }.commands {
        CommandGroup(replacing: .textEditing) {}
    }
}

}
Tamas
  • 3,254
  • 4
  • 29
  • 51

5 Answers5

5

To my knowledge you cannot hide the whole menu, you can just hide element groups inside of it:

    .commands {
        CommandGroup(replacing: .pasteboard) { }
        CommandGroup(replacing: .undoRedo) { }
    }
ChrisR
  • 9,523
  • 1
  • 8
  • 26
  • What about the file menu? According to Apple that can be removed. https://developer.apple.com/design/human-interface-guidelines/macos/menus/menu-bar-menus/#file-menu – Tamas Mar 01 '22 at 20:55
  • 1
    with `CommandGroup(replacing: .saveItem) { }` and `CommandGroup(replacing: .newItem) { }` you get an empty file menu. But I don't see a way to get rid of it completely. Unfortunately SwiftUI for macOS still lacks a lot .... – ChrisR Mar 01 '22 at 22:31
3

The current suggestions failed for me when SwiftUI updated the body of a window.

Solution:

Use KVO and watch the NSApp for changes on \.mainMenu. You can remove whatever you want after SwiftUI has its turn.

@objc
class AppDelegate: NSObject, NSApplicationDelegate {
    
    var token: NSKeyValueObservation?
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        
        // Remove a single menu
        if let m = NSApp.mainMenu?.item(withTitle: "Edit") {
            NSApp.mainMenu?.removeItem(m)
        }

        // Remove Multiple Menus
        ["Edit", "View", "Help", "Window"].forEach { name in
            NSApp.mainMenu?.item(withTitle: name).map { NSApp.mainMenu?.removeItem($0) }
        }
        
        

        // Must remove after every time SwiftUI re adds
        token = NSApp.observe(\.mainMenu, options: .new) { (app, change) in
            ["Edit", "View", "Help", "Window"].forEach { name in
                NSApp.mainMenu?.item(withTitle: name).map { NSApp.mainMenu?.removeItem($0) }
            }

            // Remove a single menu
            guard let menu = app.mainMenu?.item(withTitle: "Edit") else { return }
            app.mainMenu?.removeItem(menu)
        }
    }
}

struct MarblesApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some View { 
        //... 
    }
}

Thoughts:

SwiftUI either has a bug or they really don't want you to remove the top level menus in NSApp.mainMenu. SwiftUI seems to reset the whole menu with no way to override or customize most details currently (Xcode 13.4.1). The CommandGroup(replacing: .textEditing) { }-esque commands don't let you remove or clear a whole menu. Assigning a new NSApp.mainMenu just gets clobbered when SwiftUI wants even if you specify no commands.

This seems like a very fragile solution. There should be a way to tell SwiftUI to not touch the NSApp.mainMenu or enable more customization. Or it seems SwiftUI should check that it owned the previous menu (the menu items are SwiftUI.AppKitMainMenuItem). Or I'm missing some tool they've provided. Hopefully this is fixed in the WWDC beta?

(In Xcode 13.4.1 with Swift 5 targeting macOS 12.3 without Catalyst.)

waggles
  • 2,804
  • 1
  • 15
  • 16
1

For native (Cocoa) apps

It is possible to remove application menus using an NSApplicationDelegate. This approach may break in future macOS versions (e.g. if the position of the Edit menu were to change), but does currently work:

class MyAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
  let indexOfEditMenu = 2
   
  func applicationDidFinishLaunching(_ : Notification) {
    NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
  }
}


@main
struct MyApp: App {
  @NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate

  var body: some Scene {
    WindowGroup {
      ContentView()
    }.commands {
      // ...
    }
  }
}

For Catalyst (UIKit) apps

For Catalyst-based macOS apps, the approach is similar to that above, except that a UIApplicationDelegate deriving from UIResponder is used:

class MyAppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
   override func buildMenu(with builder: UIMenuBuilder) {
      /// Only operate on the main menu bar.
      if builder.system == .main {
         builder.remove(menu: .edit)
      }
   }
}


@main
struct MyApp: App {
  @UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate

  var body: some Scene {
    WindowGroup {
      ContentView()
    }.commands {
      // ...
    }
  }
}
1

For those of you that are looking for any updates on this - have a look at this question that I asked (and answered myself):

SwiftUI Update the mainMenu [SOLVED] kludgey

The way I got around it was to put it in a DispatchQueue.main.async closure in the AppDelegate applicationWillUpdate function

import Foundation
import AppKit

public class AppDelegate: NSObject, NSApplicationDelegate {
    public func applicationWillUpdate(_ notification: Notification) {
        DispatchQueue.main.async {
            let currentMainMenu = NSApplication.shared.mainMenu

            let editMenu: NSMenuItem? = currentMainMenu?.item(withTitle: "Edit")
            if nil != editMenu {
                NSApp.mainMenu?.removeItem(editMenu!)
            }
        }
    }
}

It took me a good 4 days of searching and attempting things :) - typical that it came down to a 2 line code change

0

You should be able to hide whole menus by modifying @waggles's answer a bit.

class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {

    // SwiftUI updates menu on occlusion state change
    func applicationDidChangeOcclusionState(_ notification: Notification) {
        ["Edit", "View"].forEach { name in
            NSApp.mainMenu?.item(withTitle: name).map { NSApp.mainMenu?.removeItem($0) }
        }
    }
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        if let app = notification.object as? NSApplication,  app.windows.count > 0 {
            // For some reason if window delegate is not set here, 
            // occulsionState notification fires with a delay on 
            // window unhide messing up the menu-update timing.
            app.windows.first?.delegate = self
        }
    }
}
Chintan Ghate
  • 179
  • 1
  • 8