NSUndoManager custom coalescing changes to NSTextField results in crash

匆匆过客 提交于 2019-12-11 04:17:00

问题


I have a basic Cocoa app using NSUndoManager to support undo/redo. My data model's title can be updated by editing an NSTextField. Out of the box NSTextField supports coalescing changes into a single "Undo Typing" undo action so that when the user presses Cmd-Z, the text is reverted in full instead of only the last letter.

I'd like to have a single "Undo Changing Model Title" undo action that undoes multiple changes to my model's title (made in a NSTextField). I tried grouping changes myself, but my app crashes (see below).

I can't use the default undo behavior of NSTextField, because I need to update my model and the text field might be gone by the time the user tries to undo an action (the text field is in a popup window).

NSUndoManager by default groups changes that occur within a single run loop cycle, but also allows to disable this behavior and to create custom "undo groups". So I tried the following:

  1. Set undoManager.groupsByEvent = false
  2. In controlTextDidBeginEditing(), begin a new undo group
  3. In controlTextDidChange(), register an undo action
  4. In controlTextDidEndEditing(), end the undo group

This works as long as I end editing the text field by pressing Enter. If I type "abc" and press Cmd-Z to undo before ending editing, the app crashes, because the undo grouping was not closed:

[General] undo: NSUndoManager 0x60000213b1b0 is in invalid state, undo was called
with too many nested undo groups

From the docs, undo() is supposed to close the undo grouping automatically, if needed. The grouping level is 1 in my case.

undo() [...] This method also invokes endUndoGrouping() if the nesting level is 1

However, the undo group is not closed by undo(), no matter if I set groupsByEvent to true or false. My app always crashes.

What is interesting:

I observed the default behavior of NSTextField. When I type the first letter, it begins AND ends an undo group right away. It does not create any other undo groups for subsequent changes after the first change.

To reproduce:

  1. Create a new Cocoa project and paste in the code for the App Delegate (see below)
  2. Type "a" + "b" + "c" + Cmd-Z

Expected:

  • Text field value should be set to empty string

Actual:

  • Crash

Alternatively:

  1. Type "a" + "b" + "c" + Enter + Cmd-Z

Result:

  • The above works, because this time, the undo group is ended. All grouped changes are undone properly.

The problem is that the user can press Cmd-Z at any time while editing. I can't end the undo group after each change or changes cannot be undone all at once.

undo() not closing the undo group might be a bug, but nonetheless, grouping changes and undoing while typing must be possible, because that's what NSTextField does out of the box (and it works).

Source:

import Cocoa

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, NSTextFieldDelegate {

    @IBOutlet weak var window: NSWindow!

    private var textField:NSTextField!

    private let useCustomUndo = true

    private var myModelTitle = ""

    func applicationDidFinishLaunching(_ aNotification: Notification) {

        //useCustomUndo = false

        observeUndoManger()

        setup()

        NSLog("Window      undo manager: \(Unmanaged.passUnretained(window.undoManager!).toOpaque())")
        NSLog("NSTextField undo manager: \(Unmanaged.passUnretained(textField.undoManager!).toOpaque())")
    }

    func controlTextDidBeginEditing(_ obj: Notification) {
        NSLog("Did begin editing & starting undo group")

        // With or without grouping the app crashes on undo if the group was not ended:
        window.undoManager?.groupsByEvent = true

        window.undoManager?.beginUndoGrouping()
    }

    func controlTextDidEndEditing(_ obj: Notification) {
        NSLog("Did end editing & ending undo group")

        window.undoManager?.endUndoGrouping()
    }

    func controlTextDidChange(_ obj: Notification) {
        NSLog("Text did change")

        setModelTitleWithUndo(title: textField.stringValue)
    }

    private func setModelTitleWithUndo(title:String) {
        NSLog("Current groupingLevel: \(window.undoManager!.groupingLevel)")

        window.undoManager?.registerUndo(withTarget: self, handler: {[oldValue = myModelTitle, weak self] _  in

           guard let self = self, let undoManager = self.window.undoManager else { return }

           NSLog("\(undoManager.isUndoing ? "Undo" : "Redo") from current model    : '\(self.myModelTitle)' to: '\(oldValue)'")
           NSLog(                                      "     from current textfield: '\(self.textField.stringValue)' to: '\(oldValue)'")

            self.setModelTitleWithUndo(title: oldValue)
        })

        window.undoManager?.setActionName("Change Title")

        myModelTitle = title

        if window.undoManager?.isUndoing ?? false || window.undoManager?.isRedoing ?? false
        {
            textField.stringValue = myModelTitle
        }

        NSLog("Model: '\(myModelTitle)'")
    }

    private func observeUndoManger() {
        for i in [(NSNotification.Name.NSUndoManagerCheckpoint       , "<checkpoint>"          ),
                  (NSNotification.Name.NSUndoManagerDidOpenUndoGroup , "<did open undo group>" ),
                  (NSNotification.Name.NSUndoManagerDidCloseUndoGroup, "<did close undo group>"),
                  (NSNotification.Name.NSUndoManagerDidUndoChange    , "<did undo change>"     ),
                  (NSNotification.Name.NSUndoManagerDidRedoChange    , "<did redo change>"     )]
        {
            NotificationCenter.default.addObserver(forName: i.0, object: nil, queue: nil) {n in

                let undoManager = n.object as! UndoManager

                NSLog("\(Unmanaged.passUnretained(undoManager).toOpaque()) \(i.1) grouping level: \(undoManager.groupingLevel), groups by event: \(undoManager.groupsByEvent)")
            }
        }
    }

    private func setup() {
       textField = NSTextField(string: myModelTitle)
       textField.translatesAutoresizingMaskIntoConstraints = false

       window.contentView!.addSubview(textField)

       textField.leadingAnchor.constraint(equalTo: window.contentView!.leadingAnchor).isActive = true
       textField.topAnchor.constraint(equalTo: window.contentView!.topAnchor, constant: 50).isActive = true
       textField.trailingAnchor.constraint(equalTo: window.contentView!.trailingAnchor).isActive = true

       if useCustomUndo
       {
           textField.cell?.allowsUndo = false
           textField.delegate = self
       }
    }
}

UPDATE:

I observed the behavior of NSTextField some more. NSTextField does not group across multiple run loop cycles. NSTextField groups by event and uses groupsByEvent = true. It creates a new undo group when I type the first letter, closes the group, and does not create any additional undo groups for the next letters I type. Very strange...

来源:https://stackoverflow.com/questions/58409945/nsundomanager-custom-coalescing-changes-to-nstextfield-results-in-crash

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!