From c42e19a96ebd8dbc15eeb4f15029812691cde9b4 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 19 May 2018 02:08:02 +0200 Subject: =?UTF-8?q?#63:=20*=20Fix=20filter=20rule=20editor=20not=20working?= =?UTF-8?q?=20on=2010.6=20(caused=20by=20Apple=E2=80=99s=20abysmally=20vag?= =?UTF-8?q?ue=20documentation=20on=20NSRuleEditor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Also fix filter dropdown not updating after filter definitions have been changed --- Source/SPRuleFilterController.h | 2 + Source/SPRuleFilterController.m | 181 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 165 insertions(+), 18 deletions(-) diff --git a/Source/SPRuleFilterController.h b/Source/SPRuleFilterController.h index 4d425329..5dc4b07f 100644 --- a/Source/SPRuleFilterController.h +++ b/Source/SPRuleFilterController.h @@ -57,6 +57,8 @@ NSString * const SPRuleFilterHeightChangedNotification; SEL action; BOOL enabled; + + NSUInteger opNodeCacheVersion; } /** diff --git a/Source/SPRuleFilterController.m b/Source/SPRuleFilterController.m index 104e1cbc..99b7824d 100644 --- a/Source/SPRuleFilterController.m +++ b/Source/SPRuleFilterController.m @@ -120,10 +120,12 @@ const NSString * const SerFilterExprDefinition = @"_filterDefinition"; NSString *name; NSString *typegrouping; NSArray *operatorCache; + NSUInteger opCacheVersion; } @property(copy, nonatomic) NSString *name; @property(copy, nonatomic) NSString *typegrouping; @property(retain, nonatomic) NSArray *operatorCache; +@property(assign, nonatomic) NSUInteger opCacheVersion; @end @interface StringNode : RuleNode { @@ -221,6 +223,10 @@ static void _addIfNotNil(NSMutableArray *array, id toAdd); - (void)_resize; - (void)openContentFilterManagerForFilterType:(NSString *)filterType; - (IBAction)filterTable:(id)sender; +- (IBAction)_menuItemInRuleEditorClicked:(id)sender; +- (void)_pretendPlayRuleEditorForCriteria:(NSMutableArray *)criteria displayValues:(NSMutableArray *)displayValues inRow:(NSInteger)row; +- (void)_ensureValidOperatorCache:(ColumnNode *)col; +static BOOL _arrayContainsInViewHierarchy(NSArray *haystack, id needle); @end @@ -238,6 +244,7 @@ static void _addIfNotNil(NSMutableArray *array, id toAdd); preferredHeight = 0.0; target = nil; action = NULL; + opNodeCacheVersion = 1; // Init default filters for Content Browser contentFilters = [[NSMutableDictionary alloc] init]; @@ -357,10 +364,7 @@ static void _addIfNotNil(NSMutableArray *array, id toAdd); RuleNodeType type = [(RuleNode *)criterion type]; if(type == RuleNodeTypeColumn) { ColumnNode *node = (ColumnNode *)criterion; - if(![node operatorCache]) { - NSArray *ops = [self _compareTypesForColumn:node]; - [node setOperatorCache:ops]; - } + [self _ensureValidOperatorCache:node]; return [[node operatorCache] count]; } // the first child of an operator is the first argument (if it has one) @@ -454,23 +458,29 @@ static void _addIfNotNil(NSMutableArray *array, id toAdd); item = [NSMenuItem separatorItem]; } else { + /* NOTE: + * Apple's doc on NSRuleEditor says that returning NSMenuItems is supported. + * However there seems to be a major discrepancy between what Apple considers "supported" and what any + * sane person would consider supported. + * + * Basically one would expect NSMenuItems to be handled in the same way a number of NSString children of a + * row's element will be handled, but that was not Apples intention. By supported they actually mean + * "Your app won't crash immediately if you return an NSMenuItem here" - but that's about it. + * Even selecting such an NSMenuItem will already cause an exception on 10.6 and be treated as a NOOP on + * later OSes. + * So if we return NSMenuItems we have to implement the full logic of the NSRuleEditor for updating and + * displaying the row ourselves, starting with handling the target/action of the NSMenuItems! + */ item = [[NSMenuItem alloc] initWithTitle:[[node settings] objectForKey:@"title"] action:NULL keyEquivalent:@""]; [item setToolTip:[[node settings] objectForKey:@"tooltip"]]; [item setTag:[[[node settings] objectForKey:@"tag"] integerValue]]; - //TODO the following seems to be mentioned exactly nowhere on the internet/in documentation, but without it NSMenuItems won't work properly, even though Apple says they are supported [item setRepresentedObject:@{ - @"item": node, - @"value": [item title], + @"node": node, // this one is needed by the "Edit filters…" item for context @"filterType": SPBoxNil([[node settings] objectForKey:@"filterType"]), }]; - // override the default action from the rule editor if given (used to open the edit content filters sheet) - id _target = [[node settings] objectForKey:@"target"]; - SEL _action = (SEL)[(NSValue *)[[node settings] objectForKey:@"action"] pointerValue]; - if(_target && _action) { - [item setTarget:_target]; - [item setAction:_action]; - } + [item setTarget:self]; + [item setAction:@selector(_menuItemInRuleEditorClicked:)]; [item autorelease]; } return item; @@ -512,6 +522,131 @@ static void _addIfNotNil(NSMutableArray *array, id toAdd); } } +- (IBAction)_menuItemInRuleEditorClicked:(id)sender +{ + if(!sender) return; // NSRuleEditor will throw on nil + + NSInteger row = [filterRuleEditor rowForDisplayValue:sender]; + + if(row == NSNotFound) return; // unknown display values + + OpNode *node = [[(NSMenuItem *)sender representedObject] objectForKey:@"node"]; + + // if the row has an explicit handler, pass on the action and do nothing + id _target = [[node settings] objectForKey:@"target"]; + SEL _action = (SEL)[(NSValue *)[[node settings] objectForKey:@"action"] pointerValue]; + if(_target && _action) { + [_target performSelector:_action withObject:sender]; + return; + } + + /* now comes the painful part, where we'd have to find out where exactly in the row this + * displayValue should appear. + * + * Luckily we know that this method will only be invoked by the displayValues of OpNode + * and currently OpNode can only appear as the second node in a row (after the column). + * + * Annoyingly we can't tell the rule editor to just replace a single element. We actually + * have to recalculate the whole row starting with the element we replaced - a task the + * rule editor would normally do for us when using NSStrings! + */ + NSMutableArray *criteria = [[filterRuleEditor criteriaForRow:row] mutableCopy]; + NSMutableArray *displayValues = [[filterRuleEditor displayValuesForRow:row] mutableCopy]; + + // find the position of the previous opnode (just for safety) + NSUInteger opIndex = NSNotFound; + NSUInteger i = 0; + for(RuleNode *obj in criteria) { + if([obj type] == RuleNodeTypeOperator) { + opIndex = i; + break; + } + i++; + } + + if(opIndex < [criteria count]) { + // yet another uglyness: if one of the displayValues is an input and currently the first responder + // we have to manually restore that for the new input we create for UX reasons. + // However an NSTextField is seldom a first responder, usually it's an invisible subview of the text field... + id firstResponder = [[filterRuleEditor window] firstResponder]; + BOOL hasFirstResponderInRow = _arrayContainsInViewHierarchy(displayValues, firstResponder); + + //remove previous opnode and everything that follows and append new opnode + NSRange stripRange = NSMakeRange(opIndex, ([criteria count] - opIndex)); + [criteria removeObjectsInRange:stripRange]; + [criteria addObject:node]; + + //remove the display value for the old op node and everything that followed + [displayValues removeObjectsInRange:stripRange]; + + //now we'll fill in everything again + [self _pretendPlayRuleEditorForCriteria:criteria displayValues:displayValues inRow:row]; + + //and update the row to its new state + [filterRuleEditor setCriteria:criteria andDisplayValues:displayValues forRowAtIndex:row]; + + if(hasFirstResponderInRow) { + // make the next possible object after the opnode the new next responder (since the previous one is gone now) + for (NSUInteger j = stripRange.location + 1; j < [displayValues count]; ++j) { + id obj = [displayValues objectAtIndex:j]; + if([obj respondsToSelector:@selector(acceptsFirstResponder)] && [obj acceptsFirstResponder]) { + [[filterRuleEditor window] makeFirstResponder:obj]; + break; + } + } + } + } + + [criteria release]; + [displayValues release]; +} + +BOOL _arrayContainsInViewHierarchy(NSArray *haystack, id needle) +{ + //first, try it the easy way + if([haystack indexOfObjectIdenticalTo:needle] != NSNotFound) return YES; + + // otherwise, if needle is a view, check if it appears as a desencdant of some other view in haystack + Class NSViewClass = [NSView class]; + if([needle isKindOfClass:NSViewClass]) { + for(id obj in haystack) { + if([obj isKindOfClass:NSViewClass] && [needle isDescendantOf:obj]) return YES; + } + } + + return NO; +} + +/** + * This method recursively fills up the passed-in criteria and displayValues arrays with objects in the way the + * NSRuleEditor would, so they can be used with the -setCriteria:andDisplayValues:forRowAtIndex: call. + * + * Assumptions made: + * - row is a valid row within the bounds of the rule editor + * - criteria contains at least one object + * - displayValues contains exactly one less object than criteria + */ +- (void)_pretendPlayRuleEditorForCriteria:(NSMutableArray *)criteria displayValues:(NSMutableArray *)displayValues inRow:(NSInteger)row +{ + id curCriterion = [criteria lastObject]; + + //first fill in the display value for the current criterion + id display = [self ruleEditor:filterRuleEditor displayValueForCriterion:curCriterion inRow:row]; + if(!display) return; // abort if unset + [displayValues addObject:display]; + + // now let's check if we have to go deeper + NSRuleEditorRowType rowType = [filterRuleEditor rowTypeForRow:row]; + if([self ruleEditor:filterRuleEditor numberOfChildrenForCriterion:curCriterion withRowType:rowType]) { + // we only care for the first child, though + id nextCriterion = [self ruleEditor:filterRuleEditor child:0 forCriterion:curCriterion withRowType:rowType]; + if(nextCriterion) { + [criteria addObject:nextCriterion]; + [self _pretendPlayRuleEditorForCriteria:criteria displayValues:displayValues inRow:row]; + } + } +} + - (IBAction)filterTable:(id)sender { if(target && action) [target performSelector:action withObject:self]; @@ -764,10 +899,21 @@ static void _addIfNotNil(NSMutableArray *array, id toAdd); - (void)_contentFiltersHaveBeenUpdated:(NSNotification *)notification { + // invalidate our OpNode caches + opNodeCacheVersion++; //tell the rule editor to reload its criteria [filterRuleEditor reloadCriteria]; } +- (void)_ensureValidOperatorCache:(ColumnNode *)col +{ + if(![col operatorCache] || [col opCacheVersion] != opNodeCacheVersion) { + NSArray *ops = [self _compareTypesForColumn:col]; + [col setOperatorCache:ops]; + [col setOpCacheVersion:opNodeCacheVersion]; + } +} + - (BOOL)isEmpty { return ([[_modelContainer model] count] == 0); @@ -1059,10 +1205,7 @@ fail: { if([title length]) { // check if we have the operator cache, otherwise build it - if(![col operatorCache]) { - NSArray *ops = [self _compareTypesForColumn:col]; - [col setOperatorCache:ops]; - } + [self _ensureValidOperatorCache:col]; // try to find it in the operator cache for(OpNode *node in [col operatorCache]) { if([[[node filter] objectForKey:@"MenuLabel"] isEqualToString:title]) return node; @@ -1234,11 +1377,13 @@ BOOL SerIsGroup(NSDictionary *dict) @synthesize name = name; @synthesize typegrouping = typegrouping; @synthesize operatorCache = operatorCache; +@synthesize opCacheVersion = opCacheVersion; - (instancetype)init { if((self = [super init])) { type = RuleNodeTypeColumn; + opCacheVersion = 0; } return self; } -- cgit v1.2.3