diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2cfd8..5567aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ ## Changelog +#### 2.0.1 - 2014/04/25 +**A minor update with small bugfixes and improvements, including:** + +* Replaced letter- and prefix- caches with simple cached results stack + thus making backspacing much faster (Issue #29) +* Previous/Next completion shortcuts now work properly (Issue #36) +* Completion List now automatically shows for one letter (Issue #37) +* Hide Inline Preview more reliably when disabled in settings +* Moved FuzzyAutocomplete menu item into Editor menu +* Added option to disable plugin in settings +* Fixed alphabetical sorting of results when using parallel scoring +* Reliability++ +* Performance++ + + #### 2.0.0 - 2014/04/16 **A major update introducing many fixes and improvements, including:** diff --git a/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.h b/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.h index 91d9e64..75593b8 100644 --- a/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.h +++ b/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.h @@ -10,6 +10,9 @@ @interface DVTTextCompletionInlinePreviewController (FuzzyAutocomplete) +/// Swizzles methods to enable/disable the plugin ++ (void) fa_swizzleMethods; + /// Matched ranges mapped to preview space. @property (nonatomic, retain) NSArray * fa_matchedRanges; diff --git a/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.m b/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.m index d40d4ce..2cf8bf3 100644 --- a/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.m +++ b/FuzzyAutocomplete/DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.m @@ -15,7 +15,7 @@ @implementation DVTTextCompletionInlinePreviewController (FuzzyAutocomplete) -+ (void) load { ++ (void) fa_swizzleMethods { [self jr_swizzleMethod: @selector(ghostComplementRange) withMethod: @selector(_fa_ghostComplementRange) error: NULL]; diff --git a/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.h b/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.h index 4dbe9de..64cba00 100644 --- a/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.h +++ b/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.h @@ -10,4 +10,7 @@ @interface DVTTextCompletionListWindowController (FuzzyAutocomplete) +/// Swizzles methods to enable/disable the plugin ++ (void) fa_swizzleMethods; + @end diff --git a/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.m b/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.m index 881d682..7a4f45a 100644 --- a/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.m +++ b/FuzzyAutocomplete/DVTTextCompletionListWindowController+FuzzyAutocomplete.m @@ -20,7 +20,7 @@ @implementation DVTTextCompletionListWindowController (FuzzyAutocomplete) -+ (void) load { ++ (void) fa_swizzleMethods { [self jr_swizzleMethod: @selector(tableView:willDisplayCell:forTableColumn:row:) withMethod: @selector(_fa_tableView:willDisplayCell:forTableColumn:row:) error: NULL]; @@ -209,15 +209,19 @@ - (void) _fa_hackModifyRowHeight { NSTableView * tableView = [self valueForKey: @"_completionsTableView"]; FATextCompletionListHeaderView * header = (FATextCompletionListHeaderView *) tableView.headerView; NSInteger rows = MIN(8, [self.session.filteredCompletionsAlpha count]); - double delta = header && rows ? (header.frame.size.height + 1) / rows : 0; - tableView.rowHeight += delta; + if (header && rows) { + tableView.rowHeight += (header.frame.size.height + 1) / rows; + } } // Restore the original row height. - (void) _fa_hackRestoreRowHeight { NSTableView * tableView = [self valueForKey: @"_completionsTableView"]; - tableView.rowHeight = [objc_getAssociatedObject(self, &kRowHeightKey) doubleValue]; + double rowHeight = [objc_getAssociatedObject(self, &kRowHeightKey) doubleValue]; + if (rowHeight > 0) { + tableView.rowHeight = rowHeight; + } } @end diff --git a/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.h b/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.h index afd0773..d9da2b7 100644 --- a/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.h +++ b/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.h @@ -15,6 +15,9 @@ @interface DVTTextCompletionSession (FuzzyAutocomplete) +/// Swizzles methods to enable/disable the plugin ++ (void) fa_swizzleMethods; + /// Current filtering query. - (NSString *) fa_filteringQuery; diff --git a/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.m b/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.m index a6692f0..1c92403 100644 --- a/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.m +++ b/FuzzyAutocomplete/DVTTextCompletionSession+FuzzyAutocomplete.m @@ -21,10 +21,25 @@ #import #define MIN_CHUNK_LENGTH 100 +/// A simple helper class to avoid using a dictionary in resultsStack +@interface FAFilteringResults : NSObject + +@property (nonatomic, retain) NSString * query; +@property (nonatomic, retain) NSArray * allItems; +@property (nonatomic, retain) NSArray * filteredItems; +@property (nonatomic, retain) NSDictionary * scores; +@property (nonatomic, retain) NSDictionary * ranges; +@property (nonatomic, assign) NSUInteger selection; + +@end + +@implementation FAFilteringResults + +@end @implementation DVTTextCompletionSession (FuzzyAutocomplete) -+ (void) load { ++ (void) fa_swizzleMethods { [self jr_swizzleMethod: @selector(_setFilteringPrefix:forceFilter:) withMethod: @selector(_fa_setFilteringPrefix:forceFilter:) error: NULL]; @@ -51,7 +66,15 @@ + (void) load { [self jr_swizzleMethod: @selector(insertCurrentCompletion) withMethod: @selector(_fa_insertCurrentCompletion) - error: nil]; + error: nil]; + + [self jr_swizzleMethod: @selector(_selectNextPreviousByPriority:) + withMethod: @selector(_fa_selectNextPreviousByPriority:) + error: nil]; + + [self jr_swizzleMethod: @selector(showCompletionsExplicitly:) + withMethod: @selector(_fa_showCompletionsExplicitly:) + error: nil]; } #pragma mark - public methods @@ -111,7 +134,15 @@ - (BOOL) _fa_insertCurrentCompletion { return ret; } -// We additionally refresh the theme upon session creation. +// We override here to hide inline preview if disabled +- (void) _fa_showCompletionsExplicitly: (BOOL) explicitly { + [self _fa_showCompletionsExplicitly: explicitly]; + if (![FASettings currentSettings].showInlinePreview) { + [self._inlinePreviewController hideInlinePreviewWithReason: 0x0]; + } +} + +// We additionally load the settings and refresh the theme upon session creation. - (instancetype) _fa_initWithTextView: (NSTextView *) textView atLocation: (NSInteger) location cursorLocation: (NSInteger) cursorLocation @@ -129,6 +160,8 @@ - (instancetype) _fa_initWithTextView: (NSTextView *) textView method.maxPrefixBonus = settings.maxPrefixBonus; session._fa_currentScoringMethod = method; + + session._fa_resultsStack = [NSMutableArray array]; } return session; } @@ -140,6 +173,28 @@ - (NSRange) _fa_rangeOfFirstWordInString: (NSString *) string { return NSMakeRange(0, string.length); } +// We override to calculate _filteredCompletionsAlpha before calling the original +// This way the hotkeys for prev/next by score use our scoring, not Xcode's +- (void) _fa_selectNextPreviousByPriority: (BOOL) next { + if (![self valueForKey: @"_filteredCompletionsPriority"]) { + NSArray * sorted = nil; + NSDictionary * filteredScores = self.fa_scoresForFilteredCompletions; + if ([FASettings currentSettings].sortByScore) { + sorted = self.filteredCompletionsAlpha.reverseObjectEnumerator.allObjects; + } else if (filteredScores) { + sorted = [self.filteredCompletionsAlpha sortedArrayWithOptions: NSSortConcurrent + usingComparator: ^(id obj1, id obj2) + { + NSComparisonResult result = [filteredScores[obj1.name] compare: filteredScores[obj2.name]]; + return result == NSOrderedSame ? [obj2.name caseInsensitiveCompare: obj1.name] : result; + }]; + } + [self setValue: sorted forKey: @"_filteredCompletionsPriority"]; + } + + [self _fa_selectNextPreviousByPriority: next]; +} + // We override to add formatting to the inline preview. // The ghostCompletionRange is also overriden to be after the last matched letter. - (NSDictionary *) _fa_attributesForCompletionAtCharacterIndex: (NSUInteger) index @@ -194,16 +249,22 @@ - (NSString *) _fa_usefulPartialCompletionPrefixForItems: (NSArray *) items - (void)_fa_setFilteringPrefix: (NSString *) prefix forceFilter: (BOOL) forceFilter { DLog(@"filteringPrefix = @\"%@\"", prefix); - self.fa_filteringTime = 0; + // remove all cached results which are not case-insensitive prefixes of the new prefix + // only if case-sensitive exact match happens the whole cached result is used + // when case-insensitive prefix match happens we can still use allItems as a start point + NSMutableArray * resultsStack = self._fa_resultsStack; + while (resultsStack.count && ![prefix.lowercaseString hasPrefix: [[resultsStack lastObject] query].lowercaseString]) { + [resultsStack removeLastObject]; + } - NSString *lastPrefix = [self valueForKey: @"_filteringPrefix"]; + self.fa_filteringTime = 0; // Let the original handler deal with the zero letter case if (prefix.length == 0) { NSTimeInterval start = [NSDate timeIntervalSinceReferenceDate]; - self.fa_matchedRangesForFilteredCompletions = nil; - self.fa_scoresForFilteredCompletions = nil; + [self._fa_resultsStack removeAllObjects]; + [self _fa_setFilteringPrefix:prefix forceFilter:forceFilter]; if (![FASettings currentSettings].showInlinePreview) { [self._inlinePreviewController hideInlinePreviewWithReason: 0x0]; @@ -228,146 +289,20 @@ - (void)_fa_setFilteringPrefix: (NSString *) prefix forceFilter: (BOOL) forceFil NSTimeInterval start = [NSDate timeIntervalSinceReferenceDate]; - NSArray *searchSet = nil; - [self setValue: prefix forKey: @"_filteringPrefix"]; - const NSInteger anchor = [FASettings currentSettings].prefixAnchor; - - NAMED_TIMER_START(ObtainSearchSet); + FAFilteringResults * results; - if (lastPrefix && [[prefix lowercaseString] hasPrefix: [lastPrefix lowercaseString]]) { - if (lastPrefix.length >= anchor) { - searchSet = self.fa_nonZeroMatches; - } else { - searchSet = [self _fa_filteredCompletionsForPrefix: [prefix substringToIndex: MIN(prefix.length, anchor)]]; - } + if (resultsStack.count && [prefix isEqualToString: [[resultsStack lastObject] query]]) { + results = [resultsStack lastObject]; } else { - if (anchor > 0) { - searchSet = [self _fa_filteredCompletionsForPrefix: [prefix substringToIndex: MIN(prefix.length, anchor)]]; - } else { - searchSet = [self _fa_filteredCompletionsForLetter: [prefix substringToIndex:1]]; - } - } - - NAMED_TIMER_STOP(ObtainSearchSet); - - NSMutableArray * filteredList; - NSMutableDictionary *filteredRanges; - NSMutableDictionary *filteredScores; - - __block id bestMatch = nil; - - NSUInteger workerCount = [FASettings currentSettings].parallelScoring ? [FASettings currentSettings].maximumWorkers : 1; - workerCount = MIN(MAX(searchSet.count / MIN_CHUNK_LENGTH, 1), workerCount); - - NAMED_TIMER_START(CalculateScores); - - if (workerCount < 2) { - bestMatch = [self _fa_bestMatchForQuery: prefix - inArray: searchSet - filteredList: &filteredList - rangesMap: &filteredRanges - scores: &filteredScores]; - } else { - dispatch_queue_t processingQueue = dispatch_queue_create("io.github.FuzzyAutocomplete.processing-queue", DISPATCH_QUEUE_CONCURRENT); - dispatch_queue_t reduceQueue = dispatch_queue_create("io.github.FuzzyAutocomplete.reduce-queue", DISPATCH_QUEUE_SERIAL); - dispatch_group_t group = dispatch_group_create(); - - NSMutableArray *bestMatches = [NSMutableArray array]; - filteredList = [NSMutableArray array]; - filteredRanges = [NSMutableDictionary dictionary]; - filteredScores = [NSMutableDictionary dictionary]; - - for (NSInteger i = 0; i < workerCount; ++i) { - dispatch_group_async(group, processingQueue, ^{ - NSArray *list; - NSDictionary *rangesMap; - NSDictionary *scoresMap; - NAMED_TIMER_START(Processing); - id bestMatch = [self _fa_bestMatchForQuery: prefix - inArray: searchSet - offset: i - total: workerCount - filteredList: &list - rangesMap: &rangesMap - scores: &scoresMap]; - NAMED_TIMER_STOP(Processing); - dispatch_async(reduceQueue, ^{ - NAMED_TIMER_START(Reduce); - if (bestMatch) { - [bestMatches addObject:bestMatch]; - } - [filteredList addObjectsFromArray:list]; - [filteredRanges addEntriesFromDictionary:rangesMap]; - [filteredScores addEntriesFromDictionary:scoresMap]; - NAMED_TIMER_STOP(Reduce); - }); - }); - } - - dispatch_group_wait(group, DISPATCH_TIME_FOREVER); - dispatch_sync(reduceQueue, ^{}); - - bestMatch = [self _fa_bestMatchForQuery: prefix - inArray: bestMatches - filteredList: nil - rangesMap: nil - scores: nil]; - + results = [self _fa_calculateResultsForQuery: prefix]; + [resultsStack addObject: results]; } - NAMED_TIMER_STOP(CalculateScores); - - if ([FASettings currentSettings].showInlinePreview) { - if ([self._inlinePreviewController isShowingInlinePreview]) { - [self._inlinePreviewController hideInlinePreviewWithReason:0x8]; - } - } - - // setter copies the array - self.fa_nonZeroMatches = filteredList; - - NAMED_TIMER_START(FilterByScore); - - double threshold = [FASettings currentSettings].minimumScoreThreshold; - if ([FASettings currentSettings].filterByScore && threshold != 0) { - if ([FASettings currentSettings].normalizeScores) { - threshold *= [filteredScores[bestMatch.name] doubleValue]; - } - NSMutableArray * newArray = [NSMutableArray array]; - for (id item in filteredList) { - if ([filteredScores[item.name] doubleValue] >= threshold) { - [newArray addObject: item]; - } - } - filteredList = newArray; - } - - NAMED_TIMER_STOP(FilterByScore); - - NAMED_TIMER_START(SortByScore); - - if ([FASettings currentSettings].sortByScore) { - [filteredList sortWithOptions: NSSortConcurrent usingComparator:^(id obj1, id obj2) { - return [filteredScores[obj2.name] compare: filteredScores[obj1.name]]; - }]; - } - - NAMED_TIMER_STOP(SortByScore); - - NAMED_TIMER_START(FindSelection); - - NSUInteger selection = filteredList.count && bestMatch ? [filteredList indexOfObject:bestMatch] : NSNotFound; - - NAMED_TIMER_STOP(FindSelection); - - [self setPendingRequestState: 0]; - - NSString * partial = [self _usefulPartialCompletionPrefixForItems: filteredList selectedIndex: selection filteringPrefix: prefix]; - - self.fa_matchedRangesForFilteredCompletions = filteredRanges; - self.fa_scoresForFilteredCompletions = filteredScores; + NSString * partial = [self _usefulPartialCompletionPrefixForItems: results.filteredItems + selectedIndex: results.selection + filteringPrefix: prefix]; self.fa_filteringTime = [NSDate timeIntervalSinceReferenceDate] - start; @@ -378,10 +313,11 @@ - (void)_fa_setFilteringPrefix: (NSString *) prefix forceFilter: (BOOL) forceFil [self willChangeValueForKey:@"usefulPrefix"]; [self willChangeValueForKey:@"selectedCompletionIndex"]; - [self setValue: filteredList forKey: @"_filteredCompletionsAlpha"]; + [self setValue: results.filteredItems forKey: @"_filteredCompletionsAlpha"]; [self setValue: partial forKey: @"_usefulPrefix"]; - [self setValue: @(selection) forKey: @"_selectedCompletionIndex"]; - + [self setValue: @(results.selection) forKey: @"_selectedCompletionIndex"]; + [self setValue: nil forKey: @"_filteredCompletionsPriority"]; + [self didChangeValueForKey:@"filteredCompletionsAlpha"]; [self didChangeValueForKey:@"usefulPrefix"]; [self didChangeValueForKey:@"selectedCompletionIndex"]; @@ -402,26 +338,174 @@ - (void)_fa_setFilteringPrefix: (NSString *) prefix forceFilter: (BOOL) forceFil } -static char letterFilteredCompletionCacheKey; -static char prefixFilteredCompletionCacheKey; - // We nullify the caches when completions change. - (void) _fa_setAllCompletions: (NSArray *) allCompletions { [self _fa_setAllCompletions:allCompletions]; - self.fa_matchedRangesForFilteredCompletions = nil; - self.fa_scoresForFilteredCompletions = nil; - [objc_getAssociatedObject(self, &letterFilteredCompletionCacheKey) removeAllObjects]; - [objc_getAssociatedObject(self, &prefixFilteredCompletionCacheKey) removeAllObjects]; + [self._fa_resultsStack removeAllObjects]; } #pragma mark - helpers +// Calculate all the results needed by setFilteringPrefix +- (FAFilteringResults *)_fa_calculateResultsForQuery: (NSString *) query { + + FAFilteringResults * results = [[FAFilteringResults alloc] init]; + results.query = query; + + NSArray *searchSet = nil; + NSMutableArray * filteredList = nil; + __block NSMutableDictionary * filteredRanges = nil; + __block NSMutableDictionary * filteredScores = nil; + + const NSInteger anchor = [FASettings currentSettings].prefixAnchor; + + FAFilteringResults * lastResults = [self _fa_lastFilteringResults]; + + NAMED_TIMER_START(ObtainSearchSet); + + if (lastResults.query.length && [[query lowercaseString] hasPrefix: [lastResults.query lowercaseString]]) { + if (lastResults.query.length >= anchor) { + searchSet = lastResults.allItems; + } else { + searchSet = [self _fa_filteredCompletionsForPrefix: [query substringToIndex: MIN(query.length, anchor)]]; + } + } else { + if (anchor > 0) { + searchSet = [self _fa_filteredCompletionsForPrefix: [query substringToIndex: MIN(query.length, anchor)]]; + } else { + searchSet = [self _fa_filteredCompletionsForLetter: [query substringToIndex:1]]; + } + } + + NAMED_TIMER_STOP(ObtainSearchSet); + + __block id bestMatch = nil; + + NSUInteger workerCount = [FASettings currentSettings].parallelScoring ? [FASettings currentSettings].maximumWorkers : 1; + workerCount = MIN(MAX(searchSet.count / MIN_CHUNK_LENGTH, 1), workerCount); + + NAMED_TIMER_START(CalculateScores); + + if (workerCount < 2) { + bestMatch = [self _fa_bestMatchForQuery: query + inArray: searchSet + filteredList: &filteredList + rangesMap: &filteredRanges + scores: &filteredScores]; + } else { + dispatch_queue_t processingQueue = dispatch_queue_create("io.github.FuzzyAutocomplete.processing-queue", DISPATCH_QUEUE_CONCURRENT); + dispatch_queue_t reduceQueue = dispatch_queue_create("io.github.FuzzyAutocomplete.reduce-queue", DISPATCH_QUEUE_SERIAL); + dispatch_group_t group = dispatch_group_create(); + + NSMutableArray * sortedItemArrays = [NSMutableArray array]; + for (NSInteger i = 0; i < workerCount; ++i) { + [sortedItemArrays addObject: @[]]; + } + + for (NSInteger i = 0; i < workerCount; ++i) { + dispatch_group_async(group, processingQueue, ^{ + NSMutableArray *list; + NSMutableDictionary *rangesMap; + NSMutableDictionary *scoresMap; + NAMED_TIMER_START(Processing); + id goodMatch = [self _fa_bestMatchForQuery: query + inArray: searchSet + offset: i + total: workerCount + filteredList: &list + rangesMap: &rangesMap + scores: &scoresMap]; + NAMED_TIMER_STOP(Processing); + dispatch_async(reduceQueue, ^{ + NAMED_TIMER_START(Reduce); + sortedItemArrays[i] = list; + if (!filteredRanges) { + filteredRanges = rangesMap; + filteredScores = scoresMap; + bestMatch = goodMatch; + } else { + [filteredRanges addEntriesFromDictionary: rangesMap]; + [filteredScores addEntriesFromDictionary: scoresMap]; + if ([filteredScores[goodMatch.name] doubleValue] > [filteredScores[bestMatch.name] doubleValue]) { + bestMatch = goodMatch; + } + } + NAMED_TIMER_STOP(Reduce); + }); + }); + } + + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + dispatch_sync(reduceQueue, ^{}); + + filteredList = sortedItemArrays[0]; + for (NSInteger i = 1; i < workerCount; ++i) { + [filteredList addObjectsFromArray: sortedItemArrays[i]]; + } + } + + NAMED_TIMER_STOP(CalculateScores); + + results.allItems = [NSArray arrayWithArray: filteredList]; + + NAMED_TIMER_START(FilterByScore); + + double threshold = [FASettings currentSettings].minimumScoreThreshold; + if ([FASettings currentSettings].filterByScore && threshold != 0) { + if ([FASettings currentSettings].normalizeScores) { + threshold *= [filteredScores[bestMatch.name] doubleValue]; + } + NSMutableArray * newArray = [NSMutableArray array]; + for (id item in filteredList) { + if ([filteredScores[item.name] doubleValue] >= threshold) { + [newArray addObject: item]; + } + } + filteredList = newArray; + } + + NAMED_TIMER_STOP(FilterByScore); + + NAMED_TIMER_START(SortByScore); + + if ([FASettings currentSettings].sortByScore) { + [filteredList sortWithOptions: NSSortConcurrent usingComparator:^(id obj1, id obj2) { + NSComparisonResult result = [filteredScores[obj2.name] compare: filteredScores[obj1.name]]; + return result == NSOrderedSame ? [obj1.name caseInsensitiveCompare: obj2.name] : result; + }]; + } + + NAMED_TIMER_STOP(SortByScore); + + NAMED_TIMER_START(FindSelection); + + if (!filteredList.count || !bestMatch) { + results.selection = NSNotFound; + } else { + if ([FASettings currentSettings].sortByScore) { + results.selection = 0; + } else { + results.selection = [self _fa_indexOfFirstElementInSortedRange: NSMakeRange(0, filteredList.count) inArray: filteredList passingTest: ^BOOL(id item) { + return [item.name caseInsensitiveCompare: bestMatch.name] != NSOrderedAscending; + }]; + } + } + + NAMED_TIMER_STOP(FindSelection); + + results.filteredItems = filteredList; + results.ranges = filteredRanges; + results.scores = filteredScores; + + return results; +} + // Score the items, store filtered list, matched ranges, scores and the best match. - (id) _fa_bestMatchForQuery: (NSString *) query inArray: (NSArray *) array - filteredList: (NSArray **) filtered - rangesMap: (NSDictionary **) ranges - scores: (NSDictionary **) scores + filteredList: (NSMutableArray **) filtered + rangesMap: (NSMutableDictionary **) ranges + scores: (NSMutableDictionary **) scores { return [self _fa_bestMatchForQuery:query inArray:array offset:0 total:1 filteredList:filtered rangesMap:ranges scores:scores]; } @@ -431,14 +515,14 @@ - (void) _fa_setAllCompletions: (NSArray *) allCompletions { inArray: (NSArray *) array offset: (NSUInteger) offset total: (NSUInteger) total - filteredList: (NSArray **) filtered - rangesMap: (NSDictionary **) ranges - scores: (NSDictionary **) scores + filteredList: (NSMutableArray **) filtered + rangesMap: (NSMutableDictionary **) ranges + scores: (NSMutableDictionary **) scores { IDEOpenQuicklyPattern *pattern = [[IDEOpenQuicklyPattern alloc] initWithPattern:query]; - NSMutableArray *filteredList = filtered ? [NSMutableArray arrayWithCapacity: array.count] : nil; - NSMutableDictionary *filteredRanges = ranges ? [NSMutableDictionary dictionaryWithCapacity: array.count] : nil; - NSMutableDictionary *filteredScores = scores ? [NSMutableDictionary dictionaryWithCapacity: array.count] : nil; + NSMutableArray *filteredList = filtered ? [NSMutableArray arrayWithCapacity: array.count / total] : nil; + NSMutableDictionary *filteredRanges = ranges ? [NSMutableDictionary dictionaryWithCapacity: array.count / total] : nil; + NSMutableDictionary *filteredScores = scores ? [NSMutableDictionary dictionaryWithCapacity: array.count / total] : nil; double highScore = 0.0f; id bestMatch; @@ -511,104 +595,86 @@ - (void) _fa_setAllCompletions: (NSArray *) allCompletions { return bestMatch; } -// Performs a simple binary search to find rirst item with given prefix. -- (NSInteger) _fa_indexOfFirstItemWithPrefix: (NSString *) prefix inSortedArray: (NSArray *) array { - const NSUInteger N = array.count; - - if (N == 0) return NSNotFound; - - id item; - - if ([(item = array[0]).name compare: prefix options: NSCaseInsensitiveSearch] == NSOrderedDescending) { - if ([[item.name lowercaseString] hasPrefix: prefix]) { - return 0; - } else { - return NSNotFound; +// Returns index of first element passing test, or NSNotFound, assumes sorted range wrt test +- (NSUInteger) _fa_indexOfFirstElementInSortedRange: (NSRange) range + inArray: (NSArray *) array + passingTest: (BOOL(^)(id)) test +{ + if (range.length == 0) return NSNotFound; + NSUInteger a = range.location, b = range.location + range.length - 1; + if (test(array[a])) { + return a; + } else if (!test(array[b])) { + return NSNotFound; + } else { + while (b > a + 1) { + NSUInteger c = (a + b) / 2; + if (test(array[c])) { + b = c; + } else { + a = c; + } } + return b; } +} - if ([(item = array[N-1]).name compare: prefix options: NSCaseInsensitiveSearch] == NSOrderedAscending) { - return NSNotFound; - } +// Performs binary searches to find items with given prefix. +- (NSRange) _fa_rangeOfItemsWithPrefix: (NSString *) prefix + inSortedRange: (NSRange) range + inArray: (NSArray *) array +{ + NSUInteger lowerBound = [self _fa_indexOfFirstElementInSortedRange: range inArray: array passingTest: ^BOOL(id item) { + return [item.name caseInsensitiveCompare: prefix] != NSOrderedAscending; + }]; - NSUInteger a = 0, b = N-1; - while (b > a+1) { - NSUInteger c = (a + b) / 2; - if ([(item = array[c]).name compare: prefix options: NSCaseInsensitiveSearch] == NSOrderedAscending) { - a = c; - } else { - b = c; - } + if (lowerBound == NSNotFound) { + return NSMakeRange(0, 0); } - if ([[(item = array[a]).name lowercaseString] hasPrefix: prefix]) { - return a; - } - if ([[(item = array[b]).name lowercaseString] hasPrefix: prefix]) { - return b; + range.location += lowerBound; range.length -= lowerBound; + + NSUInteger upperBound = [self _fa_indexOfFirstElementInSortedRange: range inArray: array passingTest: ^BOOL(id item) { + return ![item.name.lowercaseString hasPrefix: prefix]; + }]; + + if (upperBound != NSNotFound) { + range.length = upperBound - lowerBound; } - return NSNotFound; + return range; } // gets a subset of allCompletions for given prefix - (NSArray *) _fa_filteredCompletionsForPrefix: (NSString *) prefix { prefix = [prefix lowercaseString]; - NSMutableDictionary *filteredCompletionCache = objc_getAssociatedObject(self, &prefixFilteredCompletionCacheKey); - if (!filteredCompletionCache) { - filteredCompletionCache = [NSMutableDictionary dictionary]; - objc_setAssociatedObject(self, &prefixFilteredCompletionCacheKey, filteredCompletionCache, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - NSArray *completionsForPrefix = filteredCompletionCache[prefix]; - if (!completionsForPrefix) { - NSArray * searchSet = self.allCompletions; - for (int i = 1; i < prefix.length; ++i) { - NSArray * cached = filteredCompletionCache[[prefix substringToIndex: i]]; - if (cached) { - searchSet = cached; - } - } + FAFilteringResults * lastResults = [self _fa_lastFilteringResults]; + NSArray * array; + if ([lastResults.query.lowercaseString hasPrefix: prefix]) { + array = lastResults.allItems; + } else { + NSArray * searchSet = lastResults.allItems ?: self.allCompletions; // searchSet is sorted so we can do a binary search - NSUInteger idx = [self _fa_indexOfFirstItemWithPrefix: prefix inSortedArray: searchSet]; - if (idx == NSNotFound) { - completionsForPrefix = @[]; - } else { - NSMutableArray * array = [NSMutableArray array]; - const NSUInteger N = searchSet.count; - id item; - while (idx < N && [(item = searchSet[idx]).name.lowercaseString hasPrefix: prefix]) { - [array addObject: item]; - ++idx; - } - completionsForPrefix = array; - } + NSRange range = [self _fa_rangeOfItemsWithPrefix: prefix inSortedRange: NSMakeRange(0, searchSet.count) inArray: searchSet]; + array = [searchSet subarrayWithRange: range]; } - return completionsForPrefix; + return array; } // gets a subset of allCompletions for given letter - (NSArray *) _fa_filteredCompletionsForLetter: (NSString *) letter { letter = [letter lowercaseString]; - NSMutableDictionary *filteredCompletionCache = objc_getAssociatedObject(self, &letterFilteredCompletionCacheKey); - if (!filteredCompletionCache) { - filteredCompletionCache = [NSMutableDictionary dictionary]; - objc_setAssociatedObject(self, &letterFilteredCompletionCacheKey, filteredCompletionCache, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - NSArray *completionsForLetter = [filteredCompletionCache objectForKey:letter]; - if (!completionsForLetter) { - NSString * lowerAndUpper = [letter stringByAppendingString: letter.uppercaseString]; - NSCharacterSet * set = [NSCharacterSet characterSetWithCharactersInString: lowerAndUpper]; - NSMutableArray * array = [NSMutableArray array]; - for (id item in self.allCompletions) { - NSRange range = [item.name rangeOfCharacterFromSet: set]; - if (range.location != NSNotFound) { - [array addObject: item]; - } + + NSString * lowerAndUpper = [letter stringByAppendingString: letter.uppercaseString]; + NSCharacterSet * set = [NSCharacterSet characterSetWithCharactersInString: lowerAndUpper]; + NSMutableArray * array = [NSMutableArray array]; + for (id item in self.allCompletions) { + NSRange range = [item.name rangeOfCharacterFromSet: set]; + if (range.location != NSNotFound) { + [array addObject: item]; } - completionsForLetter = array; - filteredCompletionCache[letter] = completionsForLetter; } - return completionsForLetter; + return array; } - (void)_fa_debugCompletionsByScore:(NSArray *)completions withQuery:(NSString *)query { @@ -675,34 +741,27 @@ - (void) setFa_insertingCompletion: (BOOL) value { objc_setAssociatedObject(self, &insertingCompletionKey, @(value), OBJC_ASSOCIATION_RETAIN_NONATOMIC); } -static char matchedRangesKey; - - (NSDictionary *) fa_matchedRangesForFilteredCompletions { - return objc_getAssociatedObject(self, &matchedRangesKey); -} - -- (void) setFa_matchedRangesForFilteredCompletions: (NSDictionary *) dict { - objc_setAssociatedObject(self, &matchedRangesKey, dict, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -static char scoresKey; - -- (void) setFa_scoresForFilteredCompletions: (NSDictionary *) dict { - objc_setAssociatedObject(self, &scoresKey, dict, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + NSArray * stack = [self _fa_resultsStack]; + return stack.count ? [stack.lastObject ranges] : nil; } - (NSDictionary *) fa_scoresForFilteredCompletions { - return objc_getAssociatedObject(self, &scoresKey); + NSArray * stack = [self _fa_resultsStack]; + return stack.count ? [stack.lastObject scores] : nil; } -static char kNonZeroMatchesKey; +static char kResultsStackKey; +- (NSMutableArray *) _fa_resultsStack { + return objc_getAssociatedObject(self, &kResultsStackKey); +} -- (NSArray *) fa_nonZeroMatches { - return objc_getAssociatedObject(self, &kNonZeroMatchesKey); +- (void) set_fa_resultsStack: (NSMutableArray *) stack { + objc_setAssociatedObject(self, &kResultsStackKey, stack, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } -- (void) setFa_nonZeroMatches: (NSArray *) array { - objc_setAssociatedObject(self, &kNonZeroMatchesKey, array, OBJC_ASSOCIATION_COPY_NONATOMIC); +- (FAFilteringResults *) _fa_lastFilteringResults { + return self._fa_resultsStack.count ? self._fa_resultsStack.lastObject : nil; } -@end +@end \ No newline at end of file diff --git a/FuzzyAutocomplete/FASettings.h b/FuzzyAutocomplete/FASettings.h index 29ed27a..1f44617 100644 --- a/FuzzyAutocomplete/FASettings.h +++ b/FuzzyAutocomplete/FASettings.h @@ -8,6 +8,8 @@ #import +extern NSString * FASettingsPluginEnabledDidChangeNotification; + @interface FASettings : NSObject /// Gets the singleton. Note that the settings are not loaded automatically. @@ -22,6 +24,9 @@ /// Reset to the default values. - (IBAction) resetDefaults: (id) sender; +/// Is the plugin enabled. +@property (nonatomic, readonly) BOOL pluginEnabled; + /// How many workers should work in parallel. @property (nonatomic, readonly) NSInteger prefixAnchor; diff --git a/FuzzyAutocomplete/FASettings.m b/FuzzyAutocomplete/FASettings.m index ad1911c..dcc1191 100644 --- a/FuzzyAutocomplete/FASettings.m +++ b/FuzzyAutocomplete/FASettings.m @@ -10,11 +10,15 @@ #import "FASettings.h" #import "FATheme.h" +NSString * FASettingsPluginEnabledDidChangeNotification = @"io.github.FuzzyAutocomplete.PluginEnabledDidChange"; + // increment to show settings screen to the user -static const NSUInteger kSettingsVersion = 1; +static const NSUInteger kSettingsVersion = 2; @interface FASettings () +@property (nonatomic, readwrite) BOOL pluginEnabled; + @property (nonatomic, readwrite) double minimumScoreThreshold; @property (nonatomic, readwrite) BOOL filterByScore; @property (nonatomic, readwrite) BOOL sortByScore; @@ -70,7 +74,15 @@ - (void) showSettingsWindow { style.alignment = NSCenterTextAlignment; [attributed addAttribute: NSParagraphStyleAttributeName value: style range: NSMakeRange(0, attributed.length)]; label.attributedStringValue = attributed; + + BOOL enabled = self.pluginEnabled; + [NSApp runModalForWindow: window]; + + if (self.pluginEnabled != enabled) { + [[NSNotificationCenter defaultCenter] postNotificationName: FASettingsPluginEnabledDidChangeNotification object: self]; + } + [[NSUserDefaults standardUserDefaults] synchronize]; } } @@ -84,6 +96,8 @@ - (void) windowWillClose: (NSNotification *) notification { #pragma mark - defaults +static const BOOL kDefaultPluginEnabled = YES; + static const double kDefaultMinimumScoreThreshold = 0.01; static const NSInteger kDefaultPrefixAnchor = 0; static const BOOL kDefaultSortByScore = YES; @@ -101,6 +115,8 @@ - (void) windowWillClose: (NSNotification *) notification { static const double kDefaultMaxPrefixBonus = 0.5; - (IBAction)resetDefaults:(id)sender { + self.pluginEnabled = kDefaultPluginEnabled; + self.minimumScoreThreshold = kDefaultMinimumScoreThreshold; self.filterByScore = kDefaultFilterByScore; self.sortByScore = kDefaultSortByScore; @@ -134,6 +150,8 @@ - (void) loadFromDefaults { number = [defaults objectForKey: k ## Name ## Key]; \ [self setValue: number ?: @(kDefault ## Name) forKey: @#name] + loadNumber(pluginEnabled, PluginEnabled); + loadNumber(minimumScoreThreshold, MinimumScoreThreshold); loadNumber(sortByScore, SortByScore); loadNumber(filterByScore, FilterByScore); @@ -156,14 +174,16 @@ - (void) loadFromDefaults { self.scoreFormat = [defaults stringForKey: kScoreFormatKey] ?: kDefaultScoreFormat; number = [defaults objectForKey: kSettingsVersionKey]; + if (!number || [number unsignedIntegerValue] < kSettingsVersion) { + [self migrateSettingsFromVersion: [number unsignedIntegerValue]]; NSString * pluginName = [NSBundle bundleForClass: self.class].lsl_bundleName; [defaults setObject: @(kSettingsVersion) forKey: kSettingsVersionKey]; NSAlert * alert = [NSAlert alertWithMessageText: [NSString stringWithFormat: @"New settings for %@.", pluginName] defaultButton: @"View" alternateButton: @"Skip" otherButton: nil - informativeTextWithFormat: @"New settings are available for %@ plugin. Do you want to review them now? You can always access the settings later from the Menu: Xcode > %@ > Plugin Settings...", pluginName, pluginName]; + informativeTextWithFormat: @"New settings are available for %@ plugin. Do you want to review them now? You can always access the settings later from the Menu: Editor > %@ > Plugin Settings...", pluginName, pluginName]; if ([alert runModal] == NSAlertDefaultReturn) { [self showSettingsWindow]; } @@ -173,6 +193,17 @@ - (void) loadFromDefaults { } +# pragma mark - migrate + +- (void) migrateSettingsFromVersion:(NSUInteger)version { + switch (version) { + case 0: // just break, dont migrate for 0 + break; + case 1: // dont break, fall through to higher cases + ; + } +} + # pragma mark - boilerplate // use macros to avoid some copy-paste errors @@ -194,6 +225,8 @@ - (void) set ## Name: (type) name { \ SETTINGS_KEY(SettingsVersion); +BOOL_SETTINGS_SETTER(pluginEnabled, PluginEnabled) + BOOL_SETTINGS_SETTER(showScores, ShowScores) BOOL_SETTINGS_SETTER(filterByScore, FilterByScore) BOOL_SETTINGS_SETTER(sortByScore, SortByScore) diff --git a/FuzzyAutocomplete/FASettingsWindow.xib b/FuzzyAutocomplete/FASettingsWindow.xib index 0138f21..447b118 100644 --- a/FuzzyAutocomplete/FASettingsWindow.xib +++ b/FuzzyAutocomplete/FASettingsWindow.xib @@ -1,8 +1,8 @@ - + - + @@ -432,11 +432,11 @@ Especially useful for adjusting the threshold and debugging. - + - If enabled the completion list header will contain time it took to score the completion items. Useful mostly for profiling and debugging. + If enabled, the completion list header will contain time it took to score the completion items. Useful mostly for profiling and debugging. @@ -577,7 +577,26 @@ Prefix - maximum bonus factor for prefix matches + + + + NSNegateBoolean + + + + + + + + + + + + + + + + diff --git a/FuzzyAutocomplete/FuzzyAutocomplete-Info.plist b/FuzzyAutocomplete/FuzzyAutocomplete-Info.plist index e38c063..d14f851 100644 --- a/FuzzyAutocomplete/FuzzyAutocomplete-Info.plist +++ b/FuzzyAutocomplete/FuzzyAutocomplete-Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2.0.0 + 2.0.1 CFBundleSignature ???? CFBundleVersion - 2.0.0 + 2.0.1 DVTPlugInCompatibilityUUIDs A2E4D43F-41F4-4FB9-BB94-7177011C9AED diff --git a/FuzzyAutocomplete/FuzzyAutocomplete.m b/FuzzyAutocomplete/FuzzyAutocomplete.m index 57986c9..f98cf89 100644 --- a/FuzzyAutocomplete/FuzzyAutocomplete.m +++ b/FuzzyAutocomplete/FuzzyAutocomplete.m @@ -12,6 +12,10 @@ #import "FuzzyAutocomplete.h" #import "FASettings.h" +#import "DVTTextCompletionSession+FuzzyAutocomplete.h" +#import "DVTTextCompletionListWindowController+FuzzyAutocomplete.h" +#import "DVTTextCompletionInlinePreviewController+FuzzyAutocomplete.h" + @implementation FuzzyAutocomplete + (void)pluginDidLoad:(NSBundle *)plugin { @@ -20,41 +24,81 @@ + (void)pluginDidLoad:(NSBundle *)plugin { if ([currentApplicationName isEqual:@"Xcode"]) { dispatch_once(&onceToken, ^{ - [self createMenuItem: plugin]; + [self createMenuItem]; + [[NSNotificationCenter defaultCenter] addObserver: self + selector: @selector(applicationDidFinishLaunching:) + name: NSApplicationDidFinishLaunchingNotification + object: nil]; + [[NSNotificationCenter defaultCenter] addObserver: self + selector: @selector(menuDidChange:) + name: NSMenuDidChangeItemNotification + object: nil]; }); } } -+ (void)createMenuItem: (NSBundle *) pluginBundle { ++ (void) pluginEnabledOrDisabled: (NSNotification *) notification { + if (notification.object == [FASettings currentSettings]) { + [self swizzleMethods]; + } +} + ++ (void) applicationDidFinishLaunching: (NSNotification *) notification { + [[NSNotificationCenter defaultCenter] removeObserver: self name: NSApplicationDidFinishLaunchingNotification object: nil]; + [[FASettings currentSettings] loadFromDefaults]; + if ([FASettings currentSettings].pluginEnabled) { + [self swizzleMethods]; + } + [[NSNotificationCenter defaultCenter] addObserver: self + selector: @selector(pluginEnabledOrDisabled:) + name: FASettingsPluginEnabledDidChangeNotification + object: nil]; +} + ++ (void) menuDidChange: (NSNotification *) notification { + [self createMenuItem]; +} + ++ (void)createMenuItem { + NSBundle * pluginBundle = [NSBundle bundleForClass: self]; NSString * name = pluginBundle.lsl_bundleName; - NSMenuItem * xcodeMenuItem = [[NSApp mainMenu] itemAtIndex: 0]; - NSMenuItem * fuzzyItem = [[NSMenuItem alloc] initWithTitle: name - action: NULL - keyEquivalent: @""]; - - NSString * version = [@"Plugin Version: " stringByAppendingString: pluginBundle.lsl_bundleVersion]; - NSMenuItem * versionItem = [[NSMenuItem alloc] initWithTitle: version - action: NULL - keyEquivalent: @""]; - - NSMenuItem * settingsItem = [[NSMenuItem alloc] initWithTitle: @"Plugin Settings..." - action: @selector(showSettingsWindow) - keyEquivalent: @""]; - - settingsItem.target = [FASettings currentSettings]; - - fuzzyItem.submenu = [[NSMenu alloc] initWithTitle: name]; - [fuzzyItem.submenu addItem: versionItem]; - [fuzzyItem.submenu addItem: settingsItem]; - - NSInteger menuIndex = [xcodeMenuItem.submenu indexOfItemWithTitle: @"Behaviors"]; - if (menuIndex == -1) { - menuIndex = 3; - } else { - ++menuIndex; + NSMenuItem * editorMenuItem = [[NSApp mainMenu] itemWithTitle: @"Editor"]; + + if (editorMenuItem && ![editorMenuItem.submenu itemWithTitle: name]) { + NSMenuItem * fuzzyItem = [[NSMenuItem alloc] initWithTitle: name + action: NULL + keyEquivalent: @""]; + + NSString * version = [@"Plugin Version: " stringByAppendingString: pluginBundle.lsl_bundleVersion]; + NSMenuItem * versionItem = [[NSMenuItem alloc] initWithTitle: version + action: NULL + keyEquivalent: @""]; + + NSMenuItem * settingsItem = [[NSMenuItem alloc] initWithTitle: @"Plugin Settings..." + action: @selector(showSettingsWindow) + keyEquivalent: @""]; + + settingsItem.target = [FASettings currentSettings]; + + fuzzyItem.submenu = [[NSMenu alloc] initWithTitle: name]; + [fuzzyItem.submenu addItem: versionItem]; + [fuzzyItem.submenu addItem: settingsItem]; + + NSInteger menuIndex = [editorMenuItem.submenu indexOfItemWithTitle: @"Show Completions"]; + if (menuIndex == -1) { + [editorMenuItem.submenu addItem: [NSMenuItem separatorItem]]; + [editorMenuItem.submenu addItem: fuzzyItem]; + } else { + [editorMenuItem.submenu insertItem: fuzzyItem atIndex: menuIndex]; + } + } +} - [xcodeMenuItem.submenu insertItem: fuzzyItem atIndex: menuIndex]; ++ (void) swizzleMethods { + [DVTTextCompletionSession fa_swizzleMethods]; + [DVTTextCompletionListWindowController fa_swizzleMethods]; + [DVTTextCompletionInlinePreviewController fa_swizzleMethods]; } @end diff --git a/README.md b/README.md index 06db24d..8557882 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ Like nifty tools like this plugin? Check out [Shortcat](https://shortcatapp.com/ * Xcode's autocompletion matches like **Open Quickly** does * Supports Xcode's learning and context-aware priority system * [New] Visualizes matches in Completion List and Inline Preview -* [New] Easily customizable via a Settings Window +* [New] Easily customizable via a Settings Window (Editor > FuzzyAutocomplete) * [New] [Optional] Sorts items by their score for easier searching * [New] [Optional] Hides items based on a threshold for less clutter * [New] [Optional] Shows the query and number of matches in Header View * [New] [Optional] Shows match scores for items in the List -* [Optional] Treats first few query letters as a rquired prefix +* [New] Selects prev/next completion with shortcuts (default `⌃>` / `⌃.`) +* [Optional] Treats first few query letters as a required prefix * Productivity++ *[New] denotes a feature added in 2.0* @@ -44,10 +45,27 @@ Like nifty tools like this plugin? Check out [Shortcat](https://shortcatapp.com/ * Either: * Install with [Alcatraz](http://alcatraz.io/) * Clone and build the project -* Restart Xcode and enjoy! + * Download and unzip a release to + `~/Library/Application Support/Developer/Shared/Xcode/Plug-ins/` +* Restart Xcode and enjoy! + * You should now see a `FuzzyAutocomplete` menu item in `Editor` menu ## Changelog +#### 2.0.1 - 2014/04/25 +**A minor update with small bugfixes and improvements, including:** + +* Replaced letter- and prefix- caches with simple cached results stack + thus making backspacing much faster (Issue #29) +* Previous/Next completion shortcuts now work properly (Issue #36) +* Completion List now automatically shows for one letter (Issue #37) +* Hide Inline Preview more reliably when disabled in settings +* Moved FuzzyAutocomplete menu item into Editor menu +* Added option to disable plugin in settings +* Fixed alphabetical sorting of results when using parallel scoring +* Reliability++ +* Performance++ + #### 2.0.0 - 2014/04/16 **A major update introducing many fixes and improvements, including:**