From 1aff56cb9b355fab3d3a95f2473cafd97e69633e Mon Sep 17 00:00:00 2001 From: Benjamin Johnson Date: Sat, 5 Nov 2022 15:45:06 +1100 Subject: [PATCH] Implement constraintsWithVisualFormat method on NSLayoutConstraint --- Headers/AppKit/NSLayoutConstraint.h | 7 +- Source/GNUmakefile | 6 +- Source/GSAutoLayoutVFLParser.h | 22 ++ Source/GSAutoLayoutVFLParser.m | 526 ++++++++++++++++++++++++++++ Source/NSLayoutConstraint.m | 13 +- 5 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 Source/GSAutoLayoutVFLParser.h create mode 100644 Source/GSAutoLayoutVFLParser.m diff --git a/Headers/AppKit/NSLayoutConstraint.h b/Headers/AppKit/NSLayoutConstraint.h index e6e751ad2..6b5b02574 100644 --- a/Headers/AppKit/NSLayoutConstraint.h +++ b/Headers/AppKit/NSLayoutConstraint.h @@ -157,7 +157,12 @@ APPKIT_EXPORT_CLASS - (NSLayoutAnchor *) secondAnchor; -- (NSLayoutPriority) priority; +#if GS_HAS_DECLARED_PROPERTIES +@property NSLayoutPriority priority; +#else +- (NSLayoutPriority) priority; +- (void) setPriority: (NSLayoutPriority)priority; +#endif @end diff --git a/Source/GNUmakefile b/Source/GNUmakefile index ff11929be..d3576e55b 100644 --- a/Source/GNUmakefile +++ b/Source/GNUmakefile @@ -349,7 +349,8 @@ GSXibLoader.m \ GSXibLoading.m \ GSXibKeyedUnarchiver.m \ GSXib5KeyedUnarchiver.m \ -GSHelpAttachment.m +GSHelpAttachment.m \ +GSAutoLayoutVFLParser.m # Turn off NSMenuItem warning that NSMenuItem conforms to , # but does not implement 's methods itself (it inherits @@ -658,7 +659,8 @@ GSWindowDecorationView.h \ GSXibElement.h \ GSXibLoading.h \ GSXibKeyedUnarchiver.h \ -GSHelpAttachment.h +GSHelpAttachment.h \ +GSAutoLayoutVFLParser.h libgnustep-gui_HEADER_FILES = ${GUI_HEADERS} diff --git a/Source/GSAutoLayoutVFLParser.h b/Source/GSAutoLayoutVFLParser.h new file mode 100644 index 000000000..f3e9c3aa6 --- /dev/null +++ b/Source/GSAutoLayoutVFLParser.h @@ -0,0 +1,22 @@ +#import + +@interface GSAutoLayoutVFLParser : NSObject +{ + NSDictionary *_views; + NSLayoutFormatOptions _options; + NSDictionary *_metrics; + NSScanner *_scanner; + NSMutableArray *_constraints; + NSMutableArray *_layoutFormatConstraints; + NSView *_view; + BOOL _createLeadingConstraintToSuperview; + BOOL _isVerticalOrientation; +} + + +-(instancetype)initWithFormat: (NSString*)format options: (NSLayoutFormatOptions)options metrics: (NSDictionary*)metrics views: (NSDictionary*)views; + +-(NSArray*)parse; + +@end + diff --git a/Source/GSAutoLayoutVFLParser.m b/Source/GSAutoLayoutVFLParser.m new file mode 100644 index 000000000..5c252ac28 --- /dev/null +++ b/Source/GSAutoLayoutVFLParser.m @@ -0,0 +1,526 @@ +#import "GSAutoLayoutVFLParser.h" +#import + +struct GSObjectOfPredicate { + NSNumber *priority; + NSView *view; + NSLayoutRelation relation; + CGFloat constant; +}; +typedef struct GSObjectOfPredicate GSObjectOfPredicate; + +NSInteger const GS_DEFAULT_VIEW_SPACING = 8; +NSInteger const GS_DEFAULT_SUPERVIEW_SPACING = 20; + +@implementation GSAutoLayoutVFLParser + +-(instancetype)initWithFormat: (NSString*)format options: (NSLayoutFormatOptions)options metrics: (NSDictionary*)metrics views: (NSDictionary*)views +{ + if (self = [super init]) { + if ([format length] == 0) { + [self failParseWithMessage:@"Cannot parse an empty string"]; + } + + _views = views; + _metrics = metrics; + _options = options; + + _scanner = [NSScanner scannerWithString:format]; + _constraints = [NSMutableArray array]; + _layoutFormatConstraints = [NSMutableArray array]; + } + + return self; +} + +-(NSArray*)parse +{ + [self parseOrientation]; + NSNumber *spacingConstant = [self parseLeadingSuperViewConnection]; + NSView *previousView = nil; + + while (![_scanner isAtEnd]) { + NSArray *viewConstraints = [self parseView]; + if (_createLeadingConstraintToSuperview) { + [self addLeadingSuperviewConstraint: spacingConstant]; + _createLeadingConstraintToSuperview = NO; + } + + if (previousView != nil) { + [self addViewSpacingConstraint:spacingConstant previousView:previousView]; + [self addFormattingConstraints: previousView]; + } + [_constraints addObjectsFromArray:viewConstraints]; + + spacingConstant = [self parseConnection]; + if ([_scanner scanString:@"|" intoString:nil]) { + [self addTrailingToSuperviewConstraint: spacingConstant]; + } + previousView = _view; + } + + [_constraints addObjectsFromArray:_layoutFormatConstraints]; + + return _constraints; +} + +-(void)addFormattingConstraints: (NSView*)lastView +{ + BOOL hasFormatOptions = (_options & NSLayoutFormatAlignmentMask) > 0; + if (!hasFormatOptions) { + return; + } + [self assertHasValidFormatLayoutOptions]; + + NSArray *attributes = [self layoutAttributesForLayoutFormatOptions:_options]; + for (NSNumber *layoutAttribute in attributes) { + NSLayoutConstraint *formatConstraint = [NSLayoutConstraint constraintWithItem:lastView attribute:[layoutAttribute integerValue] relatedBy:NSLayoutRelationEqual toItem:_view attribute:[layoutAttribute integerValue] multiplier:1.0 constant:0]; + [_layoutFormatConstraints addObject:formatConstraint]; + } +} + +-(void)assertHasValidFormatLayoutOptions +{ + if (_isVerticalOrientation && [self isVerticalEdgeFormatLayoutOption: _options]) { + [self failParseWithMessage:@"A vertical alignment format option cannot be used with a vertical layout"]; + } else if (!_isVerticalOrientation && ![self isVerticalEdgeFormatLayoutOption:_options]) { + [self failParseWithMessage:@"A horizontal alignment format option cannot be used with a horizontal layout"]; + } +} + +-(void)parseOrientation +{ + if ([_scanner scanString:@"V:" intoString:nil]) { + _isVerticalOrientation = true; + } else { + [_scanner scanString:@"H:" intoString:nil]; + } +} + +-(NSArray*)parseView +{ + [self parseViewOpen]; + + _view = [self parseViewName]; + NSArray *viewConstraints = [self parsePredicateList]; + [self parseViewClose]; + + return viewConstraints; +} + +-(NSView*)parseViewName +{ + NSString *viewName = nil; + NSCharacterSet *viewTerminators = [NSCharacterSet characterSetWithCharactersInString:@"]("]; + [_scanner scanUpToCharactersFromSet:viewTerminators intoString:&viewName]; + + if (viewName == nil) { + [self failParseWithMessage:@"Failed to parse view name"]; + } + + if (![self isValidIdentifer:viewName]) { + [self failParseWithMessage:@"Invalid view name. A view name must be a valid C identifier and may only contain letters, numbers and underscores"]; + } + + return [self resolveViewWithIdentifier:viewName]; +} + +-(BOOL)isVerticalEdgeFormatLayoutOption: (NSLayoutFormatOptions)options +{ + if (options & NSLayoutFormatAlignAllTop) { + return YES; + } + if (options & NSLayoutFormatAlignAllBaseline) { + return YES; + } + if (options & NSLayoutFormatAlignAllFirstBaseline) { + return YES; + } + if (options & NSLayoutFormatAlignAllBottom) { + return YES; + } + if (options & NSLayoutFormatAlignAllCenterY) { + return YES; + } + + return NO; +} + +-(void)addViewSpacingConstraint: (NSNumber*)spacing previousView: (NSView*)previousView +{ + CGFloat viewSpacingConstant = spacing ? [spacing doubleValue] : GS_DEFAULT_VIEW_SPACING; + NSLayoutAttribute firstAttribute; + NSLayoutAttribute secondAttribute; + NSView *firstItem; + NSView *secondItem; + + NSLayoutFormatOptions directionOptions = _options & NSLayoutFormatDirectionMask; + if (_isVerticalOrientation) { + firstAttribute = NSLayoutAttributeTop; + secondAttribute = NSLayoutAttributeBottom; + firstItem = _view; + secondItem = previousView; + } else if (directionOptions & NSLayoutFormatDirectionRightToLeft) { + firstAttribute = NSLayoutAttributeLeft; + secondAttribute = NSLayoutAttributeRight; + firstItem = previousView; + secondItem = _view; + } else if (directionOptions & NSLayoutFormatDirectionLeftToRight) { + firstAttribute = NSLayoutAttributeLeft; + secondAttribute = NSLayoutAttributeRight; + firstItem = _view; + secondItem = previousView; + } else { + firstAttribute = NSLayoutAttributeLeading; + secondAttribute = NSLayoutAttributeTrailing; + firstItem = _view; + secondItem = previousView; + } + + NSLayoutConstraint *viewSeparatorConstraint = [NSLayoutConstraint constraintWithItem:firstItem attribute:firstAttribute relatedBy:NSLayoutRelationEqual toItem:secondItem attribute:secondAttribute multiplier:1.0 constant:viewSpacingConstant]; + + [_constraints addObject:viewSeparatorConstraint]; +} + +-(void)addLeadingSuperviewConstraint: (NSNumber*)spacing +{ + NSLayoutAttribute firstAttribute; + NSView *firstItem; + NSView *secondItem; + + NSLayoutFormatOptions directionOptions = _options & NSLayoutFormatDirectionMask; + if (_isVerticalOrientation) { + firstAttribute = NSLayoutAttributeTop; + firstItem = _view; + secondItem = _view.superview; + } else if (directionOptions & NSLayoutFormatDirectionRightToLeft) { + firstAttribute = NSLayoutAttributeRight; + firstItem = _view.superview; + secondItem = _view; + } else if (directionOptions & NSLayoutFormatDirectionLeftToRight) { + firstAttribute = NSLayoutAttributeLeft; + firstItem = _view; + secondItem = _view.superview; + } else { + firstAttribute = _isVerticalOrientation ? NSLayoutAttributeTop : NSLayoutAttributeLeading; + firstItem = _view; + secondItem = _view.superview; + } + + CGFloat viewSpacingConstant = spacing ? [spacing doubleValue] : GS_DEFAULT_SUPERVIEW_SPACING; + + NSLayoutConstraint *leadingConstraintToSuperview = [NSLayoutConstraint constraintWithItem:firstItem attribute:firstAttribute relatedBy:NSLayoutRelationEqual toItem:secondItem attribute:firstAttribute multiplier:1.0 constant:viewSpacingConstant]; + [_constraints addObject:leadingConstraintToSuperview]; +} + +-(void)addTrailingToSuperviewConstraint: (NSNumber*)spacing +{ + CGFloat viewSpacingConstant = spacing ? [spacing doubleValue] : GS_DEFAULT_SUPERVIEW_SPACING; + + NSLayoutFormatOptions directionOptions = _options & NSLayoutFormatDirectionMask; + NSLayoutAttribute attribute; + NSView *firstItem; + NSView *secondItem; + + if (_isVerticalOrientation) { + attribute = NSLayoutAttributeBottom; + firstItem = _view.superview; + secondItem = _view; + } else if (directionOptions & NSLayoutFormatDirectionRightToLeft) { + attribute = NSLayoutAttributeLeft; + firstItem = _view; + secondItem = _view.superview; + } else if (directionOptions & NSLayoutFormatDirectionLeftToRight) { + attribute = NSLayoutAttributeRight; + firstItem = _view.superview; + secondItem = _view; + } else { + attribute = NSLayoutAttributeTrailing; + firstItem = _view.superview; + secondItem = _view; + } + + NSLayoutConstraint *trailingConstraintToSuperview = [NSLayoutConstraint constraintWithItem: firstItem attribute:attribute relatedBy:NSLayoutRelationEqual toItem: secondItem attribute:attribute multiplier:1.0 constant:viewSpacingConstant]; + [_constraints addObject:trailingConstraintToSuperview]; +} + +-(NSNumber*)parseLeadingSuperViewConnection +{ + BOOL foundSuperview = [_scanner scanString:@"|" intoString:nil]; + if (!foundSuperview) { + return nil; + } + _createLeadingConstraintToSuperview = YES; + return [self parseConnection]; +} + +-(NSNumber*)parseConnection +{ + BOOL foundConnection = [_scanner scanString:@"-" intoString:nil]; + if (!foundConnection) { + return [NSNumber numberWithDouble:0]; + } + + NSNumber *simplePredicateValue = [self parseSimplePredicate]; + BOOL endConnectionFound = [_scanner scanString:@"-" intoString:nil]; + + if (simplePredicateValue != nil && !endConnectionFound) { + [self failParseWithMessage:@"A connection must end with a '-'"]; + } else if (simplePredicateValue == nil && endConnectionFound) { + [self failParseWithMessage:@"Found invalid connection"]; + } + + return simplePredicateValue; +} + +-(NSNumber*)parseSimplePredicate +{ + float constant; + BOOL scanConstantResult = [_scanner scanFloat:&constant]; + if (scanConstantResult) { + return [NSNumber numberWithDouble:constant]; + } else { + NSString *metricName = nil; + NSCharacterSet *simplePredicateTerminatorsCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@"-[|"]; + BOOL didParseMetricName = [_scanner scanUpToCharactersFromSet:simplePredicateTerminatorsCharacterSet intoString:&metricName]; + if (!didParseMetricName) { + return nil; + } + if (![self isValidIdentifer:metricName]) { + [self failParseWithMessage:@"Invalid metric identifier. Metric identifiers must be a valid C identifier and may only contain letters, numbers and underscores"]; + } + + NSNumber *metric = [self resolveMetricWithIdentifier:metricName]; + return metric; + } +} + +-(NSArray*)parsePredicateList +{ + BOOL startsWithPredicateList = [_scanner scanString:@"(" intoString:nil]; + if (!startsWithPredicateList) { + return [NSArray array]; + } + + NSMutableArray *viewPredicateConstraints = [NSMutableArray array]; + BOOL shouldParsePredicate = YES; + while (shouldParsePredicate) { + GSObjectOfPredicate *predicate = [self parseObjectOfPredicate]; + [viewPredicateConstraints addObject:[self createConstraintFromParsedPredicate:predicate]]; + [self freeObjectOfPredicate:predicate]; + + shouldParsePredicate = [_scanner scanString:@"," intoString:nil]; + } + + if (![_scanner scanString:@")" intoString:nil]) { + [self failParseWithMessage:@"A predicate on a view must end with ')'"]; + } + + return viewPredicateConstraints; +} + +-(NSLayoutConstraint*)createConstraintFromParsedPredicate: (GSObjectOfPredicate*)predicate +{ + NSLayoutConstraint *constraint = nil; + NSLayoutAttribute attribute = _isVerticalOrientation ? NSLayoutAttributeHeight : NSLayoutAttributeWidth; + if (predicate->view != nil) { + constraint = [NSLayoutConstraint constraintWithItem:_view attribute:attribute relatedBy:predicate->relation toItem:predicate->view attribute:attribute multiplier:1.0 constant:predicate->constant]; + } else { + constraint = [NSLayoutConstraint constraintWithItem:_view attribute:attribute relatedBy:predicate->relation toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:predicate->constant]; + } + + if (predicate->priority) { + constraint.priority = [predicate->priority doubleValue]; + } + + return constraint; +} + +-(GSObjectOfPredicate*)parseObjectOfPredicate +{ + NSLayoutRelation relation = [self parseRelation]; + + CGFloat parsedConstant; + NSView *predicatedView = nil; + BOOL scanConstantResult = [_scanner scanDouble:&parsedConstant]; + if (!scanConstantResult) { + NSString *identiferName = [self parseIdentifier]; + if (![self isValidIdentifer:identiferName]) { + [self failParseWithMessage:@"Invalid metric or view identifier. Metric/View identifiers must be a valid C identifier and may only contain letters, numbers and underscores"]; + } + + NSNumber *metric = [_metrics objectForKey:identiferName]; + if (metric != nil) { + parsedConstant = [metric doubleValue]; + } else if ([_views objectForKey:identiferName]) { + parsedConstant = 0; + predicatedView = [_views objectForKey:identiferName]; + } else { + NSString *message = [NSString stringWithFormat:@"Failed to find constant or metric for identifier '%@'", identiferName]; + [self failParseWithMessage:message]; + } + } + + NSNumber *priorityValue = [self parsePriority]; + + GSObjectOfPredicate *predicate = calloc(1, sizeof(GSObjectOfPredicate)); + predicate->priority = priorityValue; + predicate->relation = relation; + predicate->constant = parsedConstant; + predicate->view = predicatedView; + + return predicate; +} + +-(NSLayoutRelation)parseRelation +{ + if ([_scanner scanString:@"==" intoString:nil]) { + return NSLayoutRelationEqual; + } else if ([_scanner scanString:@">=" intoString:nil]) { + return NSLayoutRelationGreaterThanOrEqual; + } else if ([_scanner scanString:@"<=" intoString:nil]) { + return NSLayoutRelationLessThanOrEqual; + } else { + return NSLayoutRelationEqual; + } +} + +-(NSNumber*)parsePriority +{ + NSCharacterSet *priorityMarkerCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@"@"]; + BOOL foundPriorityMarker = [_scanner scanCharactersFromSet:priorityMarkerCharacterSet intoString:nil]; + if (!foundPriorityMarker) { + return nil; + } + + return [self parseConstant]; +} + +-(NSNumber*)resolveMetricWithIdentifier: (NSString*)identifier +{ + NSNumber *metric = [_metrics objectForKey:identifier]; + if (metric == nil) { + [self failParseWithMessage:@"Found metric not inside metric dictionary"]; + } + return metric; +} + +-(NSView*)resolveViewWithIdentifier: (NSString*)identifier +{ + NSView *view = [_views objectForKey:identifier]; + if (view == nil) { + [self failParseWithMessage:@"Found view not inside view dictionary"]; + } + return view; +} + +-(NSNumber*)parseConstant +{ + CGFloat constant; + BOOL scanConstantResult = [_scanner scanDouble:&constant]; + if (scanConstantResult) { + return [NSNumber numberWithFloat:constant]; + } + + NSString *metricName = [self parseIdentifier]; + if (![self isValidIdentifer:metricName]) { + [self failParseWithMessage:@"Invalid metric identifier. Metric identifiers must be a valid C identifier and may only contain letters, numbers and underscores"]; + } + + return [self resolveMetricWithIdentifier:metricName]; +} + +-(NSString*)parseIdentifier +{ + NSString *identifierName = nil; + NSCharacterSet *identifierTerminators = [NSCharacterSet characterSetWithCharactersInString:@"),"]; + BOOL scannedIdentifier = [_scanner scanUpToCharactersFromSet:identifierTerminators intoString:&identifierName]; + if (!scannedIdentifier) { + [self failParseWithMessage:@"Failed to find constant or metric"]; + } + + return identifierName; +} + +-(void)parseViewOpen +{ + NSCharacterSet *openViewIdentifier = [NSCharacterSet characterSetWithCharactersInString:@"["]; + BOOL scannedOpenBracket = [_scanner scanCharactersFromSet:openViewIdentifier intoString:nil]; + if (!scannedOpenBracket) { + [[NSException exceptionWithName:NSInternalInconsistencyException reason:@"A view must start with a '['" userInfo:nil] raise]; + } +} + +-(void)parseViewClose +{ + NSCharacterSet *closeViewIdentifier = [NSCharacterSet characterSetWithCharactersInString:@"]"]; + BOOL scannedCloseBracket = [_scanner scanCharactersFromSet:closeViewIdentifier intoString:nil]; + if (!scannedCloseBracket) { + [[NSException exceptionWithName:NSInternalInconsistencyException reason:@"A view must end with a ']'" userInfo:nil] raise]; + } +} + +-(BOOL)isValidIdentifer: (NSString*)identifer +{ + NSRegularExpression *cIdentifierRegex = [NSRegularExpression regularExpressionWithPattern:@"^[a-zA-Z_][a-zA-Z0-9_]*$" options:0 error:nil]; + NSArray *matches = [cIdentifierRegex matchesInString:identifer options:0 range:NSMakeRange(0, identifer.length)]; + + return [matches count] > 0; +} + +-(NSArray*)layoutAttributesForLayoutFormatOptions: (NSLayoutFormatOptions)options { + NSMutableArray *attributes = [NSMutableArray array]; + + if (options & NSLayoutFormatAlignAllLeft) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeLeft]]; + } + if (options & NSLayoutFormatAlignAllRight) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeRight]]; + } + if (options & NSLayoutFormatAlignAllTop) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeTop]]; + } + if (options & NSLayoutFormatAlignAllBottom) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeBottom]]; + } + if (options & NSLayoutFormatAlignAllLeading) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeLeading]]; + } + if (options & NSLayoutFormatAlignAllTrailing) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeTrailing]]; + } + if (options & NSLayoutFormatAlignAllCenterX) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeCenterX]]; + } + if (options & NSLayoutFormatAlignAllCenterY) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeCenterY]]; + } + if (options & NSLayoutFormatAlignAllBaseline) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeBaseline]]; + } + if (options & NSLayoutFormatAlignAllFirstBaseline) { + [attributes addObject:[NSNumber numberWithInteger:NSLayoutAttributeFirstBaseline]]; + } + + if ([attributes count] == 0) { + [self failParseWithMessage:@"Unrecognized layout formatting option"]; + } + + return attributes; +} + +-(void)failParseWithMessage: (NSString*)parseErrorMessage +{ + NSException *parseException = [NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"Unable to parse constraint format: %@", parseErrorMessage] userInfo:nil]; + [parseException raise]; +} + + +-(void)freeObjectOfPredicate: (GSObjectOfPredicate*)predicate +{ + predicate->view = nil; + predicate->priority = nil; + free(predicate); +} + +@end diff --git a/Source/NSLayoutConstraint.m b/Source/NSLayoutConstraint.m index 50cafefb5..adc6fbc7d 100644 --- a/Source/NSLayoutConstraint.m +++ b/Source/NSLayoutConstraint.m @@ -32,6 +32,7 @@ #import "AppKit/NSLayoutConstraint.h" #import "AppKit/NSWindow.h" #import "AppKit/NSApplication.h" +#import "GSAutoLayoutVFLParser.h" static NSMutableArray *activeConstraints = nil; // static NSNotificationCenter *nc = nil; @@ -236,8 +237,16 @@ static NSMutableArray *activeConstraints = nil; metrics: (NSDictionary *)metrics views: (NSDictionary *)views { - NSMutableArray *array = [NSMutableArray arrayWithCapacity: 10]; - return array; + GSAutoLayoutVFLParser *parser = [[GSAutoLayoutVFLParser alloc] + initWithFormat: fmt + options: opt + metrics: metrics + views: views]; + NSArray *constraints = [parser parse]; + + [parser release]; + + return constraints; } - (instancetype) initWithItem: (id)firstItem