NSUndoManager custom coalescing changes to NSTextField results in crash - cocoa

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...

Related

NSTextField Loses Focus When Neighboring NSTableView Reloaded

I have a search field (NSTextField) called searchField and when you type in it, it refreshes the data shown in a NSTableView. The problem is that this refresh also triggers the selection of a table row, and that takes the focus out of the NSTextField.
The user types in the search field:
func controlTextDidChange(_ obj: Notification) {
if let field = obj.object as? NSTextField, field == searchField{
refreshData()
}
}
Then the NSTableView gets reloaded here:
func refreshData(){
//Process search term and filter data array
//...
//Restore previously selected row (if available)
let index = tableView.selectedRow
tableView.reloadData()
//Select the previously selected row
tableView.selectRowIndexes(NSIndexSet(index: index) as IndexSet, byExtendingSelection: false)
}
If I comment-out both the reloadData() and the selectRowIndexes then the search field behaves as intended (I can keep typing and it keeps the focus in the field). But if include either or both of those methods, the search field loses focus after I type the first character and refreshData() is called.
How can I keep focus in my search field and not let the table reload hijack the focus?
Ugh... it turns out I could never get the NSTableView to let go of the focus because it wasn't set to allow an Empty selection state. Checking a box in Interface Builder fixed it.

Replacing the ESC button on a NSTouchBar

I am building a NSTouchBar for my app using storyboard.
I want to replace the ESC button with something else.
As usual, there is no doc telling you how to do that.
I have searched the web and I have found vague informations like
You can change the content of "esc" to something else, though, like
"done" or anything, even an icon, by using
escapeKeyReplacementItemIdentifier with the NSTouchBarItem.
But this is too vague to understand.
Any ideas?
This is what I did so far.
I have added a button to NSTouchBar on storyboard and changed its identifier to newESC. I added this line programmatically:
self.touchBar.escapeKeyReplacementItemIdentifier = #"newESC";
When I run the App the ESC key is now invisible but still occupies its space on the bar. The button that was supposed to replace it, appears next to it. So that bar that was
`ESC`, `NEW_ESC`, `BUTTON1`, `BUTTON2`, ...
is now
`ESC` (invisible), `NEW_ESC`, `BUTTON1`, `BUTTON2`, ...
The old ESC is still occupying its space on the bar.
This is done by creating a touch bar item, let's say a NSCustomTouchBarItem containing a NSButton, and associating this item with its own identifier.
Then with another identifier you do your usual logic but you add the previously created identifier as the ESC replacement.
Quick example in Swift:
func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItemIdentifier) -> NSTouchBarItem? {
switch identifier {
case NSTouchBarItemIdentifier.identifierForESCItem:
let item = NSCustomTouchBarItem(identifier: identifier)
let button = NSButton(title: "Button!", target: self, action: #selector(escTapped))
item.view = button
return item
case NSTouchBarItemIdentifier.yourUsualIdentifier:
let item = NSCustomTouchBarItem(identifier: identifier)
item.view = NSTextField(labelWithString: "Example")
touchBar.escapeKeyReplacementItemIdentifier = .identifierForESCItem
return item
default:
return nil
}
}
func escTapped() {
// do additional logic when user taps ESC (optional)
}
I also suggest making an extension (category) for the identifiers, it avoids making typos with string literals:
#available(OSX 10.12.2, *)
extension NSTouchBarItemIdentifier {
static let identifierForESCItem = NSTouchBarItemIdentifier("com.yourdomain.yourapp.touchBar.identifierForESCItem")
static let yourUsualIdentifier = NSTouchBarItemIdentifier("com.yourdomain.yourapp.touchBar.yourUsualIdentifier")
}

Binding and NSPopUpButton: changing the value of the current key doesn't change the selection

I've a really simple UI with a single NSPopUpButton. Its selectedIndex is bound to an int value ViewController.self.my_selection. As soon as I change the selection from the UI (i.e. a select the third item of the NSPopUpButton) I see that my_selection value changes. So far so good, what I'm trying to obtain is the opposed direction though. I want to change the my_selection value programmatically and see the NSPopUpButton selecting the item a the index that I've defined in my_selection. I erroneously supposed that behaviour was the default behaviour for bindings...
This is what I'm obtaining now:
NSPoPUpButton ---> select item at index 2 ----> my_selection becomes equal to 2
This is what I want to achieve (keeping also the previous behaviour)
my_selection ---> set value to 3----> NSPoPUpButton selected index = 3
Without a bit more info (see my comment) it's hard to see exactly what you're doing wrong. Here's how I got it working: First, create a simple class...
// Create a simple class
class Beatle: NSObject {
convenience init(name: String) {
self.init()
self.name = name
}
dynamic var name: String?
}
Then, in the AppDelegate I created a four-item array called beatles:
dynamic var beatles: [Beatle]?
func applicationDidFinishLaunching(aNotification: NSNotification) {
beatles = [Beatle(name: "John"),
Beatle(name: "Paul"),
Beatle(name: "Ringo"),
Beatle(name: "George")]
}
In Interface Builder I set things up so that this array provides the pop-up with its content:
This class also has a selectedIndex property that is bound to the pop-up button's selectedIndex binding - this property provides read-write access to the pop-up button's selection:
// Set the pop-up selection by changing the value of this variable.
// (Make sure you mark it as dynamic.)
dynamic var selectedIndex: Int = 0

Swift mac os - Re-enable a segmented control cell

I'm trying to create a UI in which the user can select/deselect multiple items.
I choose to use an segemented control to do such a thing and it's perfect for that use.
The behavior I want it to have is
1) an item must be set to disabled when clicked on
2) an item must be set to enabled when clicked on
I implemented step 1 but can't do step 2 as when the cell is set to disabled it won't send any event
Here's the code
#IBOutlet weak var segBtnStrings: NSSegmentedControl!
#IBAction func setStringState(sender: AnyObject) {
var segment = sender.selectedSegment
var enable = segBtnStrings.isEnabledForSegment(segment)
segBtnStrings.setEnabled(!enable, forSegment: segment)
}

NSTextField controlTextDidEndEditing: called while being edited (inside an NSOutlineView)

In my NSOutlineView, I have a NSTextField inside a NSTableCellView. I am listening for the controlTextDidEndEditing: notification to happen when the user finishes the editing. However, in my case, this notification is being fired even while the user is in the middle of typing, or takes even a second-long pause in typing. This seems bizarre. I tested a NSTextField in the same view, but outside of the NSOutlineView, and it doesn't behave this way; it only calls controlTextDidEndEditing: if the user pressed the Tab or Enter keys (as expected).
Is there something I can do to prevent the NSTextField from sending controlTextDidEndEditing: unless a Enter or Tab key is pressed?
Found a solution for this:
- (void)controlTextDidEndEditing:(NSNotification *) notification {
// to prevent NSOutlineView from calling controlTextDidEndEditing by itself
if ([notification.userInfo[#"NSTextMovement"] unsignedIntegerValue]) {
....
It's an old question, but for reference, I ran into a similar problem where controlTextDidEndEditing: was called at the beginning of the editing session.
My workaround is to check if the text field still has the focus (i.e. cursor):
func controlTextDidEndEditing(_ obj: Notification) {
guard
let textField = obj.object as? NSTextField,
!textField.isFocused
else {
return
}
...
}
public extension NSTextField
{
public var isFocused:Bool {
if
window?.firstResponder is NSTextView,
let fieldEditor = window?.fieldEditor(false, for: nil),
let delegate = fieldEditor.delegate as? NSTextField,
self == delegate
{
return true
}
return false
}
}
Note to self:
I ran into this problem when adding a new item to NSOutlineView and making it editable with NSOutlineView.editColumn(row:,with:,select).
controlTextDidEndEditing() would be called right away at the start of the editing session.
It turns out it was a first responder/animation race condition. I used a NSTableView.AnimationOptions.slideDown animation when inserting the row and made the row editable afterwards.
The problem here is that the row is made editable while it is still animating. When the animation finishes, the first responder changes to the window and back to the text field, which causes controlTextDidEndEditing() to be called.
outlineView.beginUpdates()
outlineView.insertItems(at: IndexSet(integer:atIndex),
inParent: intoParent == rootItem ? nil : intoParent,
withAnimation: .slideDown) // Animating!
outlineView.endUpdates()
// Problem: the animation above won't have finished leading to first responder issues.
self.outlineView.editColumn(0, row: insertedRowIndex, with: nil, select: true)
Solution 1:
Don't use an animation when inserting the row.
Solution 2:
Wrap beginUpdates/endUpdates into an NSAnimationContext group, add a completion handler to only start editing once the animation finished.
Debugging tips:
Observe changes to firstResponder in your window controller
Put a breakpoint in controlTextDidEndEditing() and take a very close look at the stack trace to see what is causing it to be called. What gave it away in my case were references to animation calls.
To reproduce, wrap beginUpdates/endUpdates in an NSAnimationContext and increase the animation duration to a few seconds.

Resources