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.
Related
It's common to have a text editor for code or other structured content that balances delimiters of some sort; when you double click on a { it selects to the matching }, or similarly for ( ) pairs, [ ] pairs, etc. How can I implement this behavior in NSTextView in Cocoa/Obj-C?
(I will be posting an answer momentarily, since I found nothing on SO about this and spent today implementing a solution. Better answers are welcome.)
ADDENDUM:
This is not the same as this question, which is about NSTextField and is primarily concerned with NSTextField and field editor issues. If that question is solved by substituting a custom NSTextView subclass into the field editor, then that custom subclass could use the solution given here, of course; but there might be many other ways to solve the problem for NSTextField, and substituting a custom NSTextView subclass into the field editor is not obviously the right solution to that problem, and in any case a programmer concerned with delimiter balancing in NSTextView (which is presumably the more common problem) could care less about all of those NSTextField and field editor issues. So that is a different question – although I will add a link from that question to this one, as one possible direction it could go.
This is also not the same as this question, which is really about changing the definition of a "word" in NSTextView when a double-click occurs. As per Apple's documentation, these are different problems with different solutions; for delimiter-balancing (this question) Apple specifically recommends the use of NSTextView's selectionRangeForProposedRange:granularity: method, whereas for changing the definition of a word (that question) Apple specifically states that the selectionRangeForProposedRange:granularity: method should not be used.
In their Cocoa Text Architecture Guide (https://developer.apple.com/library/prerelease/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextEditing/TextEditing.html), Apple suggests subclassing NSTextView and overriding selectionRangeForProposedRange:granularity: to achieve this sort of thing; they even say "For example, in a code editor you can provide a delegate that extends a double click on a brace or parenthesis character to its matching delimiter." However, it is not immediately clear how to achieve this, since you want the delimiter match to happen only at after a simple double-click on a delimiter, not after a double-click-drag or even a double-click-hold-release.
The best solution I could come up with involves overriding mouseDown: as well, and doing a little bookkeeping about the state of affairs. Maybe there is a simpler way. I've left out the core part of the code where the delimiter match actually gets calculated; that will depend on what delimiters you're matching, what syntactical complexities (strings, comments) might exist, and so forth. In my code I actually call a tokenizer to get a token stream, and I use that to find the matching delimiter. YMMV. So, here's what I've got:
In your NSTextView subclass interface (or class extension, better yet):
// these are used in selectionRangeForProposedRange:granularity:
// to balance delimiters properly
BOOL inEligibleDoubleClick;
NSTimeInterval doubleDownTime;
In your NSTextView subclass implementation:
- (void)mouseDown:(NSEvent *)theEvent
{
// Start out willing to work with a double-click for delimiter-balancing;
// see selectionRangeForProposedRange:proposedCharRange granularity: below
inEligibleDoubleClick = YES;
[super mouseDown:theEvent];
}
- (NSRange)selectionRangeForProposedRange:(NSRange)proposedCharRange
granularity:(NSSelectionGranularity)granularity
{
if ((granularity == NSSelectByWord) && inEligibleDoubleClick)
{
// The proposed range has to be zero-length to qualify
if (proposedCharRange.length == 0)
{
NSEvent *event = [NSApp currentEvent];
NSEventType eventType = [event type];
NSTimeInterval eventTime = [event timestamp];
if (eventType == NSLeftMouseDown)
{
// This is the mouseDown of the double-click; we do not want
// to modify the selection here, just log the time
doubleDownTime = eventTime;
}
else if (eventType == NSLeftMouseUp)
{
// After the double-click interval since the second mouseDown,
// the mouseUp is no longer eligible
if (eventTime - doubleDownTime <= [NSEvent doubleClickInterval])
{
NSString *scriptString = [[self textStorage] string];
...insert delimiter-finding code here...
...return the matched range, or NSBeep()...
}
else
{
inEligibleDoubleClick = false;
}
}
else
{
inEligibleDoubleClick = false;
}
}
else
{
inEligibleDoubleClick = false;
}
}
return [super selectionRangeForProposedRange:proposedCharRange
granularity:granularity];
}
It's a little fragile, because it relies on NSTextView's tracking working in a particular way and calling out to selectionRangeForProposedRange:granularity: in a particular way, but the assumptions are not large; I imagine it's pretty robust.
I have a text field and I bind it to an NSString instance variable.
When I type in the text field, it does not update the variable. It waits until I press the Enter key. I don't want to hit Enter every time.
What do I need to change in order to make the binding change value immediately?
By default, the value binding of an NSTextField does not update continuously. To fix this, you need, after selecting your text field, to check the "Continuously Updates Value" box in the Bindings Inspector under the Value heading:
However, most often, what you really want to do is update the property to which the text field is bound when the user has finished editing and presses a button ("Save" or "OK", for example). To do this, you needn't continuously update the property as described above, you just need to end editing. Daniel Jalkut provides an extremely useful implementation of just such a method:
#interface NSWindow (Editing)
- (void)endEditing;
#end
#implementation NSWindow (Editing)
- (void)endEditing
{
// Save the current first responder, respecting the fact
// that it might conceptually be the delegate of the
// field editor that is "first responder."
id oldFirstResponder = [oMainDocumentWindow firstResponder];
if ((oldFirstResponder != nil) &&
[oldFirstResponder isKindOfClass:[NSTextView class]] &&
[(NSTextView*)oldFirstResponder isFieldEditor])
{
// A field editor's delegate is the view we're editing
oldFirstResponder = [oldFirstResponder delegate];
if ([oldFirstResponder isKindOfClass:[NSResponder class]] == NO)
{
// Eh ... we'd better back off if
// this thing isn't a responder at all
oldFirstResponder = nil;
}
}
// Gracefully end all editing in our window (from Erik Buck).
// This will cause the user's changes to be committed.
if([oMainDocumentWindow makeFirstResponder:oMainDocumentWindow])
{
// All editing is now ended and delegate messages sent etc.
}
else
{
// For some reason the text object being edited will
// not resign first responder status so force an
/// end to editing anyway
[oMainDocumentWindow endEditingFor:nil];
}
// If we had a first responder before, restore it
if (oldFirstResponder != nil)
{
[oMainDocumentWindow makeFirstResponder:oldFirstResponder];
}
}
#end
So if for example you had a "Save" button targeting your view controller's method -save:, you would call
- (IBAction)save:(id)sender
{
[[[self view] window] endEditing];
//at this point, all properties bound to text fields have the same
//value as the contents of the text fields.
//save stuff...
}
The previous answer is beautiful, and I learned from it about tricking the Window/View/Document system to end-editing on everything at the programmer's will.
However, the default responder chain behavior (including the preservation of the first responder until the USER moved their focus to something else) is fundamental to the Mac's "look and feel" and I wouldn't mess with it lightly (I swear I did very powerful things in responder-chain manipulation, so I don't say that out of fear.)
In addition - there is even a simpler method - that does not require changing the binding. In the Interface-builder, select the text field, and select the "Attribute Inspector" tab. You'll see the following:
Checking the red-circled "continuous" will do the trick. This option is basic and older even than binding, and its main use is to allow validator object (a whole new story) to validate the text and change it on the fly, as the user types. When the text-field calls validator calls, it also updates bound values.
In the main window of my application, I have a collection view such that each collection view item contains an NSTableView. The window controller for this window has an NSString * property projecttitle. This property is bound to an NSTextField in the window for which I have overridden the default return key behavior so that the user can hit Return and write a carriage return into the text field. Now, after changing the string in the text field and THEN dragging an item between the table views for two different collection view items, the projecttitle property becomes nil. I sort of feel like maybe I just have way too much going on here and that this bug will be impossible to track down, but maybe someone has seen something even remotely similar to this behavior elsewhere?
EDIT: Putting a breakpoint on the projecttitle property doesn't seem to yield anything useful. The program execution does not break at all upon dragging and dropping, but the property will indeed be nil after this.
EDIT 2: After more digging around, it appears the behavior is related to the NSFormatter object. It happens not only when dragging and dropping, but apparently any time the nstextfield attempts to resign as responder. This behavior stops when I disconnect the formatter object in IB. Here's the string validation code for the formatter which forces the string to be less than or equal to 4 lines long and with each line being no longer than 32 characters. This seems to work fine when actually typing the in the text field, but apparently, not after.
-(BOOL)isPartialStringValid:(NSString **)partialStringPtr
proposedSelectedRange:(NSRangePointer)proposedSelRangePtr
originalString:(NSString *)origString
originalSelectedRange:(NSRange)origSelRange
errorDescription:(NSString **)error {
NSArray * lines = [*partialStringPtr componentsSeparatedByString:#"\n"];
if ( [lines count] > 4 ) {
return NO;
}
for (NSString * line in lines) {
if ( [line length] > self.maxlength ) {
return NO;
}
}
return YES;
}
Okay, solved. Turned out it was the -getObjectValue:forString:errorDescription: method. I had it implemented as
-(BOOL)getObjectValue:(id *)obj forString:(NSString *)string errorDescription:(NSString **)error {
*obj = string;
return YES;
}
Changing *obj = string to *obj = [NSString stringWithString:string] fixed everything right up.
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.
Here's my setup:
myTextField is bound to a key in the Shared User Defaults Controller. The user can only enter numbers in the text field.
Each time my application loads, I load default preferences (from the app's Resources folder) using [[NSUserDefaults standardUserDefaults] registerDefaults: ... ].
myMenuItem's title is bound to the same key in the Shared User Defaults Controller.
The issue:
When myTextField is empty, myMenuItem receives the default value that was loaded using registerDefaults: instead of some value that represents the empty field (I would expect 0 or nil).
For example, when the NSTextField is empty the menu item receives "2", the value that was loaded using registerDefaults:, instead of some value that means that the field is empty.
If I comment the registerDefaults: code, the binding returns nil as I would expect when there is nothing in the NSTextField.
I tried to mess around with many of the bindings' settings as well as experiment with placeholder values and I looked at the Cocoa Bindings and User Defaults docs but I could not find the solution.
Expected behavior:
When the text field is empty, I want myMenuItem to reflect that instead of using the default value that was registered using registerDefaults:.
Any help would be greatly appreciated.
I got some hints from the nice folks at irc.freenode.org #macdev and found the following to be the solution to my problem:
Creating a subclass of NSFormatter (or NSNumberFormatter in my case) and overriding getObjectValue:forString:errorDescription: as follows overrides the default behaviour of returning nil (which makes the binding use the registered default value) to instead return 0 when the text field is empty.
- (BOOL)getObjectValue:(id *)anObject forString:(NSString *)string errorDescription:(NSString **)error {
if ([string isEqualToString:#""]) {
*anObject = [NSNumber numberWithInt:0];
return YES;
} else {
return [super getObjectValue:anObject forString:string errorDescription:error];
}
}
A NSValueTransformer subclass' reverse conversion method would have also worked for this.
Note:
The solution above does not work for strings. That is, if your userDefault value is a string and you've bound that to the value of an NSTextField and you subclass NSFormatter and do this:
- (BOOL)getObjectValue:(id *)anObject forString:(NSString *)string errorDescription:(NSString **)error {
if ([string isEqualToString:#""]) {
*anObject = nil;
}
return YES;
}
You'll find that when the NSTextField is empty, the binding is set to whatever value you originally registered using -registerDefaults: rather than to an empty string or a nil value. I attempted to use *anObject = [NSNull null]; as well, but that didn't work either --- the binding is still set to the original, default value.
I ended up setting *anObject = #" "; (a string with a single space) in the code above, so that when the textField is empty, the value in the userDefaults pList is set to the string: #" ". I then check for that string everywhere that uses that userDefault value and respond appropriately (ignore the single space and treat it as if the userDefault value were empty).
It's an ugly hack, but the only way I can find to work around this issue currently. I'm posting it here in case someone else stumbles on this thread. If you ask me, binding an NSTextField to a userDefault should bloody well set that userDefault to an empty string when the textField is empty! Why Apple chose to do it the way they did is beyond me.