diff --git a/Source/NSTextCheckingController.m b/Source/NSTextCheckingController.m index 739f79af4..4d739dc21 100644 --- a/Source/NSTextCheckingController.m +++ b/Source/NSTextCheckingController.m @@ -1,40 +1,55 @@ /* Implementation of class NSTextCheckingController Copyright (C) 2020 Free Software Foundation, Inc. - + By: Gregory John Casamento Date: 02-08-2020 This file is part of the GNUstep Library. - + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110 USA. */ -#import "AppKit/NSTextCheckingController.h" +#import +#import +#import +#import +#import +#import + +#import "AppKit/NSAttributedString.h" +#import "AppKit/NSCell.h" +#import "AppKit/NSMenu.h" +#import "AppKit/NSMenuItem.h" +#import "AppKit/NSPanel.h" #import "AppKit/NSSpellChecker.h" +#import "AppKit/NSTextCheckingController.h" + +/* The length asked for when the extent of the text is not known yet. */ +#define GS_CHECKING_CHUNK 1024 + +@interface NSTextCheckingController (Private) + +- (NSString *) _substringInRange: (NSRange)range + actualRange: (NSRangePointer)actualRange; +- (NSRange) _misspelledRangeInString: (NSString *)string + startingAt: (NSUInteger)location; +- (void) _markMisspelledRange: (NSRange)range; +- (void) _clearAnnotationsInRange: (NSRange)range; -/* -@interface NSSpellChecker (Private) -- (void) _findNext: (id)sender; -- (void) _ignore: (id)sender; -- (void) _guess: (id)sender; -- (void) _correct: (id)sender; -- (void) _switchDictionary: (id)sender; -- (void) _highlightGuess: (id)sender; @end -*/ @implementation NSTextCheckingController @@ -45,7 +60,7 @@ - (instancetype) initWithClient: (id)client if (self != nil) { _client = client; - _spellCheckerDocumentTag = 0; + _spellCheckerDocumentTag = [NSSpellChecker uniqueSpellDocumentTag]; } return self; } @@ -66,44 +81,202 @@ - (void) setSpellCheckerDocumentTag: (NSInteger)tag _spellCheckerDocumentTag = tag; } +/* Asks the client for the text of a range. The client is free to return less + than was asked for, so the range it actually supplied is reported back and + every range found in the result has to be offset by its location. */ +- (NSString *) _substringInRange: (NSRange)range + actualRange: (NSRangePointer)actualRange +{ + NSAttributedString *substring; + + substring = [_client annotatedSubstringForProposedRange: range + actualRange: actualRange]; + return [substring string]; +} + +- (NSRange) _misspelledRangeInString: (NSString *)string + startingAt: (NSUInteger)location +{ + int wordCount = 0; + + if (location > [string length]) + { + return NSMakeRange(NSNotFound, 0); + } + /* The count is handed to the spell server as an out parameter, so it needs + somewhere to write even where the caller has no use for it. */ + return [[NSSpellChecker sharedSpellChecker] + checkSpellingOfString: string + startingAt: location + language: nil + wrap: NO + inSpellDocumentWithTag: _spellCheckerDocumentTag + wordCount: &wordCount]; +} + +- (void) _markMisspelledRange: (NSRange)range +{ + NSDictionary *annotations; + + annotations = [NSDictionary dictionaryWithObject: + [NSNumber numberWithUnsignedInt: NSSpellingStateSpellingFlag] + forKey: NSSpellingStateAttributeName]; + [_client setAnnotations: annotations range: range]; +} + +- (void) _clearAnnotationsInRange: (NSRange)range +{ + [_client setAnnotations: [NSDictionary dictionary] range: range]; +} + // instance methods... - (void) changeSpelling: (id)sender { - + NSString *correction; + NSRange selected; + NSRange actual; + + if (_client == nil) + { + return; + } + + correction = [[sender selectedCell] stringValue]; + if (correction == nil) + { + return; + } + + selected = [_client selectedRange]; + [self _substringInRange: selected actualRange: &actual]; + + [_client replaceCharactersInRange: actual + withAnnotatedString: [[[NSAttributedString alloc] + initWithString: correction] autorelease]]; + + selected = NSMakeRange(actual.location, [correction length]); + [self _clearAnnotationsInRange: selected]; + [_client selectAndShowRange: selected]; } - (void) checkSpelling: (id)sender { - /* - int wordCount = 0; - [[NSSpellChecker sharedSpellChecker] checkSpellingOfString: [_client string] - startingAt: 0 - language: nil - wrap: NO - inSpellDocumentWithTag: _spellCheckerDocumentTag - wordCount: &wordCount];*/ + NSRange selected; + NSRange actual; + NSString *text; + NSRange misspelled; + + if (_client == nil) + { + return; + } + + /* Checking carries on from the end of the selection, so that asking again + moves to the word after the one being shown. */ + selected = [_client selectedRange]; + text = [self _substringInRange: + NSMakeRange(selected.location, GS_CHECKING_CHUNK) actualRange: &actual]; + if (text == nil) + { + return; + } + + misspelled = [self _misspelledRangeInString: text + startingAt: NSMaxRange(selected) - actual.location]; + if (misspelled.length == 0) + { + return; + } + + misspelled.location += actual.location; + [_client selectAndShowRange: misspelled]; + [self _markMisspelledRange: misspelled]; } -- (void) checkTextInRange: (NSRange)range - types: (NSTextCheckingTypes)checkingTypes +- (void) checkTextInRange: (NSRange)range + types: (NSTextCheckingTypes)checkingTypes options: (NSDictionary *)options { + NSRange actual; + NSString *text; + NSUInteger location = 0; + + if (_client == nil) + { + return; + } + + text = [self _substringInRange: range actualRange: &actual]; + if (text == nil) + { + return; + } + + [self _clearAnnotationsInRange: actual]; + + if ((checkingTypes & NSTextCheckingTypeSpelling) == 0) + { + return; + } + + /* Every misspelling in the range is marked, not only the first. */ + while (location < [text length]) + { + NSRange misspelled = [self _misspelledRangeInString: text + startingAt: location]; + + if (misspelled.length == 0) + { + break; + } + location = NSMaxRange(misspelled); + misspelled.location += actual.location; + [self _markMisspelledRange: misspelled]; + } } - (void) checkTextInSelection: (id)sender { + if (_client == nil) + { + return; + } + [self checkTextInRange: [_client selectedRange] + types: NSTextCheckingTypeSpelling + options: [NSDictionary dictionary]]; } - (void) checkTextInDocument: (id)sender { + NSRange actual; + + if (_client == nil) + { + return; + } + + /* The extent of the document is whatever the client reports for a range + that covers everything. */ + [self _substringInRange: NSMakeRange(0, NSUIntegerMax) actualRange: &actual]; + [self checkTextInRange: actual + types: NSTextCheckingTypeSpelling + options: [NSDictionary dictionary]]; } - (void) didChangeTextInRange: (NSRange)range { + [self considerTextCheckingForRange: range]; } - (void) considerTextCheckingForRange: (NSRange)range { + if (_client == nil) + { + return; + } + [self checkTextInRange: range + types: NSTextCheckingTypeSpelling + options: [NSDictionary dictionary]]; } - (void) didChangeSelectedRange @@ -112,21 +285,89 @@ - (void) didChangeSelectedRange - (void) ignoreSpelling: (id)sender { + NSString *word = [[sender selectedCell] stringValue]; + + if (word != nil) + { + [[NSSpellChecker sharedSpellChecker] + ignoreWord: word inSpellDocumentWithTag: _spellCheckerDocumentTag]; + } } - (void) insertedTextInRange: (NSRange)range { + [self considerTextCheckingForRange: range]; } - (void) invalidate { + if (_client != nil) + { + [[NSSpellChecker sharedSpellChecker] + closeSpellDocumentWithTag: _spellCheckerDocumentTag]; + _client = nil; + } } - (NSMenu *) menuAtIndex: (NSUInteger)location - clickedOnSelection: (BOOL)clickedOnSelection + clickedOnSelection: (BOOL)clickedOnSelection effectiveRange: (NSRangePointer)effectiveRange { - return nil; + NSAttributedString *substring; + NSRange actual; + NSRange marked; + NSMenu *menu; + NSEnumerator *guesses; + NSString *guess; + + if (effectiveRange != NULL) + { + *effectiveRange = NSMakeRange(NSNotFound, 0); + } + if (_client == nil) + { + return nil; + } + + /* A menu is offered for text that has already been marked, not for text + that has yet to be checked. */ + substring = [_client annotatedSubstringForProposedRange: + NSMakeRange(location, 0) actualRange: &actual]; + if ([substring length] == 0) + { + return nil; + } + + marked = NSMakeRange(NSNotFound, 0); + if ([substring attribute: NSSpellingStateAttributeName + atIndex: 0 + effectiveRange: &marked] == nil) + { + return nil; + } + + marked.location += actual.location; + if (effectiveRange != NULL) + { + *effectiveRange = marked; + } + + menu = [[[NSMenu alloc] initWithTitle: @""] autorelease]; + guesses = [[[NSSpellChecker sharedSpellChecker] + guessesForWord: [[substring string] substringWithRange: + NSMakeRange(0, MIN(marked.length, [substring length]))]] objectEnumerator]; + while ((guess = [guesses nextObject]) != nil) + { + [[menu addItemWithTitle: guess + action: @selector(changeSpelling:) + keyEquivalent: @""] setTarget: self]; + } + if ([menu numberOfItems] == 0) + { + [menu addItemWithTitle: @"No Guesses Found" action: NULL keyEquivalent: @""]; + } + + return menu; } - (void) orderFrontSubstitutionsPanel: (id)sender @@ -135,6 +376,21 @@ - (void) orderFrontSubstitutionsPanel: (id)sender - (void) showGuessPanel: (id)sender { + NSSpellChecker *checker = [NSSpellChecker sharedSpellChecker]; + NSRange selected; + NSRange actual; + NSString *text; + + if (_client != nil) + { + selected = [_client selectedRange]; + text = [self _substringInRange: selected actualRange: &actual]; + if ([text length] > 0) + { + [checker updateSpellingPanelWithMisspelledWord: text]; + } + } + [[checker spellingPanel] orderFront: sender]; } - (void) updateCandidates @@ -143,8 +399,10 @@ - (void) updateCandidates - (NSArray *) validAnnotations { - return nil; + return [NSArray arrayWithObjects: NSSpellingStateAttributeName, + NSLinkAttributeName, + NSTextAlternativesAttributeName, + nil]; } @end - diff --git a/Tests/gui/NSTextCheckingController/TestInfo b/Tests/gui/NSTextCheckingController/TestInfo new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/gui/NSTextCheckingController/basic.m b/Tests/gui/NSTextCheckingController/basic.m new file mode 100644 index 000000000..f5e5a5db1 --- /dev/null +++ b/Tests/gui/NSTextCheckingController/basic.m @@ -0,0 +1,291 @@ +/* NSTextCheckingController drives a client through the NSTextCheckingClient + * methods. The client here records what it is asked so that the calls each + * controller method makes can be checked, and holds a string with two + * misspellings in it. + * + * The behaviour asserted here was measured on macOS 26: the controller takes a + * unique spell document tag of its own, -checkSpelling: selects and marks the + * next misspelling after the selection, -changeSpelling: replaces the selected + * range and reselects it at the length of the replacement, -ignoreSpelling: + * asks the client for nothing, and after -invalidate a check does nothing at + * all. + */ +#import "ObjectTesting.h" + +#import +#import + +/* "sentance" is at 5 with length 8, "mispelled" at 20 with length 9. */ +static NSString * const kText = @"This sentance has a mispelled word in it."; + +@interface TestClient : NSObject +{ +@public + NSMutableAttributedString *store; + NSRange selected; + NSMutableArray *calls; + NSRange lastAnnotated; + NSDictionary *lastAnnotations; +} +@end + +@implementation TestClient + +- (id) init +{ + self = [super init]; + if (self != nil) + { + store = [[NSMutableAttributedString alloc] initWithString: kText]; + selected = NSMakeRange(5, 8); + calls = [[NSMutableArray alloc] init]; + lastAnnotated = NSMakeRange(NSNotFound, 0); + } + return self; +} + +- (void) dealloc +{ + RELEASE(store); + RELEASE(calls); + RELEASE(lastAnnotations); + [super dealloc]; +} + +- (NSRange) selectedRange +{ + [calls addObject: @"selectedRange"]; + return selected; +} + +- (NSAttributedString *) annotatedSubstringForProposedRange: (NSRange)range + actualRange: (NSRangePointer)actualRange +{ + NSRange clamped = NSIntersectionRange(range, NSMakeRange(0, [store length])); + + [calls addObject: @"annotatedSubstringForProposedRange:actualRange:"]; + if (actualRange != NULL) + { + *actualRange = clamped; + } + return [store attributedSubstringFromRange: clamped]; +} + +- (void) selectAndShowRange: (NSRange)range +{ + [calls addObject: @"selectAndShowRange:"]; + selected = range; +} + +- (void) setAnnotations: (NSDictionary *)annotations range: (NSRange)range +{ + [calls addObject: @"setAnnotations:range:"]; + lastAnnotated = range; + ASSIGN(lastAnnotations, annotations); + if ([annotations count] > 0) + { + [store addAttributes: annotations range: range]; + } + else + { + [store removeAttribute: NSSpellingStateAttributeName range: range]; + } +} + +- (void) replaceCharactersInRange: (NSRange)range + withAnnotatedString: (NSAttributedString *)annotatedString +{ + [calls addObject: @"replaceCharactersInRange:withAnnotatedString:"]; + [store replaceCharactersInRange: range withString: [annotatedString string]]; +} + +- (void) addAnnotations: (NSDictionary *)annotations range: (NSRange)range {} +- (void) removeAnnotation: (NSString *)name range: (NSRange)range {} +- (id) candidateListTouchBarItem { return nil; } +- (NSView *) viewForRange: (NSRange)range + firstRect: (NSRect *)firstRect + actualRange: (NSRangePointer)actualRange { return nil; } + +@end + +int main() +{ + START_SET("NSTextCheckingController") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] + isEqualToString: NSInternalInconsistencyException ]) + { + SKIP("It looks like GNUstep backend is not yet installed") + } + } + NS_ENDHANDLER + + TestClient *client = AUTORELEASE([[TestClient alloc] init]); + NSTextCheckingController *c; + + c = AUTORELEASE([[NSTextCheckingController alloc] + initWithClient: (id)client]); + + PASS(c != nil, "a controller was created") + PASS([c client] == (id)client, "the controller reports the client it was given") + + /* The controller takes a spell document tag of its own rather than leaving + it at zero, so that ignored words are kept per document. */ + PASS([c spellCheckerDocumentTag] != 0, + "the controller has its own spell checker document tag") + PASS([c spellCheckerDocumentTag] < [NSSpellChecker uniqueSpellDocumentTag], + "the tag came from the spell checker") + + [c setSpellCheckerDocumentTag: 42]; + PASS([c spellCheckerDocumentTag] == 42, "the tag can be set") + + PASS([[c validAnnotations] containsObject: NSSpellingStateAttributeName], + "the spelling state is a valid annotation") + + /* -checkSpelling: carries on from the end of the selection, so with + "sentance" selected it moves to "mispelled" and marks it. */ + START_SET("checkSpelling:") + if ([[NSSpellChecker sharedSpellChecker] guessesForWord: @"mispelled"] == nil + && [[NSSpellChecker sharedSpellChecker] + checkSpellingOfString: kText startingAt: 0].length == 0) + { + SKIP("no spell checking service is available") + } + + [client->calls removeAllObjects]; + [c checkSpelling: nil]; + + PASS([client->calls containsObject: @"selectAndShowRange:"], + "checkSpelling: shows the range it found") + PASS_EQUAL(NSStringFromRange(client->selected), + NSStringFromRange(NSMakeRange(20, 9)), + "checkSpelling: selects the misspelling after the selection") + PASS_EQUAL(NSStringFromRange(client->lastAnnotated), + NSStringFromRange(NSMakeRange(20, 9)), + "checkSpelling: annotates the misspelling it found") + PASS([[client->lastAnnotations allKeys] + containsObject: NSSpellingStateAttributeName], + "checkSpelling: marks the range with the spelling state") + + /* -changeSpelling: replaces the selection and leaves it selected at the + length of the replacement. */ + { + NSCell *cell = AUTORELEASE([[NSCell alloc] initTextCell: @"misspelled"]); + NSMatrix *sender = AUTORELEASE([[NSMatrix alloc] + initWithFrame: NSMakeRect(0, 0, 100, 20) + mode: NSRadioModeMatrix + prototype: cell + numberOfRows: 1 + numberOfColumns: 1]); + + [sender selectCellAtRow: 0 column: 0]; + [[sender selectedCell] setStringValue: @"misspelled"]; + + [client->calls removeAllObjects]; + [c changeSpelling: sender]; + + PASS([client->calls + containsObject: @"replaceCharactersInRange:withAnnotatedString:"], + "changeSpelling: replaces the text of the range") + PASS_EQUAL([client->store string], + @"This sentance has a misspelled word in it.", + "changeSpelling: puts the correction in the text") + PASS_EQUAL(NSStringFromRange(client->selected), + NSStringFromRange(NSMakeRange(20, 10)), + "changeSpelling: reselects at the length of the correction") + } + END_SET("checkSpelling:") + + /* What each method asks the client for does not depend on a spell checking + service being present, so it is checked unguarded. */ + [c setSpellCheckerDocumentTag: [NSSpellChecker uniqueSpellDocumentTag]]; + client->selected = NSMakeRange(5, 8); + + [client->calls removeAllObjects]; + [c checkSpelling: nil]; + PASS_EQUAL([client->calls objectAtIndex: 0], @"selectedRange", + "checkSpelling: starts from the selection") + PASS([client->calls + containsObject: @"annotatedSubstringForProposedRange:actualRange:"], + "checkSpelling: asks the client for the text") + + [client->calls removeAllObjects]; + [c checkTextInRange: NSMakeRange(0, 41) + types: NSTextCheckingTypeSpelling + options: [NSDictionary dictionary]]; + PASS([client->calls containsObject: @"setAnnotations:range:"], + "checkTextInRange: clears the annotations of the range it checked") + PASS_EQUAL(NSStringFromRange(client->lastAnnotated), + NSStringFromRange(NSMakeRange(0, 41)), + "checkTextInRange: covers the whole range it was given") + + [client->calls removeAllObjects]; + [c checkTextInDocument: nil]; + PASS_EQUAL(NSStringFromRange(client->lastAnnotated), + NSStringFromRange(NSMakeRange(0, 41)), + "checkTextInDocument: takes the extent from the client") + + [client->calls removeAllObjects]; + [c considerTextCheckingForRange: NSMakeRange(0, 10)]; + PASS_EQUAL(NSStringFromRange(client->lastAnnotated), + NSStringFromRange(NSMakeRange(0, 10)), + "considerTextCheckingForRange: checks the range it was given") + + [client->calls removeAllObjects]; + [c insertedTextInRange: NSMakeRange(0, 4)]; + PASS_EQUAL(NSStringFromRange(client->lastAnnotated), + NSStringFromRange(NSMakeRange(0, 4)), + "insertedTextInRange: checks the text that was inserted") + + /* A range with no marking on it has no menu. */ + { + NSRange effective = NSMakeRange(0, 0); + + PASS([c menuAtIndex: 30 clickedOnSelection: NO + effectiveRange: &effective] == nil, + "an unmarked range has no menu") + PASS(effective.location == NSNotFound, + "an unmarked range reports a not-found effective range") + } + + /* -ignoreSpelling: is between the controller and the spell checker: the + client is asked for nothing at all. */ + { + NSCell *cell = AUTORELEASE([[NSCell alloc] initTextCell: @"Kiefer"]); + NSMatrix *sender = AUTORELEASE([[NSMatrix alloc] + initWithFrame: NSMakeRect(0, 0, 100, 20) + mode: NSRadioModeMatrix + prototype: cell + numberOfRows: 1 + numberOfColumns: 1]); + + [sender selectCellAtRow: 0 column: 0]; + [[sender selectedCell] setStringValue: @"Kiefer"]; + + [client->calls removeAllObjects]; + [c ignoreSpelling: sender]; + PASS([client->calls count] == 0, "ignoreSpelling: asks the client for nothing") + } + + /* After -invalidate the controller stops working on the client. */ + [c invalidate]; + [client->calls removeAllObjects]; + [c checkSpelling: nil]; + [c checkTextInSelection: nil]; + [c checkTextInDocument: nil]; + [c considerTextCheckingForRange: NSMakeRange(0, 5)]; + PASS([client->calls count] == 0, + "an invalidated controller does not touch the client") + PASS([c menuAtIndex: 5 clickedOnSelection: NO effectiveRange: NULL] == nil, + "an invalidated controller has no menu") + + END_SET("NSTextCheckingController") + + return 0; +}