Files
ghostgram/submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextInputDecUpstream/TextView/IDRichTextLayoutController.m
T
ichmagmaus111andClaude Opus 5.5 534cabe35b bug fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:55:08 +01:00

1701 lines
80 KiB
Objective-C

#import <TargetConditionals.h>
#if TARGET_OS_IOS
#import "TGPrivateRuntimeNames.h"
#import "TGPrivateMethodAliases.h"
#import "IDRichTextLayoutController.h"
#import "IDRichTextLayoutControllerInternal.h"
#import "IDBlockDocument.h"
#import "IDBlockLayout.h"
#import "IDBlockStackCanvasView.h"
#import "IDBlockStackCanvasViewInternal.h"
#import "IDCanonicalTextProjection.h"
#import "IDCanonicalTextStorageAdapter.h"
#import "IDDocumentLayoutCoordinator.h"
#import "IDPrivateUIKit.h"
#import "IDPrivateUIKit+RichTextLayout.h"
#import "IDRichTextAnnotationStore.h"
#import "IDRichTextAnchor.h"
#import "IDRichTextAnchorInternal.h"
#import "IDRichTextPosition.h"
#import "IDRichTextRange.h"
#import "IDRichTextRangeInternal.h"
#import "IDRichTextSelectionRect.h"
#import "IDTextLayoutCanvasView.h"
#include <math.h>
@interface IDRichTextAnnotationStore (IDTemporaryAttributeReplay)
- (void)enumerateAttributesInCanonicalRange:(NSRange)range
usingBlock:(void (NS_NOESCAPE ^)(
id<NSCopying> attribute,
id value,
NSRange canonicalRange))block;
@end
@interface IDBlockLayout (IDSelectionCanvasHost)
- (nullable id)id_canvasHost;
@end
@interface IDRichTextInputControllerConnection : NSObject
@property(nonatomic, strong, nullable) id id_textInputController;
@end
@implementation IDRichTextInputControllerConnection
// UIKit reads the controller through this object by name (`-[_UITextLayoutControllerBase
// textInputController]`, reached from `_UIKeyboardStateManager`): the property is compiled under a
// neutral name and its accessors are added under the private ones (`TGPrivateMethodAliases.h`).
+ (void)initialize {
if (self != IDRichTextInputControllerConnection.class) {
return;
}
const TGPrivateMethodAlias aliases[] = {
{ TGPN_textInputController, @selector(id_textInputController) },
{ TGPN_setTextInputController_, @selector(setId_textInputController:) },
};
TGPrivateInstallMethodAliases(self, aliases, sizeof(aliases) / sizeof(aliases[0]));
}
@end
@interface IDRichTextLayoutController ()
@property(nonatomic, strong, readwrite) IDBlockDocument *document;
@property(nonatomic, strong, readwrite) IDCanonicalTextStorageAdapter *textStorageAdapter;
@property(nonatomic, strong, readwrite) IDBlockStackCanvasView *id_blockStack;
@property(nonatomic, strong) IDRichTextAnnotationStore *annotationStore;
@property(nonatomic) NSUInteger id_boundingRectForCharacterRangeCallCountState;
- (NSRange)id_projectedCanonicalRangeForTextRange:(UITextRange *)range
clipped:(BOOL)clipped
stage:(NSString *)stage;
@end
@implementation IDRichTextLayoutController
+ (void)initialize {
if (self == IDRichTextLayoutController.class) {
IDRichTextLayoutControllerInstallPrivateCallbacks(self);
}
}
static void IDRichRaise(NSString *stage, NSString *detail) {
NSString *reason = [NSString stringWithFormat:@"IDRichTextLayoutController %@ failed: %@",
stage,
detail];
@throw [NSException exceptionWithName:NSInvalidArgumentException
reason:reason
userInfo:nil];
}
static BOOL IDRichRangeIsValid(NSRange range, NSUInteger length) {
return range.location != NSNotFound &&
range.location <= length &&
range.length <= length - range.location;
}
static BOOL IDRichRectIsFinite(CGRect rect) {
return isfinite(rect.origin.x) && isfinite(rect.origin.y) &&
isfinite(rect.size.width) && isfinite(rect.size.height);
}
static BOOL IDRichNeutralAffinity(UITextStorageDirection affinity,
IDBlockTextAffinity *result) {
if (affinity == UITextStorageDirectionBackward) {
if (result != NULL) *result = IDBlockTextAffinityUpstream;
return YES;
}
if (affinity == UITextStorageDirectionForward) {
if (result != NULL) *result = IDBlockTextAffinityDownstream;
return YES;
}
return NO;
}
static BOOL IDRichNeutralDirection(UITextLayoutDirection direction,
IDBlockLayoutDirection *result) {
switch (direction) {
case UITextLayoutDirectionLeft:
if (result != NULL) *result = IDBlockLayoutDirectionLeft;
return YES;
case UITextLayoutDirectionRight:
if (result != NULL) *result = IDBlockLayoutDirectionRight;
return YES;
case UITextLayoutDirectionUp:
if (result != NULL) *result = IDBlockLayoutDirectionUp;
return YES;
case UITextLayoutDirectionDown:
if (result != NULL) *result = IDBlockLayoutDirectionDown;
return YES;
}
return NO;
}
static BOOL IDRichUIKitAffinity(IDBlockTextAffinity affinity,
UITextStorageDirection *result) {
if (affinity == IDBlockTextAffinityUpstream) {
if (result != NULL) *result = UITextStorageDirectionBackward;
return YES;
}
if (affinity == IDBlockTextAffinityDownstream) {
if (result != NULL) *result = UITextStorageDirectionForward;
return YES;
}
return NO;
}
static BOOL IDRichEffectiveDirection(UITextLayoutDirection direction,
NSInteger signedOffset,
UITextLayoutDirection *result) {
IDBlockLayoutDirection ignored;
if (!IDRichNeutralDirection(direction, &ignored)) return NO;
UITextLayoutDirection effective = direction;
if (signedOffset < 0) {
if (direction == UITextLayoutDirectionLeft) {
effective = UITextLayoutDirectionRight;
} else if (direction == UITextLayoutDirectionRight) {
effective = UITextLayoutDirectionLeft;
} else if (direction == UITextLayoutDirectionUp) {
effective = UITextLayoutDirectionDown;
} else {
effective = UITextLayoutDirectionUp;
}
}
if (result != NULL) *result = effective;
return YES;
}
static BOOL IDRichNavigationResultIsValid(
IDDocumentCaretNavigationResult *neutral,
IDBlockDocument *document,
BOOL vertical,
CGFloat requiredVerticalAnchor) {
if (neutral == nil || !neutral.exact || neutral.position == nil ||
neutral.position.documentRevision != document.documentRevision ||
neutral.position.canonicalOffset > document.canonicalLength ||
!isfinite(neutral.anchorPositionOffset) ||
(vertical &&
fabs(neutral.anchorPositionOffset - requiredVerticalAnchor) > 0.001)) {
return NO;
}
UITextStorageDirection ignored;
if (!IDRichUIKitAffinity(neutral.position.localPosition.affinity, &ignored)) {
return NO;
}
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:document];
IDCanonicalLocation *location = [projection
locationForCanonicalOffset:neutral.position.canonicalOffset];
return location != nil &&
[location.blockIdentifier isEqual:neutral.position.blockIdentifier] &&
location.localOffset == neutral.position.localPosition.utf16Offset;
}
static BOOL IDRichDocumentPositionIsValid(
IDDocumentTextPosition *position,
IDBlockDocument *document,
NSUInteger requestedOffset,
BOOL requiresRequestedOffset) {
if (position == nil ||
position.documentRevision != document.documentRevision ||
position.canonicalOffset > document.canonicalLength ||
(requiresRequestedOffset &&
position.canonicalOffset != requestedOffset)) {
return NO;
}
UITextStorageDirection ignored;
if (!IDRichUIKitAffinity(position.localPosition.affinity, &ignored)) {
return NO;
}
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:document];
IDCanonicalLocation *location = [projection
locationForCanonicalOffset:position.canonicalOffset];
if (location == nil ||
![location.blockIdentifier isEqual:position.blockIdentifier] ||
location.localOffset != position.localPosition.utf16Offset) {
return NO;
}
return [projection canonicalOffsetForBlockIdentifier:position.blockIdentifier
localOffset:position.localPosition.utf16Offset] ==
position.canonicalOffset;
}
static BOOL IDRichCaretGeometryResultIsValid(
IDDocumentCaretGeometry *result,
IDBlockDocument *document,
NSUInteger requestedOffset) {
return result != nil && result.exact &&
IDRichRectIsFinite(result.documentRect) &&
IDRichDocumentPositionIsValid(result.position, document,
requestedOffset, YES);
}
static BOOL IDRichPointResultIsValid(
IDDocumentTextPosition *result,
IDBlockDocument *document) {
return IDRichDocumentPositionIsValid(result, document, 0, NO);
}
static BOOL IDRichLineGeometryResultIsValid(
IDDocumentLineGeometry *result,
IDBlockDocument *document,
NSUInteger requestedOffset) {
if (result == nil || !result.exact ||
!IDRichDocumentPositionIsValid(result.position, document,
requestedOffset, YES) ||
result.resolvedAffinity != result.position.localPosition.affinity ||
!IDRichRangeIsValid(result.canonicalRange, document.canonicalLength) ||
requestedOffset < result.canonicalRange.location ||
requestedOffset > NSMaxRange(result.canonicalRange)) {
return NO;
}
UITextStorageDirection ignored;
if (!IDRichUIKitAffinity(result.resolvedAffinity, &ignored)) return NO;
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:document];
IDCanonicalLocation *start = [projection
locationForCanonicalOffset:result.canonicalRange.location];
IDCanonicalLocation *end = [projection
locationForCanonicalOffset:NSMaxRange(result.canonicalRange)];
if (start == nil || end == nil ||
![start.blockIdentifier isEqual:result.position.blockIdentifier] ||
![end.blockIdentifier isEqual:result.position.blockIdentifier]) {
return NO;
}
return [projection canonicalOffsetForBlockIdentifier:start.blockIdentifier
localOffset:start.localOffset] ==
result.canonicalRange.location &&
[projection canonicalOffsetForBlockIdentifier:end.blockIdentifier
localOffset:end.localOffset] ==
NSMaxRange(result.canonicalRange);
}
static BOOL IDRichSelectionGeometryResultIsValid(
IDDocumentSelectionGeometry *result,
IDBlockDocument *document,
NSRange requestedRange) {
if (result == nil || !result.exact ||
result.documentRevision != document.documentRevision ||
result.sourceGeometryGeneration == 0 ||
!NSEqualRanges(result.canonicalSelection, requestedRange)) {
return NO;
}
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:document];
BOOL claimedStart = NO;
BOOL claimedEnd = NO;
for (IDDocumentSelectionSegment *segment in result.segments) {
IDBlock *block = nil;
for (IDBlock *candidate in document.blocks) {
if ([candidate.identifier isEqual:segment.blockIdentifier]) {
block = candidate;
break;
}
}
if (block == nil ||
!IDRichRangeIsValid(segment.canonicalRange,
document.canonicalLength) ||
!IDRichRectIsFinite(segment.documentRect) ||
CGRectIsEmpty(segment.documentRect) ||
(segment.writingDirection != IDBlockWritingDirectionLeftToRight &&
segment.writingDirection != IDBlockWritingDirectionRightToLeft)) {
return NO;
}
NSUInteger blockStart = [projection
canonicalOffsetForBlockIdentifier:block.identifier localOffset:0];
NSRange blockRange = NSMakeRange(blockStart, block.attributedText.length);
BOOL belongsToText = segment.canonicalRange.location >= blockStart &&
NSMaxRange(segment.canonicalRange) <= NSMaxRange(blockRange);
BOOL belongsToHead = blockStart > 0 &&
(segment.boundaryRole & IDBlockSelectionBoundaryRoleHead) != 0 &&
NSEqualRanges(segment.canonicalRange,
NSMakeRange(blockStart - 1, 1));
BOOL belongsToTail = NSMaxRange(blockRange) < document.canonicalLength &&
(segment.boundaryRole & IDBlockSelectionBoundaryRoleTail) != 0 &&
NSEqualRanges(segment.canonicalRange,
NSMakeRange(NSMaxRange(blockRange), 1));
if (!belongsToText && !belongsToHead && !belongsToTail) return NO;
if ((segment.containsLogicalStart && claimedStart) ||
(segment.containsLogicalEnd && claimedEnd)) return NO;
claimedStart |= segment.containsLogicalStart;
claimedEnd |= segment.containsLogicalEnd;
}
return YES;
}
- (NSUInteger)id_boundingRectForCharacterRangeCallCount {
return self.id_boundingRectForCharacterRangeCallCountState;
}
- (void)id_directSelectionReplacementDidOccur {
}
- (instancetype)initWithDocument:(IDBlockDocument *)document
blockStack:(IDBlockStackCanvasView *)blockStack {
IDCanonicalTextStorageAdapter *textStorageAdapter = document == nil
? nil
: IDCanonicalTextStorageAdapterCreate(document);
return [self initWithDocument:document
textStorageAdapter:textStorageAdapter
blockStack:blockStack];
}
- (instancetype)initWithDocument:(IDBlockDocument *)document
textStorageAdapter:(IDCanonicalTextStorageAdapter *)textStorageAdapter
blockStack:(IDBlockStackCanvasView *)blockStack {
if (document == nil) {
IDRichRaise(@"initWithDocument:blockStack:", @"document is nil");
}
if (textStorageAdapter == nil || textStorageAdapter.document != document) {
IDRichRaise(@"initWithDocument:blockStack:",
@"canonical text storage belongs to another document");
}
if (blockStack == nil) {
IDRichRaise(@"initWithDocument:blockStack:", @"block stack is nil");
}
if ([blockStack id_document] != document) {
IDRichRaise(@"initWithDocument:blockStack:",
@"block stack belongs to another document");
}
NSArray<IDBlock *> *blocks = document.blocks;
NSArray<IDBlockLayout *> *layouts = blockStack.blockLayouts;
if (layouts.count != blocks.count) {
IDRichRaise(@"initWithDocument:blockStack:",
@"block stack is incomplete or stale");
}
for (NSUInteger index = 0; index < blocks.count; index += 1) {
if (![layouts[index].blockIdentifier isEqual:blocks[index].identifier]) {
IDRichRaise(@"initWithDocument:blockStack:",
@"block stack order or identity is stale");
}
}
self = [super init];
if (self != nil) {
_id_textInputControllerConnection = [IDRichTextInputControllerConnection new];
_document = document;
_id_blockStack = blockStack;
_textStorageAdapter = textStorageAdapter;
_id_documentLayoutCoordinator = [[IDDocumentLayoutCoordinator alloc]
initWithDocument:document
source:blockStack];
_id_interactionTextContainer = [[NSTextContainer alloc] initWithSize:CGSizeZero];
_annotationStore = [[IDRichTextAnnotationStore alloc] initWithDocument:document];
[layouts enumerateObjectsUsingBlock:^(IDBlockLayout *layout,
NSUInteger index,
BOOL *stop) {
IDPrivateRequireRichTemporaryLayoutManager(
layout.layoutManager,
[NSString stringWithFormat:
@"Rich temporary layout-manager preflight for block %lu",
(unsigned long)index]);
IDPrivateRequireRichTemporaryCanvas(
layout.canvasView,
[NSString stringWithFormat:
@"Rich temporary canvas preflight for block %lu",
(unsigned long)index]);
}];
[self id_updateInteractionContainerGeometry];
}
return self;
}
- (IDRichTextPosition *)positionForCanonicalOffset:(NSUInteger)offset
affinity:(UITextStorageDirection)affinity {
return [self id_positionForCanonicalOffset:offset
affinity:affinity
tracksChanges:YES];
}
- (IDRichTextPosition *)id_positionForCanonicalOffset:(NSUInteger)offset
affinity:(UITextStorageDirection)affinity
tracksChanges:(BOOL)tracksChanges {
if (offset > self.document.canonicalLength) {
IDRichRaise(@"positionForCanonicalOffset:affinity:",
@"offset is outside the document");
}
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:self.document];
IDCanonicalLocation *location = [projection locationForCanonicalOffset:offset];
if (location == nil) {
IDRichRaise(@"positionForCanonicalOffset:affinity:",
@"offset has no canonical location");
}
IDRichTextAnchorBias bias = affinity == UITextStorageDirectionBackward
? IDRichTextAnchorBiasBefore
: IDRichTextAnchorBiasAfter;
IDRichTextAnchor *anchor = [[IDRichTextAnchor alloc]
initWithDocument:self.document
blockIdentifier:location.blockIdentifier
localOffset:location.localOffset
bias:bias
tracksChanges:tracksChanges];
return [[IDRichTextPosition alloc] initWithAnchor:anchor affinity:affinity];
}
- (IDRichTextRange *)rangeForCanonicalRange:(NSRange)range {
if (!IDRichRangeIsValid(range, self.document.canonicalLength)) {
IDRichRaise(@"rangeForCanonicalRange:", @"range is outside the document");
}
IDRichTextPosition *start = [self
positionForCanonicalOffset:range.location
affinity:UITextStorageDirectionBackward];
IDRichTextPosition *end = [self
positionForCanonicalOffset:NSMaxRange(range)
affinity:UITextStorageDirectionForward];
return [[IDRichTextRange alloc] initWithStart:start end:end];
}
- (IDRichTextRange *)id_nativeRangeForCanonicalRange:(NSRange)range {
NSUInteger documentLength = self.document.canonicalLength;
if (!IDRichRangeIsValid(range, documentLength)) {
IDRichRaise(@"nativeRangeForCanonicalRange:", @"range is outside the document");
}
UITextStorageDirection startAffinity = range.length == 0
? UITextStorageDirectionForward
: UITextStorageDirectionBackward;
IDRichTextPosition *start = [self
id_positionForCanonicalOffset:range.location
affinity:startAffinity
tracksChanges:NO];
IDRichTextPosition *end = range.length == 0
? start
: [self id_positionForCanonicalOffset:NSMaxRange(range)
affinity:UITextStorageDirectionForward
tracksChanges:NO];
return [[IDRichTextRange alloc] initWithStart:start end:end];
}
- (NSRange)canonicalRangeForTextRange:(IDRichTextRange *)range clipped:(BOOL)clipped {
NSString *stage = clipped
? TGPN_characterRangeForTextRange_clippedToDocument_()
: TGPN_characterRangeForTextRange_();
return [self id_projectedCanonicalRangeForTextRange:range
clipped:clipped
stage:stage];
}
- (NSTextStorage *)id_textStorage {
return self.textStorageAdapter;
}
- (void)id_attachInteractionTextView:(UIView *)view {
if (view == nil) IDRichRaise(@"attachInteractionTextView:", @"view is nil");
IDPrivateRequireTextContainerHost(view, @"Document interaction-container host preflight");
self.id_interactionTextView = view;
IDPrivateTextContainerSetTextView(self.id_interactionTextContainer, view);
}
- (void)id_detachInteractionTextView {
IDPrivateTextContainerSetTextView(self.id_interactionTextContainer, nil);
self.id_interactionTextView = nil;
}
- (void)id_updateInteractionContainerGeometry {
CGSize size = self.id_blockStack.documentContentSize;
CGFloat width = isfinite(size.width) ? MAX(size.width, 1.0) : 1.0;
CGFloat height = isfinite(size.height) ? MAX(size.height, 1.0) : 1.0;
self.id_interactionTextContainer.size = CGSizeMake(width, height);
}
- (NSArray<NSTextContainer *> *)id_textContainers {
return @[self.id_interactionTextContainer];
}
- (NSTextContainer *)id_firstTextContainer {
return self.id_interactionTextContainer;
}
- (NSTextContainer *)id_textContainerForPosition:(UITextPosition *)position {
(void)[self id_validPosition:position stage:TGPN_textContainerForPosition_()];
return self.id_interactionTextContainer;
}
- (BOOL)id_canAccessLayoutManager {
return NO;
}
- (id)id_layoutManager {
return nil;
}
- (id)inputController {
return ((IDRichTextInputControllerConnection *)
_id_textInputControllerConnection).id_textInputController;
}
- (void)id_adoptTextInputController:(id)controller {
if (controller == nil) {
IDRichRaise(TGPN_adoptTextInputController_(), @"controller is nil");
}
id adopted = self.inputController;
if (adopted != nil && adopted != controller) {
IDRichRaise(TGPN_adoptTextInputController_(),
@"a different controller is already adopted");
}
((IDRichTextInputControllerConnection *)
_id_textInputControllerConnection).id_textInputController = controller;
}
- (void)id_detachFromTextInputController {
((IDRichTextInputControllerConnection *)
_id_textInputControllerConnection).id_textInputController = nil;
}
- (UITextPosition *)id_beginningOfDocument {
return [self id_positionForCanonicalOffset:0
affinity:UITextStorageDirectionForward
tracksChanges:NO];
}
- (UITextPosition *)id_endOfDocument {
return [self id_positionForCanonicalOffset:self.document.canonicalLength
affinity:UITextStorageDirectionForward
tracksChanges:NO];
}
- (IDRichTextPosition *)id_positionWithValidIdentity:(UITextPosition *)position
stage:(NSString *)stage {
if (![position isMemberOfClass:IDRichTextPosition.class]) {
IDRichRaise(stage, @"position has a foreign concrete class");
}
IDRichTextPosition *richPosition = (IDRichTextPosition *)position;
IDBlockDocument *positionDocument = richPosition.anchor.document;
if (positionDocument == nil) {
IDRichRaise(stage, @"position anchor has expired");
}
if (positionDocument != self.document) {
IDRichRaise(stage, @"position belongs to another document");
}
return richPosition;
}
- (NSUInteger)id_rawCanonicalOffsetForPosition:(IDRichTextPosition *)position
stage:(NSString *)stage
clipped:(BOOL)clipped {
IDRichTextPosition *valid = [self id_positionWithValidIdentity:position stage:stage];
if (!valid.anchor.id_tracksChanges) {
return [valid.anchor id_snapshotCanonicalOffsetClampedToLength:self.document.canonicalLength];
}
NSUInteger cursor = 0;
NSArray<IDBlock *> *blocks = self.document.blocks;
for (NSUInteger index = 0; index < blocks.count; index += 1) {
IDBlock *block = blocks[index];
NSUInteger blockLength = block.attributedText.length;
if ([block.identifier isEqual:valid.anchor.blockIdentifier]) {
NSUInteger localOffset = valid.anchor.localOffset;
if (!clipped && localOffset > blockLength) {
IDRichRaise(stage, @"position is outside its block");
}
NSUInteger rawOffset = localOffset > NSUIntegerMax - cursor
? NSUIntegerMax
: cursor + localOffset;
if (clipped) {
return MIN(rawOffset, self.document.canonicalLength);
}
if (rawOffset > self.document.canonicalLength) {
IDRichRaise(stage, @"position is outside the document");
}
return rawOffset;
}
cursor += blockLength;
if (index + 1 < blocks.count) {
cursor += 1;
}
}
IDRichRaise(stage, @"position block no longer exists");
return 0;
}
- (IDRichTextPosition *)id_validPosition:(UITextPosition *)position
stage:(NSString *)stage {
IDRichTextPosition *richPosition =
[self id_positionWithValidIdentity:position stage:stage];
(void)[self id_rawCanonicalOffsetForPosition:richPosition stage:stage clipped:NO];
return richPosition;
}
- (UITextRange *)id_validRange:(UITextRange *)range stage:(NSString *)stage {
UITextPosition *start = nil;
UITextPosition *end = nil;
if ([range isMemberOfClass:IDRichTextRange.class]) {
IDRichTextRange *richRange = (IDRichTextRange *)range;
start = [richRange id_storedStart];
end = [richRange id_storedEnd];
} else {
start = range.start;
end = range.end;
}
if (start == nil && end == nil) {
return range;
}
if (start == nil || end == nil) {
IDRichRaise(stage, @"range has only one endpoint");
}
[self id_positionWithValidIdentity:start stage:stage];
[self id_positionWithValidIdentity:end stage:stage];
return range;
}
- (NSUInteger)id_canonicalOffsetForPosition:(IDRichTextPosition *)position {
return [self id_rawCanonicalOffsetForPosition:position
stage:@"canonicalOffsetForPosition:"
clipped:NO];
}
- (UITextRange *)id_emptyTextRangeAtPosition:(nullable UITextPosition *)position {
// nil is the beginning of the document, the native layout controller's answer (UIKit's input
// controller passes an unchecked stepped position here after a keyboard replacement; p19).
if (position == nil) {
position = [self id_beginningOfDocument];
}
IDRichTextPosition *valid = [self id_validPosition:position
stage:TGPN_emptyTextRangeAtPosition_()];
return [[IDRichTextRange alloc] initWithStart:valid end:valid];
}
- (UITextRange *)id_textRangeFromPosition:(nullable UITextPosition *)from
toPosition:(nullable UITextPosition *)to {
// A MISSING endpoint is the native layout controller's answer, not a failure: nil `from` is the
// beginning of the document and nil `to` is `from`. UIKit's own input controller relies on it —
// `characterRangeByExtendingPosition:inDirection:` steps one character with
// `positionFromPosition:offset:affinity:` and passes the result straight through, so at a
// document edge it asks for a range to nil and a stock text view answers an empty range there
// (composer harness p19, `HarnessExtendPastDocumentEdgeTests`). A position of a foreign class,
// an expired anchor or another document still fails.
if (from == nil) {
from = [self id_beginningOfDocument];
}
if (to == nil) {
to = from;
}
IDRichTextPosition *validFrom = [self id_validPosition:from
stage:@"textRangeFromPosition:toPosition:"];
IDRichTextPosition *validTo = [self id_validPosition:to
stage:@"textRangeFromPosition:toPosition:"];
return [[IDRichTextRange alloc] initWithStart:validFrom end:validTo];
}
- (nullable UITextPosition *)id_positionFromPosition:(UITextPosition *)position
offset:(NSInteger)offset {
IDRichTextPosition *valid = [self id_validPosition:position
stage:@"positionFromPosition:offset:"];
NSUInteger base = [self id_canonicalOffsetForPosition:valid];
NSUInteger length = self.document.canonicalLength;
if ((offset < 0 && (NSUInteger)(-(offset + 1)) + 1 > base) ||
(offset >= 0 && (NSUInteger)offset > length - base)) {
return nil;
}
NSUInteger result = offset < 0
? base - ((NSUInteger)(-(offset + 1)) + 1)
: base + (NSUInteger)offset;
return [self id_positionForCanonicalOffset:result
affinity:valid.affinity
tracksChanges:valid.anchor.id_tracksChanges];
}
- (nullable UITextPosition *)id_positionFromPosition:(UITextPosition *)position
offset:(NSInteger)offset
affinity:(UITextStorageDirection)affinity {
IDRichTextPosition *valid = [self id_validPosition:position
stage:@"positionFromPosition:offset:affinity:"];
NSUInteger base = [self id_canonicalOffsetForPosition:valid];
NSUInteger length = self.document.canonicalLength;
if ((offset < 0 && (NSUInteger)(-(offset + 1)) + 1 > base) ||
(offset >= 0 && (NSUInteger)offset > length - base)) {
return nil;
}
NSUInteger result = offset < 0
? base - ((NSUInteger)(-(offset + 1)) + 1)
: base + (NSUInteger)offset;
return [self id_positionForCanonicalOffset:result
affinity:affinity
tracksChanges:valid.anchor.id_tracksChanges];
}
- (IDBlockLayout *)id_layoutForPosition:(IDRichTextPosition *)position
stage:(NSString *)stage
location:(IDCanonicalLocation * __autoreleasing *)location {
NSUInteger offset = [self id_rawCanonicalOffsetForPosition:position stage:stage clipped:NO];
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:self.document];
IDCanonicalLocation *projected = [projection locationForCanonicalOffset:offset];
IDBlockLayout *layout = [self.id_blockStack
layoutForBlockIdentifier:projected.blockIdentifier];
if (layout == nil) IDRichRaise(stage, @"position has no current block layout");
if (location != NULL) *location = projected;
return layout;
}
- (IDRichTextPosition *)id_positionForBlockLayout:(IDBlockLayout *)layout
localOffset:(NSUInteger)localOffset
affinity:(UITextStorageDirection)affinity
tracksChanges:(BOOL)tracksChanges {
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:self.document];
NSUInteger canonical = [projection canonicalOffsetForBlockIdentifier:layout.blockIdentifier
localOffset:localOffset];
return [self id_positionForCanonicalOffset:canonical
affinity:affinity
tracksChanges:tracksChanges];
}
- (nullable UITextPosition *)id_positionFromPosition:(UITextPosition *)position
inDirection:(UITextLayoutDirection)direction
offset:(NSInteger)offset
affinity:(UITextStorageDirection)affinity
anchorPositionOffset:(CGFloat)anchorPositionOffset {
NSString *stage = @"positionFromPosition:inDirection:offset:affinity:anchorPositionOffset:";
IDRichTextPosition *valid = [self id_validPosition:position stage:stage];
if (offset == 0) return valid;
if (!isfinite(anchorPositionOffset)) return nil;
NSUInteger stepCount = offset < 0
? (NSUInteger)(-(offset + 1)) + 1
: (NSUInteger)offset;
if (stepCount > self.document.canonicalLength) return nil;
UITextLayoutDirection effectiveDirection;
IDBlockTextAffinity currentAffinity;
IDBlockLayoutDirection neutralDirection;
if (!IDRichEffectiveDirection(direction, offset, &effectiveDirection) ||
!IDRichNeutralAffinity(affinity, &currentAffinity) ||
!IDRichNeutralDirection(effectiveDirection, &neutralDirection)) {
return nil;
}
BOOL vertical = effectiveDirection == UITextLayoutDirectionUp ||
effectiveDirection == UITextLayoutDirectionDown;
NSUInteger currentOffset = [self id_canonicalOffsetForPosition:valid];
CGFloat currentAnchor = anchorPositionOffset;
// Each line is one step from where the previous step landed, always toward the SAME goal x
// (the caller's anchor), which is how a held ↑/↓ resolves: UIKit asks for `offset` lines from
// the history's start. A step with no line beyond it is the document's edge, and the walk ends
// there at the document's END (↓) or START (↑) — a stock text view's answer. Answering nil
// instead left UIKit to fall back on a position of its own, which past a container was a line
// far back up the document (composer harness p17, `HarnessVerticalMoveAtDocumentEdgeTests`).
IDDocumentCaretNavigationResult *neutral = nil;
BOOL reachedEdge = NO;
for (NSUInteger index = 0; index < stepCount; index += 1) {
neutral = [self.id_documentLayoutCoordinator
visuallyAdjacentPositionFromCanonicalOffset:currentOffset
affinity:currentAffinity
direction:neutralDirection
anchorPositionOffset:vertical
? anchorPositionOffset
: currentAnchor];
if (!IDRichNavigationResultIsValid(neutral, self.document,
vertical, anchorPositionOffset)) {
neutral = nil;
if (vertical) {
BOOL downward = effectiveDirection == UITextLayoutDirectionDown;
NSUInteger edgeOffset = downward ? self.document.canonicalLength : 0;
IDBlockTextAffinity edgeAffinity = downward
? IDBlockTextAffinityUpstream
: IDBlockTextAffinityDownstream;
if ([self id_canonicalOffset:currentOffset
affinity:currentAffinity
isOnTheLineOfOffset:edgeOffset
affinity:edgeAffinity
downward:downward]) {
currentOffset = edgeOffset;
currentAffinity = edgeAffinity;
reachedEdge = YES;
}
}
break;
}
currentOffset = neutral.position.canonicalOffset;
currentAffinity = neutral.position.localPosition.affinity;
currentAnchor = neutral.anchorPositionOffset;
}
BOOL landed = neutral != nil || reachedEdge;
UITextStorageDirection uiAffinity = UITextStorageDirectionForward;
if (landed && !IDRichUIKitAffinity(currentAffinity, &uiAffinity)) {
landed = NO;
}
IDRichTextPosition *result = !landed
? nil
: [self id_positionForCanonicalOffset:currentOffset
affinity:uiAffinity
tracksChanges:valid.anchor.id_tracksChanges];
return result;
}
- (UITextRange *)id_textRangeForLineEnclosingPosition:(UITextPosition *)position {
IDRichTextPosition *valid = [self id_validPosition:position
stage:TGPN_textRangeForLineEnclosingPosition_()];
return [self id_textRangeForLineEnclosingPosition:valid
effectiveAffinity:valid.affinity];
}
- (UITextRange *)id_textRangeForLineEnclosingPosition:(UITextPosition *)position
effectiveAffinity:(UITextStorageDirection)affinity {
NSString *stage = TGPN_textRangeForLineEnclosingPosition_effectiveAffinity_();
IDRichTextPosition *valid = [self id_validPosition:position stage:stage];
NSUInteger revision = self.document.documentRevision;
NSUInteger canonicalOffset = [self id_canonicalOffsetForPosition:valid];
IDBlockTextAffinity neutralRequestedAffinity;
BOOL hasNeutralRequestedAffinity =
IDRichNeutralAffinity(affinity, &neutralRequestedAffinity);
IDDocumentLineGeometry *neutral = hasNeutralRequestedAffinity
? [self.id_documentLayoutCoordinator
lineGeometryForCanonicalOffset:canonicalOffset
affinity:neutralRequestedAffinity]
: nil;
BOOL validNeutral = revision == self.document.documentRevision &&
IDRichLineGeometryResultIsValid(neutral, self.document, canonicalOffset);
IDRichTextRange *result = validNeutral
? [self id_nativeRangeForCanonicalRange:neutral.canonicalRange]
: nil;
return result;
}
- (NSDictionary<NSAttributedStringKey, id> *)id_attributesAtPosition:(UITextPosition *)position
inDirection:(UITextStorageDirection)direction {
NSString *stage = TGPN_attributesAtPosition_inDirection_();
IDRichTextPosition *valid = [self id_validPosition:position stage:stage];
IDCanonicalLocation *location = nil;
IDBlockLayout *layout = [self id_layoutForPosition:valid stage:stage location:&location];
return [layout attributesAtLocalOffset:location.localOffset direction:direction];
}
- (NSWritingDirection)id_baseWritingDirectionAtPosition:(UITextPosition *)position {
NSString *stage = TGPN_baseWritingDirectionAtPosition_();
IDRichTextPosition *valid = [self id_validPosition:position stage:stage];
IDCanonicalLocation *location = nil;
IDBlockLayout *layout = [self id_layoutForPosition:valid stage:stage location:&location];
return [layout baseWritingDirectionAtLocalOffset:location.localOffset];
}
- (NSComparisonResult)id_comparePosition:(UITextPosition *)first
toPosition:(UITextPosition *)second {
IDRichTextPosition *validFirst = [self id_validPosition:first
stage:@"comparePosition:toPosition:"];
IDRichTextPosition *validSecond = [self id_validPosition:second
stage:@"comparePosition:toPosition:"];
NSUInteger firstOffset = [self id_canonicalOffsetForPosition:validFirst];
NSUInteger secondOffset = [self id_canonicalOffsetForPosition:validSecond];
if (firstOffset < secondOffset) return NSOrderedAscending;
if (firstOffset > secondOffset) return NSOrderedDescending;
return NSOrderedSame;
}
- (NSInteger)id_offsetFromPosition:(UITextPosition *)first
toPosition:(UITextPosition *)second {
IDRichTextPosition *validFirst = [self id_validPosition:first
stage:@"offsetFromPosition:toPosition:"];
IDRichTextPosition *validSecond = [self id_validPosition:second
stage:@"offsetFromPosition:toPosition:"];
NSUInteger firstOffset = [self id_canonicalOffsetForPosition:validFirst];
NSUInteger secondOffset = [self id_canonicalOffsetForPosition:validSecond];
if (secondOffset >= firstOffset) {
NSUInteger delta = secondOffset - firstOffset;
if (delta > NSIntegerMax) {
IDRichRaise(@"offsetFromPosition:toPosition:", @"offset overflows NSInteger");
}
return (NSInteger)delta;
}
NSUInteger delta = firstOffset - secondOffset;
if (delta > (NSUInteger)NSIntegerMax) {
IDRichRaise(@"offsetFromPosition:toPosition:", @"offset overflows NSInteger");
}
return -(NSInteger)delta;
}
- (UITextStorageDirection)id_affinityForPosition:(UITextPosition *)position {
return [self id_validPosition:position stage:TGPN_affinityForPosition_()].affinity;
}
- (NSRange)id_projectedCanonicalRangeForTextRange:(UITextRange *)range
clipped:(BOOL)clipped
stage:(NSString *)stage {
UITextRange *valid = [self id_validRange:range stage:stage];
UITextPosition *firstPosition = nil;
UITextPosition *secondPosition = nil;
if ([valid isMemberOfClass:IDRichTextRange.class]) {
IDRichTextRange *richRange = (IDRichTextRange *)valid;
firstPosition = [richRange id_storedStart];
secondPosition = [richRange id_storedEnd];
} else {
firstPosition = valid.start;
secondPosition = valid.end;
}
if (firstPosition == nil && secondPosition == nil) {
return NSMakeRange(0, 0);
}
NSUInteger first = [self id_rawCanonicalOffsetForPosition:(IDRichTextPosition *)firstPosition
stage:stage
clipped:clipped];
NSUInteger second = [self id_rawCanonicalOffsetForPosition:(IDRichTextPosition *)secondPosition
stage:stage
clipped:clipped];
NSUInteger start = MIN(first, second);
NSUInteger end = MAX(first, second);
return NSMakeRange(start, end - start);
}
- (NSRange)id_characterRangeForTextRange:(nullable UITextRange *)range
clipped:(BOOL)clipped {
if (range == nil) {
return NSMakeRange(0, 0);
}
NSString *stage = clipped
? TGPN_characterRangeForTextRange_clippedToDocument_()
: TGPN_characterRangeForTextRange_();
NSRange characterRange = [self id_projectedCanonicalRangeForTextRange:range
clipped:clipped
stage:stage];
// iOS 26.5's singular clipped callback collapses every empty range to zero.
return clipped && characterRange.length == 0 ? NSMakeRange(0, 0) : characterRange;
}
- (NSArray<NSValue *> *)id_characterRangesForTextRange:(UITextRange *)range
clipped:(BOOL)clipped {
NSString *stage = clipped
? TGPN_characterRangesForTextRange_clippedToDocument_()
: @"characterRangesForTextRange:";
NSRange characterRange = [self id_projectedCanonicalRangeForTextRange:range
clipped:clipped
stage:stage];
return @[[NSValue valueWithRange:characterRange]];
}
- (UITextRange *)id_textRangeForCharacterRange:(NSRange)range {
NSString *stage = TGPN_textRangeForCharacterRange_();
if (range.length > NSUIntegerMax - range.location) {
IDRichRaise(stage, @"range overflows NSUInteger");
}
NSUInteger documentLength = self.document.canonicalLength;
if (range.location > documentLength || NSMaxRange(range) > documentLength) {
range = NSMakeRange(documentLength, 0);
}
return [self id_nativeRangeForCanonicalRange:range];
}
- (UITextRange *)id_textRangeForCharacterRanges:(NSArray<NSValue *> *)ranges {
if (![ranges isKindOfClass:NSArray.class] || ranges.count != 1) {
IDRichRaise(TGPN_textRangeForCharacterRanges_(),
@"P0 requires exactly one contiguous range");
}
id value = ranges.firstObject;
if (![value isKindOfClass:NSValue.class] ||
strcmp([value objCType], @encode(NSRange)) != 0) {
IDRichRaise(TGPN_textRangeForCharacterRanges_(),
@"range element has a foreign value type");
}
NSRange range = [value rangeValue];
NSString *stage = TGPN_textRangeForCharacterRanges_();
if (range.length > NSUIntegerMax - range.location) {
IDRichRaise(stage, @"range element overflows NSUInteger");
}
NSUInteger documentLength = self.document.canonicalLength;
if (range.location > documentLength || NSMaxRange(range) > documentLength) {
range = NSMakeRange(documentLength, 0);
}
return [self id_nativeRangeForCanonicalRange:range];
}
- (UITextPosition *)id_positionWithOffset:(NSInteger)offset
affinity:(UITextStorageDirection)affinity {
if (offset < 0 || (NSUInteger)offset > self.document.canonicalLength) {
IDRichRaise(TGPN_positionWithOffset_affinity_(), @"offset is outside the document");
}
return [self id_positionForCanonicalOffset:(NSUInteger)offset
affinity:affinity
tracksChanges:NO];
}
- (NSRange)id_rangeOfCharacterClusterAtIndex:(NSUInteger)index type:(NSInteger)type {
if (type < 1 || type > 4) {
[NSException raise:NSInternalInconsistencyException
format:@"Unexpected clusterType = %ld", (long)type];
}
NSString *string = self.textStorageAdapter.string;
if (string.length == 0 || index == string.length) {
return NSMakeRange(string.length, 0);
}
NSUInteger characterIndex = MIN(index, string.length - 1);
return [string rangeOfComposedCharacterSequenceAtIndex:characterIndex];
}
- (NSArray *)id_selectionRectsForRange:(UITextRange *)range
fromView:(UIView *)view
forContainerPassingTest:(BOOL (^)(id container))predicate {
NSRange characterRange = [self id_projectedCanonicalRangeForTextRange:range
clipped:YES
stage:TGPN_selectionRectsForRange_fromView_forContainerPassingTest_()];
if (predicate != nil && !predicate(self.id_interactionTextContainer)) {
return @[];
}
if (characterRange.length == 0) {
UITextPosition *position = range.start;
CGRect documentRect = [self
id_insertionRectForPosition:position
typingAttributes:@{}
placeholderAttachment:nil
textContainer:NULL];
if (!IDRichRectIsFinite(documentRect)) {
return @[];
}
documentRect.origin.x = CGRectGetMidX(documentRect);
documentRect.size.width = 0.0;
return @[[[IDRichTextSelectionRect alloc]
initWithRect:documentRect
containsStart:YES
containsEnd:YES
isVertical:NO
writingDirection:
[self id_baseWritingDirectionAtPosition:position]]];
}
NSArray<IDRichTextSelectionRect *> *exact = [self
id_exactSelectionRectsForRange:range
fromView:view
forContainerPassingTest:nil];
return exact ?: @[];
}
- (NSArray<IDRichTextSelectionRect *> *)
id_exactSelectionRectsForRange:(UITextRange *)range
fromView:(UIView *)view
forContainerPassingTest:(BOOL (^)(id container))predicate {
NSRange characterRange = [self id_projectedCanonicalRangeForTextRange:range
clipped:YES
stage:@"exactSelectionRectsForRange:"];
if (predicate != nil && !predicate(self.id_interactionTextContainer)) {
return @[];
}
if (characterRange.length == 0) {
return [self id_selectionRectsForRange:range
fromView:view
forContainerPassingTest:nil];
}
NSUInteger documentRevision = self.document.documentRevision;
IDDocumentSelectionGeometry *geometry = [self.id_documentLayoutCoordinator
selectionGeometryForCanonicalRange:characterRange];
if (documentRevision != self.document.documentRevision ||
!IDRichSelectionGeometryResultIsValid(geometry, self.document,
characterRange)) {
return nil;
}
UIView *targetView = view ?: self.id_blockStack.superview;
NSMutableArray<IDRichTextSelectionRect *> *rects =
[NSMutableArray arrayWithCapacity:geometry.segments.count];
IDBlockWritingDirection sharedDirection = geometry.segments.count == 0
? IDBlockWritingDirectionLeftToRight
: geometry.segments.firstObject.writingDirection;
for (IDDocumentSelectionSegment *segment in geometry.segments) {
CGRect rect = targetView == nil
? segment.documentRect
: [self.id_blockStack convertRect:segment.documentRect toView:targetView];
if (!IDRichRectIsFinite(rect) || CGRectIsEmpty(rect)) return nil;
NSWritingDirection direction =
sharedDirection == IDBlockWritingDirectionRightToLeft
? NSWritingDirectionRightToLeft
: NSWritingDirectionLeftToRight;
[rects addObject:[[IDRichTextSelectionRect alloc]
initWithRect:rect
baselineOffset:segment.baselineOffset
containsStart:segment.containsLogicalStart
containsEnd:segment.containsLogicalEnd
isVertical:segment.isVertical
writingDirection:direction]];
}
return rects.copy;
}
- (void)id_requestTextGeometryAtPosition:(UITextPosition *)position
typingAttributes:(NSDictionary<NSAttributedStringKey, id> *)attributes
resultBlock:(void (^)(NSTextContainer *container,
CGRect rect,
CGFloat baselineOffset,
UITextPosition *position))resultBlock {
NSString *stage = TGPN_requestTextGeometryAtPosition_typingAttributes_resultBlock_();
IDRichTextPosition *valid = [self id_validPosition:position stage:stage];
if (resultBlock == nil) return;
CGRect rect = [self id_insertionRectForPosition:valid
typingAttributes:attributes
placeholderAttachment:nil
textContainer:NULL];
// `-[UITextInputController selectionRectsForRange:]` asks this for the range's end and stamps
// the baseline on every rect the layout controller returned; the text-choice assistant reads
// it back to place an accepted autocorrection's underline. Called with the rect alone, this
// block left the baseline in an unset register — infinity on device, an underline at
// y = infinity (2026-09-11).
resultBlock(self.id_interactionTextContainer, rect,
[self id_baselineOffsetForPosition:valid insertionRect:rect stage:stage], valid);
}
/// The line's baseline measured down from `insertionRect`'s top, from the same TextKit segments
/// the selection rects carry; 0 when the line has no geometry, which UIKit reads as "unknown".
- (CGFloat)id_baselineOffsetForPosition:(IDRichTextPosition *)position
insertionRect:(CGRect)insertionRect
stage:(NSString *)stage {
(void)stage;
if (!IDRichRectIsFinite(insertionRect)) return 0.0;
// Not `id_layoutForPosition:`, which raises for a block without a layout: this runs inside
// UIKit's own geometry calls, where an unlaid-out block is an ordinary in-flight state.
NSUInteger offset = [self id_canonicalOffsetForPosition:position];
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:self.document];
IDCanonicalLocation *location = [projection locationForCanonicalOffset:offset];
IDBlockLayout *layout = location == nil ? nil
: [self.id_blockStack layoutForBlockIdentifier:location.blockIdentifier];
if (layout == nil) return 0.0;
NSArray<IDRichTextSelectionRect *> *rects =
[layout selectionRectsForLocalRange:NSMakeRange(location.localOffset, 0)
extendHead:YES
extendTail:YES];
IDRichTextSelectionRect *line = rects.firstObject;
if (line == nil || line.baselineOffset <= 0.0) return 0.0;
CGRect lineRect = [self.id_blockStack convertRect:line.rect fromView:layout.canvasView];
CGFloat baseline = CGRectGetMinY(lineRect) + line.baselineOffset - CGRectGetMinY(insertionRect);
return isfinite(baseline) && baseline > 0.0 ? baseline : 0.0;
}
- (CGRect)id_insertionRectForPosition:(UITextPosition *)position
typingAttributes:(NSDictionary<NSAttributedStringKey, id> *)attributes
placeholderAttachment:(id)attachment
textContainer:(NSTextContainer * _Nullable * _Nullable)textContainer {
NSString *stage = TGPN_insertionRectForPosition_typingAttributes_placeholderAttachment_textContainer_();
IDRichTextPosition *valid = [self id_validPosition:position stage:stage];
(void)attributes;
(void)attachment;
if (textContainer != NULL) *textContainer = self.id_interactionTextContainer;
NSUInteger revision = self.document.documentRevision;
NSUInteger canonicalOffset = [self id_canonicalOffsetForPosition:valid];
IDBlockTextAffinity neutralCaretAffinity;
BOOL hasNeutralCaretAffinity =
IDRichNeutralAffinity(valid.affinity, &neutralCaretAffinity);
IDDocumentCaretGeometry *neutral = hasNeutralCaretAffinity
? [self.id_documentLayoutCoordinator
caretGeometryForCanonicalOffset:canonicalOffset
affinity:neutralCaretAffinity]
: nil;
BOOL validNeutral = revision == self.document.documentRevision &&
IDRichCaretGeometryResultIsValid(neutral, self.document, canonicalOffset);
CGRect documentRect = validNeutral ? neutral.documentRect : CGRectNull;
return documentRect;
}
- (UITextPosition *)id_nearestPositionAtPoint:(CGPoint)point
inContainer:(NSTextContainer *)container {
NSString *stage = TGPN_nearestPositionAtPoint_inContainer_();
if (container != nil && container != self.id_interactionTextContainer) {
IDRichRaise(stage, @"container is not the document interaction container");
}
NSUInteger revision = self.document.documentRevision;
IDDocumentTextPosition *neutral = [self.id_documentLayoutCoordinator
nearestPositionToDocumentPoint:point];
BOOL validNeutral = revision == self.document.documentRevision &&
IDRichPointResultIsValid(neutral, self.document);
UITextStorageDirection affinity = UITextStorageDirectionForward;
if (validNeutral &&
!IDRichUIKitAffinity(neutral.localPosition.affinity, &affinity)) {
validNeutral = NO;
}
IDRichTextPosition *result = validNeutral
? [self id_positionForCanonicalOffset:neutral.canonicalOffset
affinity:affinity
tracksChanges:NO]
: nil;
return result;
}
- (UITextPosition *)id_cursorPositionAtPoint:(CGPoint)point
inContainer:(NSTextContainer *)container {
return [self id_nearestPositionAtPoint:point inContainer:container];
}
- (UITextRange *)id_visualSelectionRangeForExtent:(UITextPosition *)extent
forPoint:(CGPoint)point
fromPosition:(UITextPosition *)position
inDirection:(UITextStorageDirection)direction {
NSString *stage = @"visualSelectionRangeForExtent:forPoint:fromPosition:inDirection:";
IDRichTextPosition *validExtent = [self id_validPosition:extent stage:stage];
IDRichTextPosition *anchor = [self id_validPosition:position
stage:stage];
IDCanonicalLocation *extentLocation = nil;
IDBlockLayout *layout = [self id_layoutForPosition:validExtent
stage:stage
location:&extentLocation];
UIView *hostView = self.id_blockStack.superview;
CGPoint localPoint = hostView == nil
? [layout.canvasView convertPoint:point fromView:self.id_blockStack]
: [layout.canvasView convertPoint:point fromView:hostView];
localPoint.x = MAX(localPoint.x, 0.0);
localPoint.y = MAX(localPoint.y, 0.0);
NSUInteger anchorOffset = [self id_canonicalOffsetForPosition:anchor];
NSUInteger extentOffset = [self id_canonicalOffsetForPosition:validExtent];
IDCanonicalLocation *anchorLocation = nil;
IDBlockLayout *anchorLayout = [self id_layoutForPosition:anchor
stage:stage
location:&anchorLocation];
BOOL sameLayout = anchorLayout == layout;
NSUInteger localAnchorOffset = sameLayout
? anchorLocation.localOffset
: (anchorOffset < extentOffset ? 0 : layout.contentStorage.textStorage.length);
id<NSTextLocation> localExtent = [layout locationForLocalOffset:extentLocation.localOffset];
id<NSTextLocation> localAnchor = [layout locationForLocalOffset:localAnchorOffset];
if (localExtent == nil || localAnchor == nil) return nil;
@try {
NSTextSelectionAffinity affinity = direction == UITextStorageDirectionForward
? NSTextSelectionAffinityDownstream
: NSTextSelectionAffinityUpstream;
NSTextSelection *fixedAnchor = [[NSTextSelection alloc]
initWithLocation:localAnchor affinity:affinity];
NSArray<NSTextSelection *> *selections =
[layout.layoutManager.textSelectionNavigation
textSelectionsInteractingAtPoint:localPoint
inContainerAtLocation:localExtent
anchors:@[fixedAnchor]
modifiers:1
selecting:YES
bounds:(CGRect){CGPointZero,
layout.textContainer.size}];
NSMutableArray<NSTextRange *> *ranges = [NSMutableArray array];
for (NSTextSelection *selection in selections) {
[ranges addObjectsFromArray:selection.textRanges];
}
if (ranges.count == 0) return nil;
NSUInteger localStart = [layout localOffsetForLocation:ranges.firstObject.location];
NSUInteger localEnd = [layout localOffsetForLocation:ranges.lastObject.endLocation];
if (localStart > layout.contentStorage.textStorage.length ||
localEnd > layout.contentStorage.textStorage.length) {
return nil;
}
IDRichTextPosition *start = nil;
IDRichTextPosition *end = nil;
if (sameLayout) {
start = [self id_positionForBlockLayout:layout localOffset:localStart
affinity:direction tracksChanges:NO];
end = [self id_positionForBlockLayout:layout localOffset:localEnd
affinity:direction tracksChanges:NO];
} else {
NSUInteger movingLocalOffset = anchorOffset < extentOffset
? localEnd
: localStart;
IDRichTextPosition *moving = [self id_positionForBlockLayout:layout
localOffset:movingLocalOffset
affinity:direction
tracksChanges:NO];
NSUInteger movingOffset = [self id_canonicalOffsetForPosition:moving];
NSUInteger visualAnchorLocalOffset = anchorLocation.localOffset;
NSUInteger oppositeAnchorLocalOffset = anchorOffset < extentOffset
? anchorLayout.contentStorage.textStorage.length
: 0;
id<NSTextLocation> visualAnchorLocation =
[anchorLayout locationForLocalOffset:anchorLocation.localOffset];
id<NSTextLocation> oppositeAnchorLocation =
[anchorLayout locationForLocalOffset:oppositeAnchorLocalOffset];
CGRect anchorCaret = [anchorLayout
caretRectForLocalOffset:anchorLocation.localOffset
affinity:direction];
if (visualAnchorLocation != nil && oppositeAnchorLocation != nil &&
!CGRectIsNull(anchorCaret)) {
NSTextSelection *oppositeAnchor = [[NSTextSelection alloc]
initWithLocation:oppositeAnchorLocation affinity:affinity];
NSArray<NSTextSelection *> *anchorSelections =
[anchorLayout.layoutManager.textSelectionNavigation
textSelectionsInteractingAtPoint:
CGPointMake(CGRectGetMidX(anchorCaret), CGRectGetMidY(anchorCaret))
inContainerAtLocation:visualAnchorLocation
anchors:@[oppositeAnchor]
modifiers:1
selecting:YES
bounds:(CGRect){CGPointZero,
anchorLayout.textContainer.size}];
NSTextRange *visualAnchorRange =
anchorSelections.firstObject.textRanges.firstObject;
if (visualAnchorRange != nil) {
NSUInteger candidate = [anchorLayout localOffsetForLocation:
anchorOffset < extentOffset
? visualAnchorRange.location
: visualAnchorRange.endLocation];
if (candidate <= anchorLayout.contentStorage.textStorage.length) {
visualAnchorLocalOffset = candidate;
}
}
}
IDRichTextPosition *visualAnchor = [self
id_positionForBlockLayout:anchorLayout
localOffset:visualAnchorLocalOffset
affinity:direction
tracksChanges:NO];
NSUInteger visualAnchorOffset =
[self id_canonicalOffsetForPosition:visualAnchor];
start = [self id_positionForCanonicalOffset:MIN(visualAnchorOffset, movingOffset)
affinity:direction tracksChanges:NO];
end = [self id_positionForCanonicalOffset:MAX(visualAnchorOffset, movingOffset)
affinity:direction tracksChanges:NO];
}
return [[IDRichTextRange alloc] initWithStart:start end:end];
} @catch (__unused NSException *exception) {
return nil;
}
}
- (void)id_enumerateLayoutsForTextRange:(UITextRange *)range
stage:(NSString *)stage
usingBlock:(void (^)(IDBlockLayout *, NSRange))block {
NSRange canonical = [self id_projectedCanonicalRangeForTextRange:range
clipped:YES
stage:stage];
NSArray<IDBlock *> *blocks = self.document.blocks;
NSUInteger cursor = 0;
for (NSUInteger index = 0; index < blocks.count; index += 1) {
IDBlock *documentBlock = blocks[index];
NSRange blockRange = NSMakeRange(cursor, documentBlock.attributedText.length);
NSRange intersection = NSIntersectionRange(canonical, blockRange);
BOOL emptyInBlock = canonical.length == 0 &&
canonical.location >= blockRange.location &&
canonical.location <= NSMaxRange(blockRange);
BOOL followingSeparator = index + 1 < blocks.count &&
NSLocationInRange(NSMaxRange(blockRange), canonical);
BOOL precedingSeparator = index > 0 &&
NSLocationInRange(blockRange.location - 1, canonical);
if (intersection.length > 0 || emptyInBlock ||
followingSeparator || precedingSeparator) {
IDBlockLayout *layout = [self.id_blockStack
layoutForBlockIdentifier:documentBlock.identifier];
NSRange local = intersection.length > 0
? NSMakeRange(intersection.location - cursor, intersection.length)
: NSMakeRange(precedingSeparator
? 0
: MIN(canonical.location - cursor, blockRange.length), 0);
if (layout != nil) block(layout, local);
}
cursor = NSMaxRange(blockRange) + (index + 1 < blocks.count ? 1 : 0);
}
}
- (CGRect)id_boundingRectForCharacterRange:(NSRange)range {
self.id_boundingRectForCharacterRangeCallCountState += 1;
UITextRange *textRange = [self id_textRangeForCharacterRange:range];
UIView *hostView = self.id_blockStack.superview;
__block CGRect result = CGRectNull;
[self id_enumerateLayoutsForTextRange:textRange
stage:TGPN_boundingRectForCharacterRange_()
usingBlock:^(IDBlockLayout *layout, NSRange localRange) {
CGRect local = [layout boundingRectForLocalRange:localRange];
if (CGRectIsNull(local) || !IDRichRectIsFinite(local)) return;
CGRect converted = hostView == nil
? local
: [layout.canvasView convertRect:local toView:hostView];
if (!IDRichRectIsFinite(converted)) return;
result = CGRectIsNull(result) ? converted : CGRectUnion(result, converted);
}];
return result;
}
- (void)id_ensureLayoutForRange:(UITextRange *)range {
[self id_enumerateLayoutsForTextRange:range
stage:@"ensureLayoutForRange:"
usingBlock:^(IDBlockLayout *layout, NSRange localRange) {
id<NSTextLocation> start = [layout locationForLocalOffset:localRange.location];
id<NSTextLocation> end = [layout locationForLocalOffset:NSMaxRange(localRange)];
if (start != nil && end != nil) {
[layout.layoutManager ensureLayoutForRange:[[NSTextRange alloc]
initWithLocation:start endLocation:end]];
}
}];
}
- (void)id_invalidateLayoutForRange:(UITextRange *)range {
[self id_enumerateLayoutsForTextRange:range
stage:@"invalidateLayoutForRange:"
usingBlock:^(IDBlockLayout *layout, NSRange localRange) {
id<NSTextLocation> start = [layout locationForLocalOffset:localRange.location];
id<NSTextLocation> end = [layout locationForLocalOffset:NSMaxRange(localRange)];
if (start != nil && end != nil) {
[layout.layoutManager invalidateLayoutForRange:[[NSTextRange alloc]
initWithLocation:start endLocation:end]];
}
}];
}
- (nullable id)id_annotationAttribute:(id)attribute
atPosition:(UITextPosition *)position {
IDRichTextPosition *valid = [self id_validPosition:position
stage:TGPN_annotationAttribute_atPosition_()];
return [self.annotationStore annotationAttribute:attribute
atCanonicalOffset:[self
id_canonicalOffsetForPosition:valid]];
}
- (NSAttributedString *)id_attributedTextInRange:(UITextRange *)range {
NSRange canonical = [self id_projectedCanonicalRangeForTextRange:range
clipped:NO
stage:@"attributedTextInRange:"];
return [self.textStorageAdapter attributedSubstringFromRange:canonical];
}
- (NSAttributedString *)id_annotatedSubstringForRange:(UITextRange *)range {
NSRange canonical = [self id_projectedCanonicalRangeForTextRange:range
clipped:YES
stage:TGPN_annotatedSubstringForRange_()];
return [self.annotationStore annotatedSubstringForCanonicalRange:canonical];
}
- (void)id_enumerateTemporaryLayoutsForCanonicalRange:(NSRange)canonical
stage:(NSString *)stage
usingBlock:(void (^)(IDBlockLayout *, NSTextRange *))block {
NSUInteger documentLength = self.document.canonicalLength;
if (!IDRichRangeIsValid(canonical, documentLength)) {
IDRichRaise(stage, @"range is outside the document");
}
if (canonical.length == 0) return;
NSUInteger cursor = 0;
for (IDBlock *documentBlock in self.document.blocks) {
NSRange blockRange = NSMakeRange(cursor, documentBlock.attributedText.length);
NSRange intersection = NSIntersectionRange(canonical, blockRange);
if (intersection.length > 0) {
IDBlockLayout *layout = [self.id_blockStack
layoutForBlockIdentifier:documentBlock.identifier];
if (layout == nil) {
IDRichRaise(stage, @"participating block layout is missing");
}
NSRange local = NSMakeRange(intersection.location - cursor,
intersection.length);
id<NSTextLocation> start = [layout locationForLocalOffset:local.location];
id<NSTextLocation> end = [layout locationForLocalOffset:NSMaxRange(local)];
if (start == nil || end == nil) {
IDRichRaise(stage, @"participating block range has no TextKit 2 locations");
}
block(layout, [[NSTextRange alloc] initWithLocation:start endLocation:end]);
}
cursor = NSMaxRange(blockRange) + 1;
}
}
- (NSRange)id_temporaryCanonicalRangeForTextRange:(UITextRange *)range
stage:(NSString *)stage {
return [self id_projectedCanonicalRangeForTextRange:range
clipped:YES
stage:stage];
}
- (void)id_addTemporaryAttribute:(id)attribute
value:(id)value
canonicalRange:(NSRange)canonical
stage:(NSString *)stage {
[self id_enumerateTemporaryLayoutsForCanonicalRange:canonical
stage:stage
usingBlock:^(IDBlockLayout *layout,
NSTextRange *localRange) {
IDPrivateRichAddTemporaryAttribute(layout.layoutManager,
attribute,
value,
localRange,
stage);
}];
}
- (void)id_removeTemporaryAttribute:(id)attribute
canonicalRange:(NSRange)canonical
stage:(NSString *)stage {
[self id_enumerateTemporaryLayoutsForCanonicalRange:canonical
stage:stage
usingBlock:^(IDBlockLayout *layout,
NSTextRange *localRange) {
IDPrivateRichRemoveTemporaryAttribute(layout.layoutManager,
attribute,
localRange,
stage);
}];
}
- (void)id_addAnnotationAttribute:(id)attribute
value:(id)value
forRange:(UITextRange *)range {
NSString *stage = TGPN_addAnnotationAttribute_value_forRange_();
NSRange canonical = [self id_temporaryCanonicalRangeForTextRange:range stage:stage];
[self.annotationStore addAttribute:attribute value:value canonicalRange:canonical];
[self id_addTemporaryAttribute:attribute
value:value
canonicalRange:canonical
stage:stage];
[self id_invalidateTemporaryAttributesInRange:range];
}
- (void)id_removeAnnotationAttribute:(id)attribute
forRange:(UITextRange *)range {
NSString *stage = TGPN_removeAnnotationAttribute_forRange_();
NSRange canonical = [self id_temporaryCanonicalRangeForTextRange:range stage:stage];
[self.annotationStore removeAttribute:attribute canonicalRange:canonical];
[self id_removeTemporaryAttribute:attribute canonicalRange:canonical stage:stage];
[self id_invalidateTemporaryAttributesInRange:range];
}
- (void)id_addRenderingAttributes:(NSDictionary *)attributes
forRange:(UITextRange *)range {
if (![attributes isKindOfClass:NSDictionary.class]) {
IDRichRaise(TGPN_addRenderingAttributes_forRange_(), @"attributes are not a dictionary");
}
NSString *stage = TGPN_addRenderingAttributes_forRange_();
NSRange canonical = [self id_temporaryCanonicalRangeForTextRange:range stage:stage];
[attributes enumerateKeysAndObjectsUsingBlock:^(id attribute, id value, BOOL *stop) {
[self.annotationStore addAttribute:attribute value:value canonicalRange:canonical];
[self id_addTemporaryAttribute:attribute
value:value
canonicalRange:canonical
stage:stage];
}];
[self id_invalidateTemporaryAttributesInRange:range];
}
- (void)id_removeRenderingAttributes:(id<NSFastEnumeration>)attributes
forRange:(UITextRange *)range {
if (attributes == nil) {
IDRichRaise(TGPN_removeRenderingAttributes_forRange_(), @"attribute collection is nil");
}
NSString *stage = TGPN_removeRenderingAttributes_forRange_();
NSRange canonical = [self id_temporaryCanonicalRangeForTextRange:range stage:stage];
for (id attribute in attributes) {
[self.annotationStore removeAttribute:attribute canonicalRange:canonical];
[self id_removeTemporaryAttribute:attribute canonicalRange:canonical stage:stage];
}
[self id_invalidateTemporaryAttributesInRange:range];
}
- (void)id_invalidateTemporaryAttributesInRange:(UITextRange *)range {
NSString *stage = TGPN__invalidateTemporaryAttributesInRange_();
NSRange canonical = [self id_temporaryCanonicalRangeForTextRange:range stage:stage];
[self id_enumerateTemporaryLayoutsForCanonicalRange:canonical
stage:stage
usingBlock:^(IDBlockLayout *layout,
NSTextRange *localRange) {
IDPrivateRichInvalidateTemporaryAttributes(layout.canvasView,
localRange,
stage);
}];
}
- (void)id_reapplyTemporaryAttributesForDocumentChange:(IDBlockDocumentChange *)change {
if (change == nil || change.affectedBlockIdentifiers.count == 0) return;
NSSet<NSUUID *> *affectedIdentifiers =
[NSSet setWithArray:change.affectedBlockIdentifiers];
NSString *stage = @"Reapply temporary attributes after document change";
NSUInteger cursor = 0;
NSArray<IDBlock *> *blocks = self.document.blocks;
for (NSUInteger index = 0; index < blocks.count; index += 1) {
IDBlock *block = blocks[index];
NSRange blockRange = NSMakeRange(cursor, block.attributedText.length);
if ([affectedIdentifiers containsObject:block.identifier] && blockRange.length > 0) {
IDBlockLayout *layout = [self.id_blockStack
layoutForBlockIdentifier:block.identifier];
if (layout == nil) {
IDRichRaise(stage, @"affected block layout is missing");
}
[self.annotationStore enumerateAttributesInCanonicalRange:blockRange
usingBlock:^(id<NSCopying> attribute,
id value,
NSRange canonicalRange) {
NSRange localRange = NSMakeRange(
canonicalRange.location - blockRange.location,
canonicalRange.length);
id<NSTextLocation> start =
[layout locationForLocalOffset:localRange.location];
id<NSTextLocation> end =
[layout locationForLocalOffset:NSMaxRange(localRange)];
if (start == nil || end == nil) {
IDRichRaise(stage, @"affected temporary range has no TextKit 2 locations");
}
NSTextRange *textRange = [[NSTextRange alloc]
initWithLocation:start endLocation:end];
IDPrivateRichAddTemporaryAttribute(layout.layoutManager,
attribute,
value,
textRange,
stage);
IDPrivateRichInvalidateTemporaryAttributes(layout.canvasView,
textRange,
stage);
}];
}
cursor = NSMaxRange(blockRange) + (index + 1 < blocks.count ? 1 : 0);
}
}
- (void)id_invalidateDisplayForCharacterRange:(NSRange)range {
NSUInteger documentLength = self.document.canonicalLength;
if (!IDRichRangeIsValid(range, documentLength)) {
IDRichRaise(@"invalidateDisplayForCharacterRange:",
@"range is outside the document");
}
NSArray<IDBlock *> *blocks = self.document.blocks;
NSArray<IDBlockLayout *> *layouts = self.id_blockStack.blockLayouts;
if (range.length == 0) {
IDCanonicalTextProjection *projection =
[[IDCanonicalTextProjection alloc] initWithDocument:self.document];
IDCanonicalLocation *location = [projection locationForCanonicalOffset:range.location];
for (IDBlockLayout *layout in layouts) {
if ([layout.blockIdentifier isEqual:location.blockIdentifier]) {
[layout.canvasView setNeedsDisplay];
return;
}
}
return;
}
NSMutableSet<NSUUID *> *invalidated = [NSMutableSet set];
NSUInteger cursor = 0;
for (NSUInteger index = 0; index < blocks.count; index += 1) {
IDBlock *block = blocks[index];
NSRange textRange = NSMakeRange(cursor, block.attributedText.length);
if (NSIntersectionRange(range, textRange).length > 0) {
[invalidated addObject:block.identifier];
}
if (index + 1 < blocks.count) {
NSRange separatorRange = NSMakeRange(NSMaxRange(textRange), 1);
if (NSIntersectionRange(range, separatorRange).length > 0) {
[invalidated addObject:block.identifier];
[invalidated addObject:blocks[index + 1].identifier];
}
cursor = NSMaxRange(separatorRange);
}
}
for (IDBlockLayout *layout in layouts) {
if ([invalidated containsObject:layout.blockIdentifier]) {
[layout.canvasView setNeedsDisplay];
}
}
}
/// Whether a caret that found no line beyond it in a vertical direction sits on the document's
/// edge line — the line holding `edgeOffset` (the document's end going down, its start going up) —
/// rather than having failed for another reason (a layout not ready, a revision that moved), which
/// must keep answering nil.
- (BOOL)id_canonicalOffset:(NSUInteger)offset
affinity:(IDBlockTextAffinity)affinity
isOnTheLineOfOffset:(NSUInteger)edgeOffset
affinity:(IDBlockTextAffinity)edgeAffinity
downward:(BOOL)downward {
if (offset == edgeOffset) {
return YES;
}
IDDocumentCaretGeometry *caret = [self.id_documentLayoutCoordinator
caretGeometryForCanonicalOffset:offset affinity:affinity];
IDDocumentCaretGeometry *edge = [self.id_documentLayoutCoordinator
caretGeometryForCanonicalOffset:edgeOffset affinity:edgeAffinity];
if (caret == nil || edge == nil) {
return NO;
}
CGRect caretRect = caret.documentRect;
CGRect edgeRect = edge.documentRect;
if (!isfinite(CGRectGetMidY(caretRect)) || !isfinite(CGRectGetMidY(edgeRect))) {
return NO;
}
return downward
? CGRectGetMidY(caretRect) >= CGRectGetMinY(edgeRect)
: CGRectGetMidY(caretRect) <= CGRectGetMaxY(edgeRect);
}
@end
#endif