I have one RACSignal that carries NSArray of objects of class Bookmark
RACSignal *bookmarksSignal = ... // Carries #[[[Bookmark alloc] init], ...]
And two RACCommands to add and delete bookmark
RACCommand *addBookmarkCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input){
return [RACSignal return:input];
}];
...
[addBookmarkCommand execute: bookmark1];
RACCommand *deleteBookmarkCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input){
return [RACSignal return:input];
}];
...
[deleteBookmarkCommand execute: bookmark2]
Commands are executed on user interaction. How can I combine bookmarksSignal and executions signals of both commands to create signal that carries NSArray of bookmarks that contains original bookmarks with those added by addBookmarkCommand and without those removed by deleteBookmarkCommand?
(bookmarks are compared by url property)
I'm afraid that I'm missing something obvious, but I can't figure out how to do this in pure way.
For lack of time, I'll go with a more laconic answer compared to #IanHenry's :)
For anything here that doesn't make sense, please comment and I'll be happy explain in detail.
// Map additions into blocks that add the bookmark to the given mutable array.
RACSignal *addBookmarkSignal = [[addBookmarkCommand concat] map:^(Bookmark *bookmark) {
return ^(NSMutableArray *bookmarks) {
return [bookmarks addObject:bookmark];
};
}];
// Map deletions into blocks that remove the bookmark from the given mutable array.
RACSignal * addBookmarkSignal = [[deleteBookmarkCommand concat] map:^(Bookmark *bookmark) {
return ^(NSMutableArray *bookmarks) {
[bookmarks removeObject:bookmark];
};
}];
// Combine the add and delete functions into a single signal.
RACSignal *updatesSignal = [RACSignal merge:#[addBookmarkSignal, addBookmarkSignal]];
RACSignal *updatedBookmarksSignal = [[bookmarksSignal
// Each time bookmarksSignal sends an array, this -map: builds a
// signal that updates the latest list of bookmarks, and sends it.
map:^(NSArray *bookmarks) {
NSMutableArray *mutableBookmarks = [bookmarks mutableCopy];
// Using the update blocks from the add/delete commands,
// produce the modified list of bookmarks.
return [[updatesSignal
map:^(void (^update)(NSMutableArray *)) {
update(mutableBookmarks);
return [mutableBookmarks copy];
}]
startWith:bookmarks];
}]
// When bookmarksSignal sends anew, switch to the newest signal of updates.
switchToLatest];
Let's forget about RACCommand for a second, and pretend that we just have a signal of bookmarks to add and a signal of bookmarks to remove and we want to create a signal of sets of bookmarks based on that. It's a good starting point, and we can figure out how to implement those after the fact.
RACSignal *addedSignal = ...;
RACSignal *removedSignal = ...;
Then we want a signal that's the combination of those two signals into a single set of all the added things without anything removed afterwards (we can map it into an NSArray after the fact if we want to).
RACSignal *bookmarkSetSignal = ...;
Now we have to fill it in. We could optimize here by making a mutable set that we modify and just send references to that same set every time a change happens. But that's sort of contrary to the nature of signals. Let's put that optimization on hold for a minute and do it the pure, functional way.
We're going to use the scanWithStart:reduce: method, because it fits this problem perfectly. It's like a fold that returns every intermediate value, which is exactly what we want.
But first we have to make addedSignal and removedSignal useful. Here's my idea: merge them into a single signal, but attach another value to them that says whether it's an add or a remove.
// turn a signal of bookmarks into a signal of tuples of the form (bookmark, isAdded)
RACSignal *changes = [RACSignal merge:#[[addedSignal map:^(id x) { return RACTuplePack(x, #YES); }],
[removedSignal map:^(id x) { return RACTuplePack(x, #NO); }]]];
Now that it's just one signal, it'll be a little easier to wrangle it. Then we can fold those changes into a single value (the composition of all the changes), except that we're reporting every step of the way. So it's a scan, not a fold. But scan makes a bad verb. Anyway:
RACSignal *bookmarkSetSignal = [changes scanWithStart:[NSSet set] reduce:^(NSSet *running, RACTuple *next) {
RACTupleUnpack(Bookmark *bookmark, NSNumber *isAddingNumber) = next;
if (isAddingNumber.boolValue) {
[running setByAddingObject:bookmark];
} else {
// you can do this with a much nicer helper, but this is the shortest way for this answer...
return [running filteredSetUsingPredicate:[NSPredicate predicateWithFormat:#"url != %#", bookmark.url]];
}
}];
Great! We started with an empty set, and every time a change occurred we created a new set by adding or removing that element. running is always whatever we computed last time (starting from the empty set), and next is the description of the change that should happen (a bookmark + whether or not it was being added or removed). We now have a signal of sets of bookmarks, just like we wanted!
Except now we need to fill out addedSignal and removedSignal.
The exact way we do this is a little...well, it depends on the user interaction. We could make each one a subject, and then user interaction would manually send new values. That might be the right way to do it. It's cleaner than manually triggering an RACCommand. Anyway, that's a separate question. Assuming the exact RACCommand format that you have now, I think we can implement it like this:
RACSignal *addedSignal = [self.addBookmarkCommand.executionSignals switchToLatest];
RACSignal *removedSignal = [self.deleteBookmarkCommand.executionSignals switchToLatest];
executionSignals is a signal of every signal returned in the signal block. Which are merely [RACSignal return:bookmark]. By switchingToLatest, we're basically "unwrapping" that value. But if we used a subject we wouldn't need to wrap/unwrap in the first place. But anyway, separate discussion.
This code will almost certainly require a little modification to do what you want (haven't tested it), but hopefully this'll point you in the right direction idea-wise.
Related
I have a custom NSTextView subclass, with a custom NSTextStorage component as well. The NSTextStorage modifies the text entered by the user based on context.
Because it's possible that the final text will be shorter than the text originally entered by the user, I had to override insertText:replacementRange in my NSTextView. A minimum example is:
- (void) insertText:(id)string replacementRange:(NSRange)replacementRange {
if ([self hasMarkedText]) {
[[self textStorage] replaceCharactersInRange:[self markedRange] withString:string];
} else {
[[self textStorage] replaceCharactersInRange:[self selectedRange] withString:string];
}
[self didChangeText];
}
This works fine in extensive testing over several months.... Except that automatic spell checking and correction is disabled. The "squigglies" don't appear under misspelled words, unless I stop typing, move the mouse, and switch focus to and from my app. After several seconds, the entire textview is spellcheck'ed. Because it happens after the fact, automatic correction is disabled of course.
If I disable my custom insertText:replacementRange: method, everything else works fine, and automatic spelling functionality returns. I just have to be careful not to trigger a change that results in shortening the text, as it triggers attribute out of range errors (the original reason for my custom method in the first place.)
Apparently Apple's implementation of insertText:replacementRange: does much more than mine. I have tried multiple variations on [self checkTextInRange...], [self checkTextInSelection:], etc. None of them restore proper functionality.
Searching Apple's documentation doesn't help point me towards what I am leaving out from my method that is causing spell checking to break. Any pointers or ideas would be much appreciated!!
Thanks in advance!
EDIT: Here are some examples of the sorts of behavior my NSTextStorage provides. (| represents the insertion caret)
Starting with:
* item
* |
If I hit the return key, I end up with the following (deleting *<space>):
* item
|
Another example, if "Change Tracking" is enabled:
this is thee| time
If I hit delete:
this is the|{--e--} time
As you can see, a single keystroke may result in the addition or deletion of multiple characters from the text.
EDIT 2: FYI -- the issue I have with attributes being out of range occur when the shortening happens while pressing return at the end of the document -- NSTextview attempts to set a new paragraph style only to find that the document is shorter than expected. I can find no way to change the range NSTextview targets.
I have a partial solution.
In my custom insertText:replacementRange: method, prior to didChangeText:
NSinteger wordCount;
NSOrthography * orthography;
static NSInteger theWordCount;
NSOrthography * orthography;
NSRange spellingRange = <range to check>
NSArray * results = [[NSSpellChecker sharedSpellChecker] checkString:[[self textStorage] string]
range:spellingRange
types:[self enabledTextCheckingTypes]
options:NULL
inSpellDocumentWithTag:0
orthography:&orthography
wordCount:&theWordCount];
if (results.count) {
[self handleTextCheckingResults:results forRange:spellingRange types:[self enabledTextCheckingTypes] options:#{} orthography:orthography wordCount:theWordCount];
}
However, this is incomplete:
Spell check and Grammar check works fine
Automatic spelling correction and text replacement do not work (even when enabled)
(EDITED 2018-05-30)
Updated response (2018-05-22):
This issue reared its ugly head again, and I really needed to figure it out.
My custom NSTextStorage is fundamentally the same as described, and still works.
I use a custom insertText:replacementRange: on my NSTextView, but it calls [super insertText:replacementRange:] to take advantage of Apple's behind-the-scenes work that makes spelling, etc. work better. My custom method only needs to set a boolean.
When shortening the text, I still get requests from Apple's insertText:replacementRange: for attributes in a non-existent part of the text. Previously, I would get stuck here, because everything I tried either caused a crash, or caused Apple's code to repeatedly request the non-existing attributes indefinitely.
Finally, I tried returning fake attributes with a NULL rangepointer, and this seems to make Apple's code happy:
- (NSDictionary *) attributesAtIndex:(NSUInteger)location effectiveRange:(nullable NSRangePointer)range {
if (location > _backingAttributes.length) {
// This happens if we shrink the text before the textview is aware of it.
// For example, if we expand "longtext" -> "short" in our smart string, then
// The textview may set and request attributes past the end of our
// _backing string.
// Initially this was due to error in my code, but now I had to add
// This error checking back
NSLog(#"get attributes at (%lu) in (%lu)", (unsigned long)location, (unsigned long)_backingAttributes.length);
NSLog(#"error");
// Apparently returning fake attributes satisfies [NSTextView insertText:replacementRange:]
range = NULL;
return #{
NSForegroundColorAttributeName : [BIColor redColor],
NSFontAttributeName : [BIFont fontWithName:#"Helvetica" size:14.0]
};
} else {
return [_backingAttributes attributesAtIndex:location effectiveRange:range];
}
}
With further testing, this turned out to not be quite enough. I ended up adding the following to the setter to store the invalid attributes and range that macOS was trying to set:
- (void) setAttributes:(NSDictionary<NSString *,id> *)attrs range:(NSRange)range {
if (NSMaxRange(range) > _backingAttributes.length) {
_invalidAttrs = attrs;
_invalidRange = range;
} else {
[self beginEditing];
[_backingAttributes setAttributes:attrs range:range];
[self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
[self endEditing];
}
}
I updated `attributesAtIndex:effectiveRange: to return the following when called with an invalid range, rather than returning the fake attributes above:
// Apparently returning fake attributes satisfies [NSTextView insertText]
*range = _invalidRange;
return _invalidAttrs;
This seems to work under various conditions that would previously trigger an exception or an infinite loop.
I'm reading the Key-Value Observing guide from Apple, and it hasn't explained one point in detail.
My question is: say I have a name property that's dependent on firstName and lastName, and I also have another property group that's dependent on name and gender.
Following the guide, I've written this
+ (NSSet *)keyPathsForValuesAffectingName {
return [NSSet setWithObjects:#"lastName",#"firstName",nil];
}
When writing the corresponding method for group, can I write it like this
+ (NSSet *)keyPathsForValuesAffectingGroup {
return [NSSet setWithObjects:#"name",#"gender",nil];
}
Or I have to add all the related properties
+ (NSSet *)keyPathsForValuesAffectingGroup {
return [NSSet setWithObjects:#"firstName",#"lastName",#"gender",nil];
}
Note that both group and name are computed, readonly properties.
If your questions means ...
Can keyPathsForValuesAffecting<key> be chained to indirectly affect keys through several dependant properties?
... then the answer is yes.
Edit: Apple's documentation on keyPathsForValuesAffectingValueForKey: confirms support for indirect dependent notifications.
Your last comment seems to suggest that you expect KVO notifications (indirect or not) being sent when directly changing an ivar. That's not the case. KVO relies on using the proper setter or on manually announce the change by sending will/didChangeValueForKey:
I'm pretty sure, that this will work, because it would be hard to let it not work.
Let us imagine, that you have three external (= different objects) observers for the properties.
What happens:
A. The opening door for KVO is a message didChangeValueForKey: to the observed object. If you have turned on automatic KVO, this message is sent automatically, when the setter for the key is called. (This is done in a dynamic generated subclass, which overwrites the setter.)
B. Then KVO code is triggered that looks semantically like this: (And it is simplified for only supporting keys, but not key paths.)
NSSet *observers = kvoBucket[key];
for( id observer in observers )
{
[observer observeValue:[self valueForKey:key] forKeyPath:key …]
}
C. With affecting keys there has to be a second loop, informing about a change of the depending properties. (I assume, that this list is generated at the initialization of the class' object or adding the observation.)
// Inform observers
NSSet *observers = kvoBucket[key];
for( id observer in observers )
{
[observer observeValue:[self valueForKey:key] forKeyPath:key …]
}
// Scan for affected properties
NSSet *keyOfaffectedProperties = affectedPropertiesBucket[key];
for( NSString *key in keyOfaffectedProperties )
{
// trigger kvo for affected property
}
No let us sit down on the developer's chair. He can duplicate the first loop into the second loop. In this case it would not work. But this is duplicate code. (And the real code is more complicated for sure.) Why should he do that? Everyone of us would say: I already have this code! Let's use it again:
- (void)informOberserversForChangeOfKey:(NSString*)key
// Inform observers
NSSet *observers = kvoBucket[key];
for( id observer in observers )
{
[observer observeValue:[self valueForKey:key] forKeyPath:key …]
}
// Scan for affected properties
NSSet *keyOfaffectedProperties = affectedPropertiesBucket[key];
for( NSString *keyOfAffectedProperty in keyOfaffectedProperties )
{
[self informObserversForChangeOfKey:keyOfAffectedProperty];
}
If there is no optimization, which makes recursion impossible or inconvenient, it should work. I would bet it works – But only a small amount of money. ;-)
Most people here have got the right idea but to confirm this I've set up a test project with exactly the same code mentioned in my question.
And the result: it works for both ways.
When I call undo on the context following deletion of a single object, all works as expected. But if user deletes an object, then deletes another object, undo will work only to restore the second object, no matter how many times user requests undo, as though undoLevels were set to 1. This happens whether undoLevels is at the default of 0 (unlimited) or is explicitly set to 6 as a test.
Furthermore, if a single action deletes multiple objects, calling undo afterward has no effect; none of the objects is restored. I tried explicitly bracketing the deletion loop with begin/endUndoGrouping, to no avail. The undoManager’s groupsByEvent is YES (by default), but it makes no difference whether I call a straight undo or undoNestedGroup.
Is the context somehow being saved after each operation? No, because if I quit and relaunch the app after running these tests, all objects are still present in the database.
What am I missing?
OK, you want code. Here’s what I imagine is most relevant:
Context getter:
- (NSManagedObjectContext *) managedObjectContextMain {
if (managedObjectContextMain) return managedObjectContextMain;
NSPersistentStoreCoordinator *coordinatorMain = [self persistentStoreCoordinatorMain];
if (!coordinatorMain) {
// present error...
return nil;
}
managedObjectContextMain = [[NSManagedObjectContext alloc] init];
[managedObjectContextMain setPersistentStoreCoordinator: coordinatorMain];
// Add undo support. (Default methods don't include this.)
NSUndoManager *undoManager = [[NSUndoManager alloc] init];
// [undoManager setUndoLevels:6]; // makes no difference
[managedObjectContextMain setUndoManager:undoManager];
[undoManager release];
// ...
return managedObjectContextMain;
}
Multiple-object deletion method (called by a button on a modal panel):
/*
NOTE FOR SO:
SpecialObject has a to-one relationship to Series.
Series has a to-many relationship to SpecialObject.
The deletion rule for both is Nullify.
Series’ specialObject members need to be kept in a given order. So Series has a transformable attribute, an array of objectIDs, used to prepare a transient attribute, an array of specialObjects, in the same order as their objectIDs.
*/
- (void) deleteMultiple {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
NSUndoManager *undoMgr = [contextMain undoManager];
[undoMgr beginUndoGrouping];
// Before performing the actual deletion, drop the seln in the locator table.
[appDelegate.objLocatorController.tvObjsFound deselectAll:self];
// Get the indices of the selected objects and enumerate through them.
NSIndexSet *selectedIndices = [appDelegate.objLocatorController.tvObjsFound selectedRowIndexes];
NSUInteger index = [selectedIndices firstIndex];
while (index != NSNotFound) {
// Get the obj to be deleted and its series.
SpecialObject *sobj = [appDelegate.objLocatorController.emarrObjsLoaded objectAtIndex:index];
Series *series = nil;
series = sobj.series;
// Just in case...
if (!series) {
printf("\nCESeries' deleteMultiple was called when Locator seln included objs that are not a part of a series. The deletion loop has therefore aborted.");
break;
}
// Get the obj's series index and delete it from the series.
// (Series has its own method that takes care of both relnshp and cache.)
NSUInteger uiIndexInSeries = [series getSeriesIndexOfObj:sobj];
[series deleteObj:sobj fromSeriesIndex:uiIndexInSeries];
// Mark the special object for Core Data deletion; it will still be a non-null object in emarrObjsLoaded (objLocatorController’s cache).
[contextMain deleteObject:sobj];
// Get the next index in the set.
index = [selectedIndices indexGreaterThanIndex:index];
}
[undoMgr endUndoGrouping];
// Purge the deleted objs from loaded, which will also reload table data.
[appDelegate.objLocatorController purgeDeletedObjsFromLoaded];
// Locator table data source has changed, so reload. But end with no selection. (SeriesBox label will have been cleared when Locator seln was dropped.)
[appDelegate.objLocatorController.tvObjsFound reloadData];
// Close the confirm panel and stop its modal session.
[[NSApplication sharedApplication] stopModal];
[self.panelForInput close];
}
Here’s the Series method that removes the object from its relationship and ordered cache:
/**
Removes a special object from the index sent in.
(The obj is removed from objMembers relationship and from the transient ordered obj cache, but it is NOT removed from the transformable array of objectIDrepns.)
*/
- (void) deleteObj:(SpecialObject *)sobj fromSeriesIndex:(NSUInteger)uiIndexForDeletion {
// Don't proceed if the obj is null or the series index is invalid.
if (!sobj)
return;
if (uiIndexForDeletion >= [self.emarrObjs count])
return;
// Use the safe Core Data method for removing the obj from the relationship set.
// (To keep it private, it has not been declared in h file. PerformSelector syntax here prevents compiler warning.)
[self performSelector:#selector(removeObjMembersObject:) withObject:sobj];
// Remove the obj from the transient ordered cache at the index given.
[self.emarrObjs removeObjectAtIndex:uiIndexForDeletion];
// But do NOT remove the obj’s objectID from the transformable dataObjIDsOrdered array. That doesn't happen until contextSave. In the meantime, undo/cancel can use dataObjIDsOrdered to restore this obj.
}
Here’s the method, and its follow-up, called by comm-z undo:
- (void) undoLastChange {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
// Perform the undo. (Core Data has integrated this functionality so that you can call undo directly on the context, as long as it has been assigned an undo manager.)
// [contextMain undo];
printf("\ncalling undo, with %lu levels.", [contextMain.undoManager levelsOfUndo]);
[contextMain.undoManager undoNestedGroup];
// Do cleanup.
[self cleanupFllwgUndoRedo];
}
- (void) cleanupFllwgUndoRedo {
Flixen_Foundry_AppDelegate *appDelegate = [[NSApplication sharedApplication] delegate];
NSManagedObjectContext *contextMain = [appDelegate managedObjectContextMain];
DataSourceCoordinator *dataSrc = appDelegate.dataSourceCoordinator;
// ...
// Rebuild caches of special managed objects.
// (Some managed objects have their own caches, i.e. Series' emarrObjs. These need to be refreshed if their membership has changed. There's no need to use special trackers; the context keeps track of these.)
for (NSManagedObject *obj in [contextMain updatedObjects]) {
if ([obj isKindOfClass:[Series class]] && ![obj isDeleted])
[((Series *)obj) rebuildSeriesCaches];
}
// ...
// Regenerate locator's caches.
[appDelegate.objLocatorController regenerateObjCachesFromMuddies]; // also reloads table
}
Here’s the series method that regenerates its caches following undo/awake:
- (void) rebuildSeriesCaches {
// Don't proceed if there are no stored IDs.
if (!self.dataObjIDsOrdered || [self.dataObjIDsOrdered count] < 1) {
// printf to alert me, because this shouldn’t happen (and so far it doesn’t)
return;
}
NSMutableArray *imarrRefreshedObjIdsOrdered = [NSMutableArray arrayWithCapacity:[self.objMembers count]];
NSMutableArray *emarrRefreshedObjs = [NSMutableArray arrayWithCapacity:[self.objMembers count]];
// Loop through objectIDs (their URIRepns) that were stored in transformable dataObjIDsOrdered.
for (NSURL *objectIDurl in self.dataObjIDsOrdered) {
// For each objectID repn, loop through the objMembers relationship, looking for a match.
for (SpecialObject *sobj in self.objMembers) {
// When a match is found, add the objectID repn and its obj to their respective replacement arrays.
if ([[sobj.objectID URIRepresentation] isEqualTo:objectIDurl]) {
[imarrRefreshedObjIdsOrdered addObject:objectIDurl];
[emarrRefreshedObjs addObject:sobj];
break;
}
// If no match is found, the obj must have been deleted; the objectID repn doesn't get added to the replacement array, so it is effectively dropped.
}
}
// Assign their replacement arrays to the transformable and transient attrs.
self.dataObjIDsOrdered = imarrRefreshedObjIdsOrdered;
self.emarrObjs = emarrRefreshedObjs;
}
(I’ve omitted the Locator’s regenerateObjCachesFromMuddies because, although I am using its table to view the results of the deletion and undo, I can reload the table with a new fetch, completely regenerating the table’s caches, and this test still shows that the undo isn’t working.)
As usual, just the task of putting together a SO question helps solve the problem, and I realize now that undo works fine as long as I’m working with simple objects that don’t involve the reciprocal SpecialObject-Series relationship. I’m doing something wrong there...
I think you're getting into a fight with custom undo stuff and Core Data's automagic support.
In normal undo/redo code, you have undoable funnel points. Usually an undoable add and its inverse undoable remove. Calling one registers the other as the inverse action and vice-versa. User undo/redo then just goes back and forth between them. You separate your "user created a new Foo" code from your "now add this foo to the collection undoably" code (that way "remove Foo" and "add Foo" work independently of supplying a newly-created Foo).
With Core Data, add and remove means "insert into the context and remove from the context". Also, you still need custom funnel methods because (in your case), you're doing some additional stuff (updating a cache). This is easy enough to do with a Foo, but what happens when you want to manipulate the relationship between a Foo/Bar assembly that gets created in one action?
If creating a Foo created a few Bars with it, it'd be one thing (-awakeFromInsert and the like) since you'd only have to deal with updating your caching (which you could do, by the way, through key/value observing the context for changes). Since creating a Foo seems to establish relationships with existing Bars (which are already in the context), you run into a difficult wall when trying to cooperate with CD's built-in undo support.
There is no easy solution in this case if you're using the built-in Core Data undo/redo support. In this case, you can do as this post suggests and turn it off. You can then handle undo/redo entirely yourself ... but you'll have a lot of code to write to observe your objects for changes to interesting attributes, registering the inverse action for each.
While it isn't a solution to your problem, I hope it at least points out the complexity of what you're trying to do and gives you a possible path forward. Without knowing a LOT more about your model (at the conceptual level at least) and how your UI presents it to the user, it's hard to give specific architectural advice.
I hope I'm wrong about this case - maybe someone else can give you a better answer. :-)
It turns out that you can have Foo creation that involves changing relationships with pre-existing Bars, and custom caches, and NSUndoManager can still handle it all — but with a kink: You have to save the context after each such change; otherwise the undo manager will cease to function.
Since undo can actually reach back to states before the save, this is not such a bad thing. It does complicate matters if you want the user to be able to revert to the state when they last chose to save, but that can be handled by making a copy of the database whenever the user chooses to save.
So in the deleteMultiple method, following the while deletion loop, I added a call to save the context.
There’s another error in my scheme, which is that I erroneously thought NSUndoManager would ignore transformable attributes. Well, obviously, since transformable attrs are persisted, they are tracked by the persistentStoreCoordinator and are therefore included in undo operations. So when I failed to update the xformable attr array upon deletion, thinking I would need its info for restoration in the event of undo, I was ruining the action/inverse-action symmetry.
So in the deleteObject:fromSeriesIndex method, the Series method that handles the caches, I added this code, updating the transformable ObjectID array:
NSMutableArray *emarrRemoveID = [self.dataObjIDsOrdered mutableCopy];
[emarrRemoveID removeObjectAtIndex:uiIndexForDeletion];
self.dataObjIDsOrdered = emarrRemoveID;
[emarrRemoveID release];
(My assumption that the NSUndoManager would ignore the transient cache was correct. The call to rebuildSeriesCaches in cleanupFllwgUndoRedo takes care of that.)
Undo now works, both for simple objects and for objects in SpecialObject-Series relationships. The only remaining problem is that it takes more than one command-Z to happen. I’ll have to experiment more with the groupings…
EDIT: It isn’t necessary to save the context post-deletion if the managed object’s custom caches are handled correctly:
1) The caches should NOT be rebuilt following undo. The undo manager will take care of this on its own, even for the transient cache, as long as the transient property is included in the managed object model.
2) When changing the NSMutableArray cache (emarrObjs), using removeObjectAtIndex alone will confuse the undo manager. The entire cache must be replaced, the same way it is with the NSArray cache dataObjIDsOrdered.
I am writing a special-purpose text editor in cocoa that does things like automatic text substitution, inline text completions (ala Xcode), etc.
I need to be able to programmatically manipulate the NSTextView’s NSTextStorage in response to 1) user typing, 2) user pasting, 3) user dropping text.
I have tried two different general approaches and both of them have caused the NSTextView’s native undo manager to get out of sync in different ways. In each case, I am only using NSTextView delegate methods. I have been trying to avoid subclassing NSTextview or NSTextStorage (though I will subclass if necessary).
The first approach I tried was doing the manipulations from within the textView delegate’s textDidChange method. From within that method, I analyzed what had been changed in the textView and then called a general purpose method for modifying text that wrapped the changes in the textStorage with calls to shouldChangeTextInRange: and didChangeText:. Some of the programmatic changes allowed clean undo’s but some did not.
The second (and maybe more intuitive because it makes changes before the text actually appears in the textView) approach I tried was doing the manipulations from within the delegate’s shouldChangeTextInRange: method, again using the same general purpose storage modification method that wraps changes in the storage with a call to shouldChangeTextInRange: and didChangeText:. Since these changes were being triggered originally from within shouldChangeTextInRange:, I set a flag that told the inner call to shouldChangeTextInRange: to be ignored so as not to enter recursive blackholeness. Again, Some of the programmatic changes allowed clean undo’s but some did not (though different ones this time, and in different ways).
With all that background, my question is, can someone point me to a general strategy for programmatically manipulating the storage of an NSTextview that will keep the undo manager clean and in sync?
In which NSTextview delegate method should I pay attention to the text changes in the textView (via typing, pasting, or dropping) and do the manipulations to the NSTextStorage? Or is the only clean way to do this by subclassing either NSTextView or NSTextStorage?
I originally posted a similar question fairly recently (thanks to OP for pointing from there back to this question).
That question was never really answered to my satisfaction, but I do have a solution to my original problem which I believe also applies to this.
My solution is not use to the delegate methods, but rather to override NSTextView. All of the modifications are done by overriding insertText: and replaceCharactersInRange:withString:
My insertText: override inspects the text to be inserted, and decides whether to insert that unmodified, or do other changes before inserting it. In any case super's insertText: is called to do the actual insertion. Additionally, my insertText: does it's own undo grouping, basically by calling beginUndoGrouping: before inserting text, and endUndoGrouping: after. This sounds way too simple to work, but it appears to work great for me. The result is that you get one undo operation per character inserted (which is how many "real" text editors work - see TextMate, for example). Additionally, this makes the additional programmatic modifications atomic with the operation that triggers them. For example, if the user types {, and my insertText: programmatically inserts }, both are included in the same undo grouping, so one undo undoes both. My insertText: looks like this:
- (void) insertText:(id)insertString
{
if( insertingText ) {
[super insertText:insertString];
return;
}
// We setup undo for basically every character, except for stuff we insert.
// So, start grouping.
[[self undoManager] beginUndoGrouping];
insertingText = YES;
BOOL insertedText = NO;
NSRange selection = [self selectedRange];
if( selection.length > 0 ) {
insertedText = [self didHandleInsertOfString:insertString withSelection:selection];
}
else {
insertedText = [self didHandleInsertOfString:insertString];
}
if( !insertedText ) {
[super insertText:insertString];
}
insertingText = NO;
// End undo grouping.
[[self undoManager] endUndoGrouping];
}
insertingText is an ivar I'm using to keep track of whether text is being inserted or not. didHandleInsertOfString: and didHandleInsertOfString:withSelection: are the functions that actually end up doing the insertText: calls to modify stuff. They're both pretty long, but I'll include an example at the end.
I'm only overriding replaceCharactersInRange:withString: because I sometimes use that call to do modification of text, and it bypasses undo. However, you can hook it back up to undo by calling shouldChangeTextInRange:replacementString:. So my override does that.
// We call replaceChractersInRange all over the place, and that does an end-run
// around Undo, unless you first call shouldChangeTextInRange:withString (it does
// the Undo stuff). Rather than sprinkle those all over the place, do it once
// here.
- (void) replaceCharactersInRange:(NSRange)range withString:(NSString*)aString
{
if( [self shouldChangeTextInRange:range replacementString:aString] ) {
[super replaceCharactersInRange:range withString:aString];
}
}
didHandleInsertOfString: does a whole buncha stuff, but the gist of it is that it either inserts text (via insertText: or replaceCharactersInRange:withString:), and returns YES if it did any insertion, or returns NO if it does no insertion. It looks something like this:
- (BOOL) didHandleInsertOfString:(NSString*)string
{
if( [string length] == 0 ) return NO;
unichar character = [string characterAtIndex:0];
if( character == '(' || character == '[' || character == '{' || character == '\"' )
{
// (, [, {, ", ` : insert that, and end character.
unichar startCharacter = character;
unichar endCharacter;
switch( startCharacter ) {
case '(': endCharacter = ')'; break;
case '[': endCharacter = ']'; break;
case '{': endCharacter = '}'; break;
case '\"': endCharacter = '\"'; break;
}
if( character == '\"' ) {
// Double special case for quote. If the character immediately to the right
// of the insertion point is a number, we're done. That way if you type,
// say, 27", it works as you expect.
NSRange selectionRange = [self selectedRange];
if( selectionRange.location > 0 ) {
unichar lastChar = [[self string] characterAtIndex:selectionRange.location - 1];
if( [[NSCharacterSet decimalDigitCharacterSet] characterIsMember:lastChar] ) {
return NO;
}
}
// Special case for quote, if we autoinserted that.
// Type through it and we're done.
if( lastCharacterInserted == '\"' ) {
lastCharacterInserted = 0;
lastCharacterWhichCausedInsertion = 0;
[self moveRight:nil];
return YES;
}
}
NSString* replacementString = [NSString stringWithFormat:#"%c%c", startCharacter, endCharacter];
[self insertText:replacementString];
[self moveLeft:nil];
// Remember the character, so if the user deletes it we remember to also delete the
// one we inserted.
lastCharacterInserted = endCharacter;
lastCharacterWhichCausedInsertion = startCharacter;
if( lastCharacterWhichCausedInsertion == '{' ) {
justInsertedBrace = YES;
}
return YES;
}
// A bunch of other cases here...
return NO;
}
I would point out that this code isn't battle-tested: I've not used it in a shipping app (yet). But it is a trimmed down version of code I'm currently using in a project I intend to ship later this year. So far it appears to work well.
In order to really see how this works you probably want an example project, so I've posted one on github.
Right, this is by no means a perfect solution, but it is a solution of sorts.
The text storage updates the undo manager based off "groups". These groups cluster together a series of edits (which I can't quite remember of the top of my head), but I do remember that a new one is created when the selection is altered.
This leads to the possible solution of quickly changing the selection to something else and then reverting it back. Not an ideal solution but it may be enough to force the text storage to push a new state to the undo manager.
I shall take a bit more of a look and investigation and see if I can't find/trace exactly what happens.
edit: I should probably mention that it's been a while since I've used NSTextView and don't currently have access to Xcode on this machine to verify that this works still. Hopefully it will.
Recently I've been seeing the word "context" used in method names in Cocoa, but I don't understand the meaning. I've seen it in places like Core Data (NSManagedObjectContext) and in Core Graphics (CGBitmapContextCreate) but it seems to be used all over (NSSet, NSArray, NSObject). I'm assuming that it comes from the c world.
What is this context they're talking about?
It's just terminology, the contexts you mention are unrelated. The word context is usually used to describe a particular "working space".
For example, a CGContextRef or NSGraphicsContext stores a graphics space that you can perform drawing operations in.
NSManagedObjectContext stores a "working set" of NSManagedObjects for a particular persistent store.
The documentation for each API describes in detail what each of these contexts are.
There is no particular meaning, but there are two common ones.
First one is connected with databases, persistence layers, graphics and such beasts where you need some notion of a ‘scope’, ‘connection’ or a ‘state’. For example when saving data into a database you usually need to open the database and then save some DB ‘handle’ you will refer to in subsequent operations. There can be many different connections and thus many different ‘handles’. In other words there can be many DB contexts. Same goes for OpenGL.
Context is also used a lot in the various callback and selector passing APIs. Here it’s simply some chunk of data you would like to receive when the callback happens or the selector gets excuted. The usual use case for this is when you subscribe several times and need to tell these occasions apart:
// In one part of code not far away.
[anObject subscribeToEvent:NSObjectEventFoo withContext:#"one"];
// Somewhere else.
[anObject subscribeToEvent:NSObjectEventFoo withContext:#"two"];
// And when the callback happens:
- (void) eventFooHappenedInContext: (id) context
{
if ([context isEqual:#"one"]) { /* something */ }
if ([context isEqual:#"two"]) { /* something else */ }
}
The context is also used in the sorting APIs, like in the NSArray you mentioned. If you for example wanted to sort the objects according to some weight stored in a NSDictionary you could use the context to pass the weights:
NSInteger weightSort(id obj1, id obj2, void *context)
{
NSDictionary weights = (NSDictionary*) context;
const int weight1 = [[weights objectForKey:obj1] intValue];
const int weight2 = [[weights objectForKey:obj2] intValue];
if (weight1 < weight2)
return NSOrderedAscending;
else if (weight1 > weight2)
return NSOrderedDescending;
else
return NSOrderedSame;
}
(This is a bit contrived, but I think you get the point.)
I found it interesting that the context is a lot of times simply a poor man’s solution to closures that are/were missing in the language. Because with closures you could simply pass a separate callback handler, like in JavaScript:
anObject.subscribeToEvent(eventFoo, function() { /* do something */ });
anObject.subscribeToEvent(eventFoo, function() { /* do something else */ });
This would be often more elegant than differentiating the use cases in the callback. It is now possible to do something like this in Objective-C with blocks (see tutorial by Mike Ash).