diff --git a/Source/ASTextNode+Beta.h b/Source/ASTextNode+Beta.h index 09259bd7f..ad897c5f0 100644 --- a/Source/ASTextNode+Beta.h +++ b/Source/ASTextNode+Beta.h @@ -35,6 +35,11 @@ NS_ASSUME_NONNULL_BEGIN */ @property (readonly) BOOL usingExperiment; +/** + * Returns a Boolean indicating if the text node will truncate for the given constrained size + */ +- (BOOL)shouldTruncateForConstrainedSize:(ASSizeRange)constrainedSize; + @end NS_ASSUME_NONNULL_END diff --git a/Source/ASTextNode.mm b/Source/ASTextNode.mm index c29d03312..accbea3ad 100644 --- a/Source/ASTextNode.mm +++ b/Source/ASTextNode.mm @@ -1218,6 +1218,11 @@ - (BOOL)isTruncated return ASLockedSelf([[self _locked_renderer] isTruncated]); } +- (BOOL)shouldTruncateForConstrainedSize:(ASSizeRange)constrainedSize +{ + return ASLockedSelf([[self _locked_rendererWithBounds:{.size = constrainedSize.max}] isTruncated]); +} + - (void)setPointSizeScaleFactors:(NSArray *)pointSizeScaleFactors { if (ASLockedSelfCompareAssignCopy(_pointSizeScaleFactors, pointSizeScaleFactors)) { diff --git a/Source/ASTextNode2.mm b/Source/ASTextNode2.mm index cca660f13..6d894be82 100644 --- a/Source/ASTextNode2.mm +++ b/Source/ASTextNode2.mm @@ -55,6 +55,95 @@ @implementation ASTextCacheValue });\ } +/** + * If it can't find a compatible layout, this method creates one. + * + * NOTE: Be careful to copy `text` if needed. + */ +static NS_RETURNS_RETAINED ASTextLayout *ASTextNodeCompatibleLayoutWithContainerAndText(ASTextContainer *container, NSAttributedString *text) { + // Allocate layoutCacheLock on the heap to prevent destruction at app exit (https://github.com/TextureGroup/Texture/issues/136) + static ASDN::StaticMutex& layoutCacheLock = *new ASDN::StaticMutex; + static NSCache *textLayoutCache; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + textLayoutCache = [[NSCache alloc] init]; + }); + + layoutCacheLock.lock(); + + ASTextCacheValue *cacheValue = [textLayoutCache objectForKey:text]; + if (cacheValue == nil) { + cacheValue = [[ASTextCacheValue alloc] init]; + [textLayoutCache setObject:cacheValue forKey:[text copy]]; + } + + // Lock the cache item for the rest of the method. Only after acquiring can we release the NSCache. + ASDN::MutexLocker lock(cacheValue->_m); + layoutCacheLock.unlock(); + + CGRect containerBounds = (CGRect){ .size = container.size }; + { + for (let &t : cacheValue->_layouts) { + CGSize constrainedSize = std::get<0>(t); + ASTextLayout *layout = std::get<1>(t); + + CGSize layoutSize = layout.textBoundingSize; + // 1. CoreText can return frames that are narrower than the constrained width, for obvious reasons. + // 2. CoreText can return frames that are slightly wider than the constrained width, for some reason. + // We have to trust that somehow it's OK to try and draw within our size constraint, despite the return value. + // 3. Thus, those two values (constrained width & returned width) form a range, where + // intermediate values in that range will be snapped. Thus, we can use a given layout as long as our + // width is in that range, between the min and max of those two values. + CGRect minRect = CGRectMake(0, 0, MIN(layoutSize.width, constrainedSize.width), MIN(layoutSize.height, constrainedSize.height)); + if (!CGRectContainsRect(containerBounds, minRect)) { + continue; + } + CGRect maxRect = CGRectMake(0, 0, MAX(layoutSize.width, constrainedSize.width), MAX(layoutSize.height, constrainedSize.height)); + if (!CGRectContainsRect(maxRect, containerBounds)) { + continue; + } + if (!CGSizeEqualToSize(container.size, constrainedSize)) { + continue; + } + + // Now check container params. + ASTextContainer *otherContainer = layout.container; + if (!UIEdgeInsetsEqualToEdgeInsets(container.insets, otherContainer.insets)) { + continue; + } + if (!ASObjectIsEqual(container.exclusionPaths, otherContainer.exclusionPaths)) { + continue; + } + if (container.maximumNumberOfRows != otherContainer.maximumNumberOfRows) { + continue; + } + if (container.truncationType != otherContainer.truncationType) { + continue; + } + if (!ASObjectIsEqual(container.truncationToken, otherContainer.truncationToken)) { + continue; + } + // TODO: When we get a cache hit, move this entry to the front (LRU). + return layout; + } + } + + // Cache Miss. Compute the text layout. + ASTextLayout *layout = [ASTextLayout layoutWithContainer:container text:text]; + + // Store the result in the cache. + { + // This is a critical section. However we also must hold the lock until this point, in case + // another thread requests this cache item while a layout is being calculated, so they don't race. + cacheValue->_layouts.push_front(std::make_tuple(container.size, layout)); + if (cacheValue->_layouts.size() > 3) { + cacheValue->_layouts.pop_back(); + } + } + + return layout; +} + static const CGFloat ASTextNodeHighlightLightOpacity = 0.11; static const CGFloat ASTextNodeHighlightDarkOpacity = 0.22; static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncationAttribute"; @@ -256,7 +345,7 @@ - (CGSize)calculateSizeThatFits:(CGSize)constrainedSize NSMutableAttributedString *mutableText = [_attributedText mutableCopy]; [self prepareAttributedString:mutableText isForIntrinsicSize:isCalculatingIntrinsicSize]; - ASTextLayout *layout = [ASTextNode2 compatibleLayoutWithContainer:_textContainer text:mutableText]; + ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, mutableText); return layout.textBoundingSize; } @@ -428,104 +517,12 @@ - (NSObject *)drawParametersForAsyncLayer:(_ASDisplayLayer *)layer }; } -/** - * If it can't find a compatible layout, this method creates one. - * - * NOTE: Be careful to copy `text` if needed. - */ -+ (ASTextLayout *)compatibleLayoutWithContainer:(ASTextContainer *)container - text:(NSAttributedString *)text NS_RETURNS_RETAINED - -{ - // Allocate layoutCacheLock on the heap to prevent destruction at app exit (https://github.com/TextureGroup/Texture/issues/136) - static ASDN::StaticMutex& layoutCacheLock = *new ASDN::StaticMutex; - static NSCache *textLayoutCache; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - textLayoutCache = [[NSCache alloc] init]; - }); - - layoutCacheLock.lock(); - - ASTextCacheValue *cacheValue = [textLayoutCache objectForKey:text]; - if (cacheValue == nil) { - cacheValue = [[ASTextCacheValue alloc] init]; - [textLayoutCache setObject:cacheValue forKey:[text copy]]; - } - - // Lock the cache item for the rest of the method. Only after acquiring can we release the NSCache. - ASDN::MutexLocker lock(cacheValue->_m); - layoutCacheLock.unlock(); - - CGRect containerBounds = (CGRect){ .size = container.size }; - { - for (let &t : cacheValue->_layouts) { - CGSize constrainedSize = std::get<0>(t); - ASTextLayout *layout = std::get<1>(t); - - CGSize layoutSize = layout.textBoundingSize; - // 1. CoreText can return frames that are narrower than the constrained width, for obvious reasons. - // 2. CoreText can return frames that are slightly wider than the constrained width, for some reason. - // We have to trust that somehow it's OK to try and draw within our size constraint, despite the return value. - // 3. Thus, those two values (constrained width & returned width) form a range, where - // intermediate values in that range will be snapped. Thus, we can use a given layout as long as our - // width is in that range, between the min and max of those two values. - CGRect minRect = CGRectMake(0, 0, MIN(layoutSize.width, constrainedSize.width), MIN(layoutSize.height, constrainedSize.height)); - if (!CGRectContainsRect(containerBounds, minRect)) { - continue; - } - CGRect maxRect = CGRectMake(0, 0, MAX(layoutSize.width, constrainedSize.width), MAX(layoutSize.height, constrainedSize.height)); - if (!CGRectContainsRect(maxRect, containerBounds)) { - continue; - } - if (!CGSizeEqualToSize(container.size, constrainedSize)) { - continue; - } - - // Now check container params. - ASTextContainer *otherContainer = layout.container; - if (!UIEdgeInsetsEqualToEdgeInsets(container.insets, otherContainer.insets)) { - continue; - } - if (!ASObjectIsEqual(container.exclusionPaths, otherContainer.exclusionPaths)) { - continue; - } - if (container.maximumNumberOfRows != otherContainer.maximumNumberOfRows) { - continue; - } - if (container.truncationType != otherContainer.truncationType) { - continue; - } - if (!ASObjectIsEqual(container.truncationToken, otherContainer.truncationToken)) { - continue; - } - // TODO: When we get a cache hit, move this entry to the front (LRU). - return layout; - } - } - - // Cache Miss. Compute the text layout. - ASTextLayout *layout = [ASTextLayout layoutWithContainer:container text:text]; - - // Store the result in the cache. - { - // This is a critical section. However we also must hold the lock until this point, in case - // another thread requests this cache item while a layout is being calculated, so they don't race. - cacheValue->_layouts.push_front(std::make_tuple(container.size, layout)); - if (cacheValue->_layouts.size() > 3) { - cacheValue->_layouts.pop_back(); - } - } - - return layout; -} - + (void)drawRect:(CGRect)bounds withParameters:(NSDictionary *)layoutDict isCancelled:(NS_NOESCAPE asdisplaynode_iscancelled_block_t)isCancelledBlock isRasterizing:(BOOL)isRasterizing { ASTextContainer *container = layoutDict[@"container"]; NSAttributedString *text = layoutDict[@"text"]; UIColor *bgColor = layoutDict[@"bgColor"]; - ASTextLayout *layout = [self compatibleLayoutWithContainer:container text:text]; + ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(container, text); if (isCancelledBlock()) { return; @@ -574,7 +571,7 @@ - (id)_linkAttributeValueAtPoint:(CGPoint)point // See discussion in https://github.com/TextureGroup/Texture/pull/396 ASTextContainer *containerCopy = [_textContainer copy]; containerCopy.size = self.calculatedSize; - ASTextLayout *layout = [ASTextNode2 compatibleLayoutWithContainer:containerCopy text:_attributedText]; + ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(containerCopy, _attributedText); NSRange visibleRange = layout.visibleRange; NSRange clampedRange = NSIntersectionRange(visibleRange, NSMakeRange(0, _attributedText.length)); @@ -830,7 +827,7 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event // See discussion in https://github.com/TextureGroup/Texture/pull/396 ASTextContainer *containerCopy = [_textContainer copy]; containerCopy.size = self.calculatedSize; - ASTextLayout *layout = [ASTextNode2 compatibleLayoutWithContainer:containerCopy text:_attributedText]; + ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(containerCopy, _attributedText); visibleRange = layout.visibleRange; } NSRange truncationMessageRange = [self _additionalTruncationMessageRangeWithVisibleRange:visibleRange]; @@ -1086,8 +1083,19 @@ - (void)setTruncationMode:(NSLineBreakMode)truncationMode - (BOOL)isTruncated { - AS_TEXT_ALERT_UNIMPLEMENTED_FEATURE(); - return NO; + return ASLockedSelf([self locked_textLayoutForSize:[self _locked_threadSafeBounds].size].truncatedLine == nil); +} + +- (BOOL)shouldTruncateForConstrainedSize:(ASSizeRange)constrainedSize +{ + return ASLockedSelf([self locked_textLayoutForSize:constrainedSize.max].truncatedLine == nil); +} + +- (ASTextLayout *)locked_textLayoutForSize:(CGSize)size +{ + ASTextContainer *container = [_textContainer copy]; + container.size = size; + return ASTextNodeCompatibleLayoutWithContainerAndText(container, _attributedText); } - (NSUInteger)maximumNumberOfLines