Rohan
Rohan

Reputation: 232

macOS SwiftUI TextEditor keyboard shortcuts for copy, paste, & cut

I'm making an app for the macOS menu/status bar in SwiftUI that when clicked on, opens a NSPopover. The app is centred around a TextEditor (new in Big Sur), but that TextEditor doesn't seem to respond to the typical Cmd + C/V/X keyboard shortcuts for copying, pasting, and cutting. I know TextEditors do support these shortcuts because if I start a new project in XCode and I don't put it in a NSPopover (I just put it into a regular Mac app, for example), it works. The copy/paste/cut options still appear in the right-click menu, but I'm not sure why I can't use keyboard shortcuts to access them in the NSPopover.

I believe it has something to do with the fact that when you click to open the popover, macOS doesn't "focus" on the app. Usually, when you open an app, you'd see the app name and the relevant menu options in the top left of the Mac menu bar (next to the Apple logo). My app doesn't do this (presumably because it's a popover).

Here's the relevant code:

TextEditor in ContentView.swift:

TextEditor(text: $userData.note)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .padding(10)
                    .font(.body)
                    .background(Color(red: 30 / 255, green: 30 / 255, blue: 30 / 255))

NSPopover logic in NotedApp.swift:

@main
struct MenuBarPopoverApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        Settings{
            EmptyView()
        }
    }
}
class AppDelegate: NSObject, NSApplicationDelegate {
    var popover = NSPopover.init()
    var statusBarItem: NSStatusItem?

    func applicationDidFinishLaunching(_ notification: Notification) {
        
        let contentView = ContentView()

        popover.behavior = .transient
        popover.animates = false
        popover.contentViewController = NSViewController()
        popover.contentViewController?.view = NSHostingView(rootView: contentView)
        statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusBarItem?.button?.title = "Noted"
        statusBarItem?.button?.action = #selector(AppDelegate.togglePopover(_:))
    }
    @objc func showPopover(_ sender: AnyObject?) {
        if let button = statusBarItem?.button {
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
        }
    }
    @objc func closePopover(_ sender: AnyObject?) {
        popover.performClose(sender)
    }
    @objc func togglePopover(_ sender: AnyObject?) {
        if popover.isShown {
            closePopover(sender)
        } else {
            showPopover(sender)
        }
    }
}

You can find the entire app in a GitHub repository here: https://github.com/R-Taneja/Noted

Upvotes: 5

Views: 2314

Answers (2)

Cole Roberts
Cole Roberts

Reputation: 984

I stumbled upon this question (and solution) while also building a SwiftUI macOS popover application and while the current solution is usable, it presents a few downsides. The largest concern being the need to make our ContentView aware of and respond to editing actions, it's likely this wouldn't scale well with nested views and complex navigation.

My solution relies on the NSResponder chain and sending a nil target using NSApplication.sendAction(_:to:from:). The backing NSTextView and NSTextField objects both utilize NSText and when either of these objects are first responder the message is passed to them.

I've confirmed the following works with a complex hierarchy and offers all of the text editing methods available on NSText.

Menu Example

@main
struct MyApp: App {
var body: some Scene {
    Settings {
        EmptyView()
    }
    .commands {
        CommandMenu("Edit") {
            Section {

                // MARK: - `Select All` -
                Button("Select All") {
                    NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.a)
                
                // MARK: - `Cut` -
                Button("Cut") {
                    NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.x)
                
                // MARK: - `Copy` -
                Button("Copy") {
                    NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.c)
                
                // MARK: - `Paste` -
                Button("Paste") {
                    NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.v)
            }
        }
    }
}

Keyboard Event Modifier (Not required)

This is solely a convenience modifier and isn't necessary to achieve a working solution.

// MARK: - `Modifiers` -

fileprivate struct KeyboardEventModifier: ViewModifier {
    enum Key: String {
        case a, c, v, x
    }
    
    let key: Key
    let modifiers: EventModifiers
    
    func body(content: Content) -> some View {
        content.keyboardShortcut(KeyEquivalent(Character(key.rawValue)), modifiers: modifiers)
    }
}

extension View {
    fileprivate func keyboardShortcut(_ key: KeyboardEventModifier.Key, modifiers: EventModifiers = .command) -> some View {
        modifier(KeyboardEventModifier(key: key, modifiers: modifiers))
    }
}

Hopefully this helps solve this issue for others!

Tested using SwiftUI 2.0 on macOS Monterrey 12.1

Upvotes: 6

Emily Atlee
Emily Atlee

Reputation: 34

I was looking for a similar solution for a TextField and found a somewhat hacky one. Here is a similar way for your situation using a TextEditor.

The first problem I tried to solve was making the textField a first responder (focus when the popup opens).

This can be done using the SwiftUI-Introspect library (https://github.com/timbersoftware/SwiftUI-Introspect) as seen in this answer for a TextField (https://stackoverflow.com/a/59277051/14847761). Similarly for a TextEditor you can do:

TextEditor(text: $userData.note)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .padding(10)
    .font(.body)
    .background(Color(red: 30 / 255, green: 30 / 255, blue: 30 / 255))

    .introspect(
        selector: TargetViewSelector.siblingContaining,
        customize: { view in
            view.becomeFirstResponder()
    })

Now to get to your main issue of cut/copy/paste, you can also use Introspect. First thing is to get a reference to the NSTextField from inside of the TextEditor:

    .introspect(
        selector: TargetViewSelector.siblingContaining,
        customize: { view in
            view.becomeFirstResponder()
    
            // Extract the NSText from the NSScrollView
            mainText = ((view as! NSScrollView).documentView as! NSText)
            //

    })

The mainText variable must be declared somewhere but cannot be a @State inside of the ContentView for some reason, ran into selection issues for my TextField. I ended up just putting it at the root level inside of the swift file:

import SwiftUI
import Introspect

// Stick this somewhere
var mainText: NSText!

struct ContentView: View {

...

Next is to setup a menu with commands, this is the main reason I think there is no cut/copy/paste as you guessed. Add a command menu to your app and add the commands you want.

@main
struct MenuBarPopoverApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        Settings{
            EmptyView()
        }
        .commands {
            MenuBarPopoverCommands(appDelegate: appDelegate)
        }
    }
}

struct MenuBarPopoverCommands: Commands {
    
    let appDelegate: AppDelegate
    
    init(appDelegate: AppDelegate) {
        self.appDelegate = appDelegate
    }
    
    var body: some Commands {
        CommandMenu("Edit"){ // Doesn't need to be Edit
            Section {
                Button("Cut") {
                    appDelegate.contentView.editCut()
                }.keyboardShortcut(KeyEquivalent("x"), modifiers: .command)
                
                Button("Copy") {
                    appDelegate.contentView.editCopy()
                }.keyboardShortcut(KeyEquivalent("c"), modifiers: .command)
                
                Button("Paste") {
                    appDelegate.contentView.editPaste()
                }.keyboardShortcut(KeyEquivalent("v"), modifiers: .command)
                
                // Might also want this
                Button("Select All") {
                    appDelegate.contentView.editSelectAll()
                }.keyboardShortcut(KeyEquivalent("a"), modifiers: .command)
            }
        }
    }
}

Also need to make the contentView accessible:

class AppDelegate: NSObject, NSApplicationDelegate {
    var popover = NSPopover.init()
    var statusBarItem: NSStatusItem?

    // making this a class variable
    var contentView: ContentView! 

    func applicationDidFinishLaunching(_ notification: Notification) {

        // assign here
        contentView = ContentView()
...

And finally the actual commands.

struct ContentView: View {

...

    func editCut() {
        mainText?.cut(self)
    }
    
    func editCopy() {
        mainText?.copy(self)
    }
    
    func editPaste() {
        mainText?.paste(self)
    }
    
    func editSelectAll() {
        mainText?.selectAll(self)
    }

    // Could also probably add undo/redo in a similar way but I haven't tried

...

}

This was my first ever answer on StackOverflow so I hope that all made sense and I did this right. But I do hope someone else comes along with a better solution, I was searching for an answer myself when I came across this question.

Upvotes: 1

Related Questions