Skip to content

Commit

Permalink
[macOS] Fix text input plugin editable transform (flutter#35979)
Browse files Browse the repository at this point in the history
  • Loading branch information
LongCatIsLooong authored Sep 9, 2022
1 parent 70e0ea6 commit c2cd3b6
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1511,11 +1511,49 @@ - (void)setEditableTransform:(NSArray*)matrix {
_cachedFirstRect = kInvalidFirstRect;
}

// Returns the bounding CGRect of the transformed incomingRect, in the view's
// coordinates.
- (CGRect)localRectFromFrameworkTransform:(CGRect)incomingRect {
CGPoint points[] = {
incomingRect.origin,
CGPointMake(incomingRect.origin.x, incomingRect.origin.y + incomingRect.size.height),
CGPointMake(incomingRect.origin.x + incomingRect.size.width, incomingRect.origin.y),
CGPointMake(incomingRect.origin.x + incomingRect.size.width,
incomingRect.origin.y + incomingRect.size.height)};

CGPoint origin = CGPointMake(CGFLOAT_MAX, CGFLOAT_MAX);
CGPoint farthest = CGPointMake(-CGFLOAT_MAX, -CGFLOAT_MAX);

for (int i = 0; i < 4; i++) {
const CGPoint point = points[i];

CGFloat x = _editableTransform.m11 * point.x + _editableTransform.m21 * point.y +
_editableTransform.m41;
CGFloat y = _editableTransform.m12 * point.x + _editableTransform.m22 * point.y +
_editableTransform.m42;

const CGFloat w = _editableTransform.m14 * point.x + _editableTransform.m24 * point.y +
_editableTransform.m44;

if (w == 0.0) {
return kInvalidFirstRect;
} else if (w != 1.0) {
x /= w;
y /= w;
}

origin.x = MIN(origin.x, x);
origin.y = MIN(origin.y, y);
farthest.x = MAX(farthest.x, x);
farthest.y = MAX(farthest.y, y);
}
return CGRectMake(origin.x, origin.y, farthest.x - origin.x, farthest.y - origin.y);
}

// The following methods are required to support force-touch cursor positioning
// and to position the
// candidates view for multi-stage input methods (e.g., Japanese) when using a
// physical keyboard.

- (CGRect)firstRectForRange:(UITextRange*)range {
NSAssert([range.start isKindOfClass:[FlutterTextPosition class]],
@"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]);
Expand All @@ -1524,22 +1562,21 @@ - (CGRect)firstRectForRange:(UITextRange*)range {
NSUInteger start = ((FlutterTextPosition*)range.start).index;
NSUInteger end = ((FlutterTextPosition*)range.end).index;
if (_markedTextRange != nil) {
// The candidates view can't be shown if _editableTransform is not affine,
// or markedRect is invalid.
if (CGRectEqualToRect(kInvalidFirstRect, _markedRect) ||
!CATransform3DIsAffine(_editableTransform)) {
// The candidates view can't be shown if the framework has not sent the
// first caret rect.
if (CGRectEqualToRect(kInvalidFirstRect, _markedRect)) {
return kInvalidFirstRect;
}

if (CGRectEqualToRect(_cachedFirstRect, kInvalidFirstRect)) {
// If the width returned is too small, that means the framework sent us
// the caret rect instead of the marked text rect. Expand it to 0.1 so
// the IME candidates view show up.
double nonZeroWidth = MAX(_markedRect.size.width, 0.1);
// the caret rect instead of the marked text rect. Expand it to 0.2 so
// the IME candidates view would show up.
CGRect rect = _markedRect;
rect.size = CGSizeMake(nonZeroWidth, rect.size.height);
_cachedFirstRect =
CGRectApplyAffineTransform(rect, CATransform3DGetAffineTransform(_editableTransform));
if (CGRectIsEmpty(rect)) {
rect = CGRectInset(rect, -0.1, 0);
}
_cachedFirstRect = [self localRectFromFrameworkTransform:rect];
}

return _cachedFirstRect;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,13 @@ - (void)testUpdateFirstRectForRange {
// yOffset = 200.
NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];
NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ];
// This matrix can be generated by running this dart code snippet:
// Matrix4.identity()..scale(3.0)..rotateZ(math.pi/2)..translate(1.0, 2.0,
// 3.0);
NSArray* affineMatrix = @[
@(0.0), @(3.0), @(0.0), @(0.0), @(-3.0), @(0.0), @(0.0), @(0.0), @(0.0), @(0.0), @(3.0), @(0.0),
@(-6.0), @(3.0), @(9.0), @(1.0)
];

// Invalid since we don't have the transform or the rect.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
Expand Down Expand Up @@ -1200,6 +1207,12 @@ - (void)testUpdateFirstRectForRange {
// Invalid marked rect is invalid.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));

// Use a 3d affine transform that does 3d-scaling, z-index rotating and 3d translation.
[inputView setEditableTransform:affineMatrix];
[inputView setMarkedRect:testRect];
XCTAssertTrue(
CGRectEqualToRect(CGRectMake(-306, 3, 300, 300), [inputView firstRectForRange:range]));
}

- (void)testFirstRectForRangeReturnsCorrectSelectionRect {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -824,24 +824,57 @@ - (NSAttributedString*)attributedSubstringForProposedRange:(NSRange)range
return @[];
}

- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange {
if (!self.flutterViewController.viewLoaded) {
return CGRectZero;
// Returns the bounding CGRect of the transformed incomingRect, in screen
// coordinates.
- (CGRect)screenRectFromFrameworkTransform:(CGRect)incomingRect {
CGPoint points[] = {
incomingRect.origin,
CGPointMake(incomingRect.origin.x, incomingRect.origin.y + incomingRect.size.height),
CGPointMake(incomingRect.origin.x + incomingRect.size.width, incomingRect.origin.y),
CGPointMake(incomingRect.origin.x + incomingRect.size.width,
incomingRect.origin.y + incomingRect.size.height)};

CGPoint origin = CGPointMake(CGFLOAT_MAX, CGFLOAT_MAX);
CGPoint farthest = CGPointMake(-CGFLOAT_MAX, -CGFLOAT_MAX);

for (int i = 0; i < 4; i++) {
const CGPoint point = points[i];

CGFloat x = _editableTransform.m11 * point.x + _editableTransform.m21 * point.y +
_editableTransform.m41;
CGFloat y = _editableTransform.m12 * point.x + _editableTransform.m22 * point.y +
_editableTransform.m42;

const CGFloat w = _editableTransform.m14 * point.x + _editableTransform.m24 * point.y +
_editableTransform.m44;

if (w == 0.0) {
return CGRectZero;
} else if (w != 1.0) {
x /= w;
y /= w;
}

origin.x = MIN(origin.x, x);
origin.y = MIN(origin.y, y);
farthest.x = MAX(farthest.x, x);
farthest.y = MAX(farthest.y, y);
}
// This only determines position of caret instead of any arbitrary range, but it's enough
// to properly position accent selection popup
if (CATransform3DIsAffine(_editableTransform) && !CGRectEqualToRect(_caretRect, CGRectNull)) {
CGRect rect =
CGRectApplyAffineTransform(_caretRect, CATransform3DGetAffineTransform(_editableTransform));

// convert to window coordinates
rect = [self.flutterViewController.flutterView convertRect:rect toView:nil];
const NSView* fromView = self.flutterViewController.flutterView;
const CGRect rectInWindow = [fromView
convertRect:CGRectMake(origin.x, origin.y, farthest.x - origin.x, farthest.y - origin.y)
toView:nil];
NSWindow* window = fromView.window;
return window ? [window convertRectToScreen:rectInWindow] : rectInWindow;
}

// convert to screen coordinates
return [self.flutterViewController.flutterView.window convertRectToScreen:rect];
} else {
return CGRectZero;
}
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange {
// This only determines position of caret instead of any arbitrary range, but it's enough
// to properly position accent selection popup
return !self.flutterViewController.viewLoaded || CGRectEqualToRect(_caretRect, CGRectNull)
? CGRectZero
: [self screenRectFromFrameworkTransform:_caretRect];
}

- (NSUInteger)characterIndexForPoint:(NSPoint)point {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,6 @@ - (bool)testFirstRectForCharacterRange {
}];

NSRect rect = [plugin firstRectForCharacterRange:NSMakeRange(0, 0) actualRange:nullptr];

@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[windowMock convertRectToScreen:NSMakeRect(28, 10, 2, 19)]);
Expand All @@ -449,6 +448,148 @@ - (bool)testFirstRectForCharacterRange {
return NSEqualRects(rect, NSMakeRect(38, 20, 2, 19));
}

- (bool)testFirstRectForCharacterRangeAtInfinity {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* controllerMock = OCMClassMock([FlutterViewController class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[controllerMock engine])
.andReturn(engineMock);

id viewMock = OCMClassMock([NSView class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock bounds])
.andReturn(NSMakeRect(0, 0, 200, 200));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
controllerMock.viewLoaded)
.andReturn(YES);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[controllerMock flutterView])
.andReturn(viewMock);

id windowMock = OCMClassMock([NSWindow class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock window])
.andReturn(windowMock);

FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:controllerMock];

FlutterMethodCall* call = [FlutterMethodCall
methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
arguments:@{
@"height" : @(20.0),
// Projects all points to infinity.
@"transform" : @[
@(1.0), @(0.0), @(0.0), @(0.0), @(0.0), @(1.0), @(0.0), @(0.0), @(0.0),
@(0.0), @(1.0), @(0.0), @(20.0), @(10.0), @(0.0), @(0.0)
],
@"width" : @(400.0),
}];

[plugin handleMethodCall:call
result:^(id){
}];

call = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setCaretRect"
arguments:@{
@"height" : @(19.0),
@"width" : @(2.0),
@"x" : @(8.0),
@"y" : @(0.0),
}];

[plugin handleMethodCall:call
result:^(id){
}];

NSRect rect = [plugin firstRectForCharacterRange:NSMakeRange(0, 0) actualRange:nullptr];
return NSEqualRects(rect, CGRectZero);
}

- (bool)testFirstRectForCharacterRangeWithEsotericAffineTransform {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
FlutterViewController* controllerMock = OCMClassMock([FlutterViewController class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[controllerMock engine])
.andReturn(engineMock);

id viewMock = OCMClassMock([NSView class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock bounds])
.andReturn(NSMakeRect(0, 0, 200, 200));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
controllerMock.viewLoaded)
.andReturn(YES);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[controllerMock flutterView])
.andReturn(viewMock);

id windowMock = OCMClassMock([NSWindow class]);
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock window])
.andReturn(windowMock);

OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[viewMock convertRect:NSMakeRect(-18, 6, 3, 3) toView:nil])
.andReturn(NSMakeRect(-18, 6, 3, 3));

OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[windowMock convertRectToScreen:NSMakeRect(-18, 6, 3, 3)])
.andReturn(NSMakeRect(-18, 6, 3, 3));

FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:controllerMock];

FlutterMethodCall* call = [FlutterMethodCall
methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
arguments:@{
@"height" : @(20.0),
// This matrix can be generated by running this dart code snippet:
// Matrix4.identity()..scale(3.0)..rotateZ(math.pi/2)..translate(1.0, 2.0,
// 3.0);
@"transform" : @[
@(0.0), @(3.0), @(0.0), @(0.0), @(-3.0), @(0.0), @(0.0), @(0.0), @(0.0),
@(0.0), @(3.0), @(0.0), @(-6.0), @(3.0), @(9.0), @(1.0)
],
@"width" : @(400.0),
}];

[plugin handleMethodCall:call
result:^(id){
}];

call = [FlutterMethodCall methodCallWithMethodName:@"TextInput.setCaretRect"
arguments:@{
@"height" : @(1.0),
@"width" : @(1.0),
@"x" : @(1.0),
@"y" : @(3.0),
}];

[plugin handleMethodCall:call
result:^(id){
}];

NSRect rect = [plugin firstRectForCharacterRange:NSMakeRange(0, 0) actualRange:nullptr];

@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[windowMock convertRectToScreen:NSMakeRect(-18, 6, 3, 3)]);
} @catch (...) {
return false;
}

return NSEqualRects(rect, NSMakeRect(-18, 6, 3, 3));
}

- (bool)testSetEditingStateWithTextEditingDelta {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
Expand Down Expand Up @@ -1226,6 +1367,15 @@ - (bool)testSelectorsAreForwardedToFramework {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRange]);
}

TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRangeAtInfinity) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testFirstRectForCharacterRangeAtInfinity]);
}

TEST(FlutterTextInputPluginTest, TestFirstRectForCharacterRangeWithEsotericAffineTransform) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc]
testFirstRectForCharacterRangeWithEsotericAffineTransform]);
}

TEST(FlutterTextInputPluginTest, TestSetEditingStateWithTextEditingDelta) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testSetEditingStateWithTextEditingDelta]);
}
Expand Down

0 comments on commit c2cd3b6

Please sign in to comment.