From 7e691f69c935460fd9d90d6c1732f724da28ef9e Mon Sep 17 00:00:00 2001 From: ericwa Date: Sat, 12 Mar 2011 07:45:14 +0000 Subject: [PATCH] NSTextView continuous spell checking git-svn-id: svn+ssh://svn.gna.org/svn/gnustep/libs/gui/trunk@32534 72102866-910b-0410-8b05-ffd578937521 --- ChangeLog | 8 + Headers/AppKit/NSAttributedString.h | 3 + Headers/AppKit/NSTextView.h | 5 + Source/NSLayoutManager.m | 95 +++++++++++ Source/NSTextView.m | 244 +++++++++++++++++++++++++++- Source/externs.m | 5 + 6 files changed, 353 insertions(+), 7 deletions(-) diff --git a/ChangeLog b/ChangeLog index acb87b28b..bf2b1d07f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,11 @@ +2011-03-11 Eric Wasylishen + + * Source/NSLayoutManager.m: + * Source/externs.m: + * Source/NSTextView.m: + * Headers/AppKit/NSAttributedString.h: + * Headers/AppKit/NSTextView.h: Implement continuous spell checking + 2011-03-11 Fred Kiefer * ColorPickers/GSStandardColorPicker.m diff --git a/Headers/AppKit/NSAttributedString.h b/Headers/AppKit/NSAttributedString.h index 35d209adf..005dd00e3 100644 --- a/Headers/AppKit/NSAttributedString.h +++ b/Headers/AppKit/NSAttributedString.h @@ -142,6 +142,9 @@ APPKIT_EXPORT NSString *NSWebResourceLoadDelegateDocumentOption; APPKIT_EXPORT NSString *NSCharacterShapeAttributeName; APPKIT_EXPORT const unsigned NSUnderlineByWordMask; +APPKIT_EXPORT NSString *NSSpellingStateAttributeName; +APPKIT_EXPORT const unsigned NSSpellingStateSpellingFlag; +APPKIT_EXPORT const unsigned NSSpellingStateGrammarFlag; // readFrom... attributes diff --git a/Headers/AppKit/NSTextView.h b/Headers/AppKit/NSTextView.h index 6ff7fc36b..a412e99cb 100644 --- a/Headers/AppKit/NSTextView.h +++ b/Headers/AppKit/NSTextView.h @@ -111,6 +111,7 @@ therefore be stored in the NSLayoutManager to avoid problems. would be very awkward if they weren't. */ unsigned allows_undo:1; unsigned smart_insert_delete:1; + unsigned continuous_spell_checking:1; /* End of shared attributes. */ @@ -243,6 +244,10 @@ therefore be stored in the NSLayoutManager to avoid problems. NSParagraphStyle *_defaultParagraphStyle; NSDictionary *_linkTextAttributes; NSRange _markedRange; + + // Text checking (spelling/grammar) + NSTimer *_textCheckingTimer; + NSRect _lastCheckedRect; } diff --git a/Source/NSLayoutManager.m b/Source/NSLayoutManager.m index 9a71c3d8f..cf846ea11 100644 --- a/Source/NSLayoutManager.m +++ b/Source/NSLayoutManager.m @@ -107,6 +107,7 @@ first. Remaining cases, highest priority first: #import #import +#import #import "AppKit/NSAttributedString.h" #import "AppKit/NSColor.h" #import "AppKit/NSImage.h" @@ -122,6 +123,16 @@ first. Remaining cases, highest priority first: #import "GNUstepGUI/GSLayoutManager_internal.h" +@interface NSLayoutManager (spelling) + +-(void) _drawSpellingState: (NSInteger)spellingState + forGylphRange: (NSRange)range + lineFragmentRect: (NSRect)fragmentRect + lineFragmentGlyphRange: (NSRange)fragmentGlyphRange + containerOrigin: (NSPoint)containerOrigin; + +@end + @interface NSLayoutManager (LayoutHelpers) -(void) _doLayoutToContainer: (int)cindex point: (NSPoint)p; @end @@ -1718,6 +1729,41 @@ for (i = 0; i < gbuf_len; i++) printf(" %3i : %04x\n", i, gbuf[i]); */ } i += underlinedCharacterRange.length; } + + // Draw spelling state (i.e. red underline for misspelled words) + + for (i=characterRange.location; i 0 + + if (spellingState == 0) + { + return; + } + + // FIXME: calculate the underline position correctly, using the font on both the start and end glyph + start.y += [largestFont pointSize] * 0.07; + end.y += [largestFont pointSize] * 0.07; + + end.x += [largestFont advancementForGlyph: [self glyphAtIndex: (NSMaxRange(range) - 1)]].width; + + start = NSMakePoint(start.x + containerOrigin.x + fragmentRect.origin.x, start.y + containerOrigin.y + fragmentRect.origin.y); + end = NSMakePoint(end.x + containerOrigin.x + fragmentRect.origin.x, end.y + containerOrigin.y + fragmentRect.origin.y); + + path = [NSBezierPath bezierPath]; + [path setLineDash: pattern count: 2 phase: 0]; + [path setLineWidth: 1.5]; + [path moveToPoint: start]; + [path lineToPoint: end]; + + if ((spellingState & NSSpellingStateGrammarFlag) != 0) + { + [[NSColor greenColor] set]; + } + else + { + [[NSColor redColor] set]; + } + + [path stroke]; +} + +@end + @implementation NSLayoutManager diff --git a/Source/NSTextView.m b/Source/NSTextView.m index c63e80766..630cea737 100644 --- a/Source/NSTextView.m +++ b/Source/NSTextView.m @@ -148,6 +148,14 @@ Interface for a bunch of internal methods that need to be cleaned up. // - (void) copySelection; - (void) pasteSelection; + +/* + * Text checking + */ +- (void) _scheduleTextCheckingInVisibleRectIfNeeded; +- (void) _textDidChange: (NSNotification*)notif; +- (void) _textCheckingTimerFired: (NSTimer *)t; + @end @@ -187,6 +195,7 @@ Interface for a bunch of internal methods that need to be cleaned up. ([tv isFieldEditor]?0x10:0) | ([tv usesFontPanel]?0x20:0) | ([tv isRulerVisible]?0x40:0) | + ([tv isContinuousSpellCheckingEnabled]?0x80:0) | ([tv usesRuler]?0x100:0) | ([tv smartInsertDeleteEnabled]?0x200:0) | ([tv allowsUndo]?0x400:0) | @@ -738,6 +747,10 @@ If a text view is added to an empty text network, it keeps its attributes. name: NSViewFrameDidChangeNotification object: self]; + [notificationCenter addObserver: self + selector: @selector(_textDidChange:) + name: NSTextDidChangeNotification + object: self]; return self; } @@ -903,6 +916,7 @@ that makes decoding and encoding compatible with the old code. _tf.is_field_editor = ((0x10 & flags) > 0); _tf.uses_font_panel = ((0x20 & flags) > 0); _tf.is_ruler_visible = ((0x40 & flags) > 0); + _tf.continuous_spell_checking = ((0x80 & flags) > 0); _tf.uses_ruler = ((0x100 & flags) > 0); _tf.smart_insert_delete = ((0x200 & flags) > 0); _tf.allows_undo = ((0x400 & flags) > 0); @@ -1019,7 +1033,10 @@ that makes decoding and encoding compatible with the old code. selector: @selector(_updateState:) name: NSViewFrameDidChangeNotification object: self]; - + [notificationCenter addObserver: self + selector: @selector(_textDidChange:) + name: NSTextDidChangeNotification + object: self]; return self; } @@ -1051,6 +1068,11 @@ that makes decoding and encoding compatible with the old code. [notificationCenter removeObserver: self name: NSViewFrameDidChangeNotification object: self]; + [notificationCenter removeObserver: self + name: NSTextDidChangeNotification + object: self]; + [_textCheckingTimer invalidate]; + [[NSRunLoop currentRunLoop] cancelPerformSelector: @selector(_updateState:) target: self argument: nil]; @@ -1471,18 +1493,35 @@ to make sure syncing is handled properly in all cases. /* Continuous spell checking */ -/* TODO */ - (BOOL) isContinuousSpellCheckingEnabled { - NSLog(@"Method %s is not implemented for class %s", - __PRETTY_FUNCTION__, "NSTextView"); - return NO; + return _tf.continuous_spell_checking; } - (void) setContinuousSpellCheckingEnabled: (BOOL)flag { - NSLog(@"Method %s is not implemented for class %s", - __PRETTY_FUNCTION__, "NSTextView"); + NSTEXTVIEW_SYNC; + + if (_tf.continuous_spell_checking && !flag) + { + _tf.continuous_spell_checking = 0; + + const NSRange allRange = NSMakeRange(0, [[self string] length]); + [_layoutManager removeTemporaryAttribute: @"NSTextChecked" + forCharacterRange: allRange]; + [_layoutManager removeTemporaryAttribute: NSSpellingStateAttributeName + forCharacterRange: allRange]; + _lastCheckedRect = NSZeroRect; + + [_textCheckingTimer invalidate]; + _textCheckingTimer = nil; + } + else if (!_tf.continuous_spell_checking && flag) + { + _tf.continuous_spell_checking = 1; + + [self _scheduleTextCheckingInVisibleRectIfNeeded]; + } } @@ -3813,6 +3852,8 @@ Figure out how the additional layout stuff is supposed to work. drawnRange = NSMakeRange(0, 0); } + [self _scheduleTextCheckingInVisibleRectIfNeeded]; + /* FIXME: We should only draw inside of rect. This code is necessary * to remove markings of old glyphs. These would not be removed * by the following call to the layout manager because that only @@ -5740,6 +5781,195 @@ or add guards type: NSStringPboardType]; } +/** + * Text checking + */ + +- (void) _checkTextInRange: (NSRange)aRange +{ + NSRange longestRange; + id value = [_layoutManager temporaryAttribute: @"NSTextChecked" + atCharacterIndex: aRange.location + longestEffectiveRange: &longestRange + inRange: aRange]; + longestRange = NSIntersectionRange(longestRange, aRange); + + if ([value boolValue] && NSEqualRanges(longestRange, aRange)) + { + //NSLog(@"No need to check in range %@", NSStringFromRange(aRange)); + return; + } + + // Check all of aRange + + [_layoutManager removeTemporaryAttribute: NSSpellingStateAttributeName + forCharacterRange: aRange]; + + { + NSSpellChecker *sp = [NSSpellChecker sharedSpellChecker]; + NSInteger start = 0; + int count; + // FIXME: doing the spellcheck on this substring could create false-positives + // if a word is split in half.. so the range should be expanded to the + // nearest word boundary + NSString *substring = [[self string] substringWithRange: aRange]; + + do { + NSRange errorRange = [sp checkSpellingOfString: substring + startingAt: start + language: [sp language] + wrap: YES + inSpellDocumentWithTag: [self spellCheckerDocumentTag] + wordCount: &count]; + + if (errorRange.location < start) + { + break; + } + + if (errorRange.length > 0) + { + start = NSMaxRange(errorRange); + } + else + { + break; + } + + errorRange = NSMakeRange(aRange.location + errorRange.location, errorRange.length); + + //NSLog(@"highlighting mistake: %@", [[self string] substringWithRange: errorRange]); + + [_layoutManager addTemporaryAttribute: NSSpellingStateAttributeName + value: [NSNumber numberWithInteger: NSSpellingStateSpellingFlag] + forCharacterRange: errorRange]; + + } while (1); + + [_layoutManager addTemporaryAttribute: @"NSTextChecked" + value: [NSNumber numberWithBool: YES] + forCharacterRange: aRange]; + } +} + +- (void) _textCheckingTimerFired: (NSTimer *)t +{ + _textCheckingTimer = nil; + + if (nil == _layoutManager) + return; + + { + const NSRect visibleRect = [self visibleRect]; + + NSRange visibleGlyphRange = [_layoutManager glyphRangeForBoundingRect: visibleRect + inTextContainer: _textContainer]; + + NSRange visibleRange = [_layoutManager characterRangeForGlyphRange: visibleGlyphRange + actualGlyphRange: NULL]; + + [self _checkTextInRange: visibleRange]; + + _lastCheckedRect = visibleRect; + } +} + +- (void) _scheduleTextCheckingTimer +{ + [_textCheckingTimer invalidate]; + _textCheckingTimer = [NSTimer scheduledTimerWithTimeInterval: 0.5 + target: self + selector: @selector(_textCheckingTimerFired:) + userInfo: [NSValue valueWithRect: [self visibleRect]] + repeats: NO]; + +} + +- (void) _scheduleTextCheckingInVisibleRectIfNeeded +{ + if (_tf.continuous_spell_checking) + { + const NSRect visibleRect = [self visibleRect]; + if (!NSEqualRects(visibleRect, _lastCheckedRect)) + { + if (NSEqualRects(visibleRect, [[_textCheckingTimer userInfo] rectValue])) + { + return; + } + [self _scheduleTextCheckingTimer]; + } + } +} + +/** + * If selected has a nonzero length, return it unmodified. + * If selected is touching a word, expand it to cover the entire word + * Otherwise return selected unmodified + */ +- (NSRange) _rangeToInvalidateSpellingForSelectionRange: (NSRange)selected +{ + if (selected.length > 0) + { + return selected; + } + else + { + NSCharacterSet *boundary = [[NSCharacterSet letterCharacterSet] invertedSet]; + + if (selected.location == [[self string] length] && selected.location > 0) + { + selected.location--; + } + + NSRange prevNonCharacter = [[self string] rangeOfCharacterFromSet: boundary options: NSBackwardsSearch range: NSMakeRange(0, selected.location)]; + NSRange nextNonCharacter = [[self string] rangeOfCharacterFromSet: boundary options: 0 range: NSMakeRange(NSMaxRange(selected), [[self string] length] - NSMaxRange(selected))]; + NSRange range; + + if (prevNonCharacter.length == 0) + { + range.location = 0; + } + else + { + range.location = prevNonCharacter.location + 1; + } + + if (nextNonCharacter.length == 0) + { + range.length = [[self string] length] - range.location; + } + else + { + range.length = nextNonCharacter.location - range.location; + } + + return range; + } +} + +- (void) _textDidChange: (NSNotification*)notif +{ + if (_tf.continuous_spell_checking) + { + // FIXME: This uses the caret position to guess what change caused + // the NSTextDidChangeNotification and mark that range of the text as requiring re-checking. + // + // It would be better to use accurate information to decide what range + // need its spelling state invalidated. + + NSRange range = [self _rangeToInvalidateSpellingForSelectionRange: [self selectedRange]]; + if (range.length > 0) + { + [_layoutManager removeTemporaryAttribute: @"NSTextChecked" + forCharacterRange: range]; + [_layoutManager removeTemporaryAttribute: NSSpellingStateAttributeName + forCharacterRange: range]; + } + + [self _scheduleTextCheckingTimer]; + } +} + @end @implementation NSTextViewUndoObject diff --git a/Source/externs.m b/Source/externs.m index 9cea0e4fb..912bd429b 100644 --- a/Source/externs.m +++ b/Source/externs.m @@ -547,6 +547,11 @@ NSString *NSModificationTimeDocumentAttribute = @"ModificationTime"; const unsigned NSUnderlineByWordMask = 0x01; +NSString *NSSpellingStateAttributeName = @"NSSpellingState"; +const unsigned NSSpellingStateSpellingFlag = 1; +const unsigned NSSpellingStateGrammarFlag = 2; + + NSString *NSPlainTextDocumentType = @"PlainText"; NSString *NSRTFTextDocumentType = @"RTF"; NSString *NSRTFDTextDocumentType = @"RTFD";