问题
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:
- Set
undoManager.groupsByEvent = false
- In
controlTextDidBeginEditing()
, begin a new undo group - In
controlTextDidChange()
, register an undo action - 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:
- Create a new Cocoa project and paste in the code for the App Delegate (see below)
- Type "a" + "b" + "c" + Cmd-Z
Expected:
- Text field value should be set to empty string
Actual:
- Crash
Alternatively:
- 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