Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qiostextinputoverlay.mm
Go to the documentation of this file.
1// Copyright (C) 2017 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3// Qt-Security score:significant reason:default
4
5#import <UIKit/UIGestureRecognizerSubclass.h>
6#import <UIKit/UITextView.h>
7
8#include <QtGui/QGuiApplication>
9#include <QtGui/QInputMethod>
10#include <QtGui/QStyleHints>
11
12#include <QtGui/private/qinputmethod_p.h>
13#include <QtCore/private/qobject_p.h>
14#include <QtCore/private/qcore_mac_p.h>
15
16#include "qiosglobal.h"
18#include "qioswindow.h"
19#include "quiview.h"
20
21#include <utility> // for std::pair
22
23typedef std::pair<int, int> SelectionPair;
24typedef void (^Block)(void);
25
26static const CGFloat kKnobWidth = 10;
27
29{
30 return static_cast<QInputMethodPrivate *>(QObjectPrivate::get(QGuiApplication::inputMethod()))->platformInputContext();
31}
32
34{
35 if (!QGuiApplication::focusObject())
36 return {};
37
38 QInputMethodQueryEvent query(Qt::ImAnchorPosition | Qt::ImCursorPosition);
39 QGuiApplication::sendEvent(QGuiApplication::focusObject(), &query);
40 int anchorPos = query.value(Qt::ImAnchorPosition).toInt();
41 int cursorPos = query.value(Qt::ImCursorPosition).toInt();
42 return {anchorPos, cursorPos};
43}
44
45static bool hasSelection()
46{
47 SelectionPair selection = querySelection();
48 return selection.first != selection.second;
49}
50
52{
53 [CATransaction begin];
54 [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
55 block();
56 [CATransaction commit];
57}
58
59// -------------------------------------------------------------------------
60/**
61 QIOSEditMenu is just a wrapper class around UIMenuController to
62 ease showing and hiding it correctly.
63 */
64@interface QIOSEditMenu : NSObject
65@property (nonatomic, assign) BOOL visible;
66@property (nonatomic, readonly) BOOL isHiding;
67@property (nonatomic, readonly) BOOL shownByUs;
68@property (nonatomic, assign) BOOL reshowAfterHidden;
69@end
70
71@implementation QIOSEditMenu
72
73- (instancetype)init
74{
75 if (self = [super init]) {
76 NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
77
78 [center addObserverForName:UIMenuControllerWillHideMenuNotification
79 object:nil queue:nil usingBlock:^(NSNotification *) {
80 _isHiding = YES;
81 }];
82
83 [center addObserverForName:UIMenuControllerDidHideMenuNotification
84 object:nil queue:nil usingBlock:^(NSNotification *) {
85 _isHiding = NO;
86 _shownByUs = NO;
87 if (self.reshowAfterHidden) {
88 // To not abort an ongoing hide transition when showing the menu, you can set
89 // reshowAfterHidden to wait until the transition finishes before reshowing it.
90 self.reshowAfterHidden = NO;
91 dispatch_async(dispatch_get_main_queue (), ^{ self.visible = YES; });
92 }
93 }];
94 [center addObserverForName:UIKeyboardDidHideNotification object:nil queue:nil
95 usingBlock:^(NSNotification *) {
96 self.visible = NO;
97 }];
98
99 }
100
101 return self;
102}
103
104- (void)dealloc
105{
106 [[NSNotificationCenter defaultCenter] removeObserver:self name:nil object:nil];
107 [super dealloc];
108}
109
110- (BOOL)visible
111{
112 return [UIMenuController sharedMenuController].menuVisible;
113}
114
115- (void)setVisible:(BOOL)visible
116{
117 if (visible == self.visible)
118 return;
119
120 if (visible) {
121 // UIMenuController is a singleton that can be shown (and hidden) from anywhere.
122 // Try to keep track of whether or not is was shown by us (the gesture recognizers
123 // in this file) to avoid closing it if it was opened from elsewhere.
124 _shownByUs = YES;
125 // Note that the contents of the edit menu is decided by
126 // first responder, which is normally QIOSTextResponder.
127 QRectF cr = QPlatformInputContext::cursorRectangle();
128 QRectF ar = QPlatformInputContext::anchorRectangle();
129
130 CGRect targetRect = cr.united(ar).toCGRect();
131 UIView *focusView = reinterpret_cast<UIView *>(qApp->focusWindow()->winId());
132 [[UIMenuController sharedMenuController] setTargetRect:targetRect inView:focusView];
133 [[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
134 } else {
135 [[UIMenuController sharedMenuController] setMenuVisible:NO animated:YES];
136 }
137}
138
139@end
140
141void showEditMenu(UIView *focusView, QPoint touchPos)
142{
143 const bool mouseTriggered = false;
144 const Qt::KeyboardModifiers keyboardModifiers = Qt::NoModifier;
145 QWindow *qtWindow = quiview_cast(focusView).platformWindow->window();
146 const auto globalTouchPos = qtWindow->mapToGlobal(touchPos);
147 const bool contextMenuEventAccepted = QWindowSystemInterface::handleContextMenuEvent<
148 QWindowSystemInterface::SynchronousDelivery>(qtWindow, mouseTriggered, touchPos,
149 globalTouchPos, keyboardModifiers);
150
151 if (!contextMenuEventAccepted) {
152 // Fall back to show the default platform menu, like we did
153 // before we started sending context menu events. This is
154 // to be backwards compatible with Widgets and Quick items.
155 QIOSTextInputOverlay::s_editMenu.visible = YES;
156 }
157}
158
159// -------------------------------------------------------------------------
160
161@interface QIOSLoupeLayer : CALayer
162@property (nonatomic, retain) UIView *targetView;
163@property (nonatomic, assign) CGPoint focalPoint;
164@property (nonatomic, assign) BOOL visible;
165@end
166
167@implementation QIOSLoupeLayer {
168 UIView *_snapshotView;
169 BOOL _pendingSnapshotUpdate;
170 UIView *_loupeImageView;
171 CALayer *_containerLayer;
172 CGFloat _loupeOffset;
173 QTimer _updateTimer;
174}
175
176- (instancetype)initWithSize:(CGSize)size cornerRadius:(CGFloat)cornerRadius bottomOffset:(CGFloat)bottomOffset
177{
178 if (self = [super init]) {
179 _loupeOffset = bottomOffset + (size.height / 2);
180 _snapshotView = nil;
181 _pendingSnapshotUpdate = YES;
182 _updateTimer.setInterval(100);
183 _updateTimer.setSingleShot(true);
184 QObject::connect(&_updateTimer, &QTimer::timeout, [self](){ [self updateSnapshot]; });
185
186 // Create own geometry and outer shadow
187 self.frame = CGRectMake(0, 0, size.width, size.height);
188 self.cornerRadius = cornerRadius;
189 self.shadowColor = [[UIColor grayColor] CGColor];
190 self.shadowOffset = CGSizeMake(0, 1);
191 self.shadowRadius = 2.0;
192 self.shadowOpacity = 0.75;
193 self.transform = CATransform3DMakeScale(0, 0, 0);
194
195 // Create container view for the snapshots
196 _containerLayer = [[CALayer new] autorelease];
197 _containerLayer.frame = self.bounds;
198 _containerLayer.cornerRadius = cornerRadius;
199 _containerLayer.masksToBounds = YES;
200 [self addSublayer:_containerLayer];
201
202 // Create inner loupe shadow
203 const CGFloat inset = 30;
204 CALayer *topShadeLayer = [[CALayer new] autorelease];
205 topShadeLayer.frame = CGRectOffset(CGRectInset(self.bounds, -inset, -inset), 0, inset / 2);
206 topShadeLayer.borderWidth = inset / 2;
207 topShadeLayer.cornerRadius = cornerRadius;
208 topShadeLayer.borderColor = [[UIColor blackColor] CGColor];
209 topShadeLayer.shadowColor = [[UIColor blackColor] CGColor];
210 topShadeLayer.shadowOffset = CGSizeMake(0, 0);
211 topShadeLayer.shadowRadius = 15.0;
212 topShadeLayer.shadowOpacity = 0.6;
213 // Keep the shadow inside the loupe
214 CALayer *mask = [[CALayer new] autorelease];
215 mask.frame = CGRectOffset(self.bounds, inset, inset / 2);
216 mask.backgroundColor = [[UIColor blackColor] CGColor];
217 mask.cornerRadius = cornerRadius;
218 topShadeLayer.mask = mask;
219 [self addSublayer:topShadeLayer];
220
221 // Create border around the loupe. We need to do this in a separate
222 // layer (as opposed to on self) to not draw the border on top of
223 // overlapping external children (arrow).
224 CALayer *borderLayer = [[CALayer new] autorelease];
225 borderLayer.frame = self.bounds;
226 borderLayer.borderWidth = 0.75;
227 borderLayer.cornerRadius = cornerRadius;
228 borderLayer.borderColor = [[UIColor lightGrayColor] CGColor];
229 [self addSublayer:borderLayer];
230 }
231
232 return self;
233}
234
235- (void)dealloc
236{
237 _targetView = nil;
238 [super dealloc];
239}
240
241- (void)setVisible:(BOOL)visible
242{
243 if (_visible == visible)
244 return;
245
246 _visible = visible;
247
248 dispatch_async(dispatch_get_main_queue (), ^{
249 // Setting transform later, since CA will not perform an animation if
250 // changing values directly after init, and if the scale ends up empty.
251 self.transform = _visible ? CATransform3DMakeScale(1, 1, 1) : CATransform3DMakeScale(0.0, 0.0, 1);
252 });
253}
254
255- (void)updateSnapshot
256{
257 _pendingSnapshotUpdate = YES;
258 [self setNeedsDisplay];
259}
260
261- (void)setFocalPoint:(CGPoint)point
262{
263 _focalPoint = point;
264 [self updateSnapshot];
265
266 // Schedule a delayed update as well to ensure that we end up with a correct
267 // snapshot of the cursor, since QQuickRenderThread lags a bit behind
268 _updateTimer.start();
269}
270
271- (void)display
272{
273 // Take a snapshow of the target view, magnify the area around the focal
274 // point, and add the snapshow layer as a child of the container layer
275 // to make it look like a loupe. Then place this layer at the position of
276 // the focal point with the requested offset.
277 executeBlockWithoutAnimation(^{
278 if (_pendingSnapshotUpdate) {
279 UIView *newSnapshot = [_targetView snapshotViewAfterScreenUpdates:NO];
280 [_snapshotView.layer removeFromSuperlayer];
281 [_snapshotView release];
282 _snapshotView = [newSnapshot retain];
283 [_containerLayer addSublayer:_snapshotView.layer];
284 _pendingSnapshotUpdate = NO;
285 }
286
287 self.position = CGPointMake(_focalPoint.x, _focalPoint.y - _loupeOffset);
288
289 const CGFloat loupeScale = 1.5;
290 CGFloat x = -(_focalPoint.x * loupeScale) + self.frame.size.width / 2;
291 CGFloat y = -(_focalPoint.y * loupeScale) + self.frame.size.height / 2;
292 CGFloat w = _targetView.frame.size.width * loupeScale;
293 CGFloat h = _targetView.frame.size.height * loupeScale;
294 _snapshotView.layer.frame = CGRectMake(x, y, w, h);
295 });
296}
297
298@end
299
300// -------------------------------------------------------------------------
301
302@interface QIOSHandleLayer : CALayer <CAAnimationDelegate>
303@property (nonatomic, assign) CGRect cursorRectangle;
304@property (nonatomic, assign) CGFloat handleScale;
305@property (nonatomic, assign) BOOL visible;
306@property (nonatomic, copy) Block onAnimationDidStop;
307@end
308
309@implementation QIOSHandleLayer {
310 CALayer *_handleCursorLayer;
311 CALayer *_handleKnobLayer;
312 Qt::Edge _selectionEdge;
313}
314
315@dynamic handleScale;
316
317- (instancetype)initWithKnobAtEdge:(Qt::Edge)selectionEdge
318{
319 if (self = [super init]) {
320 CGColorRef bgColor = [UIColor colorWithRed:0.1 green:0.4 blue:0.9 alpha:1].CGColor;
321 _selectionEdge = selectionEdge;
322 self.handleScale = 0;
323
324 _handleCursorLayer = [[CALayer new] autorelease];
325 _handleCursorLayer.masksToBounds = YES;
326 _handleCursorLayer.backgroundColor = bgColor;
327 [self addSublayer:_handleCursorLayer];
328
329 _handleKnobLayer = [[CALayer new] autorelease];
330 _handleKnobLayer.masksToBounds = YES;
331 _handleKnobLayer.backgroundColor = bgColor;
332 _handleKnobLayer.cornerRadius = kKnobWidth / 2;
333 [self addSublayer:_handleKnobLayer];
334 }
335 return self;
336}
337
338+ (BOOL)needsDisplayForKey:(NSString *)key
339{
340 if ([key isEqualToString:@"handleScale"])
341 return YES;
342 return [super needsDisplayForKey:key];
343}
344
345- (id<CAAction>)actionForKey:(NSString *)key
346{
347 if ([key isEqualToString:@"handleScale"]) {
348 if (_visible) {
349 // The handle should "bounce" in when becoming visible
350 CAKeyframeAnimation * animation = [CAKeyframeAnimation animationWithKeyPath:key];
351 [animation setDuration:0.5];
352 animation.values = @[@(0.0f), @(1.3f), @(1.3f), @(1.0f)];
353 animation.keyTimes = @[@(0.0f), @(0.3f), @(0.9f), @(1.0f)];
354 return animation;
355 } else {
356 CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
357 [animation setDelegate:self];
358 animation.fromValue = [self valueForKey:key];
359 [animation setDuration:0.2];
360 return animation;
361 }
362 }
363 return [super actionForKey:key];
364}
365
366- (void)animationDidStop:(CAAnimation *)animation finished:(BOOL)flag
367{
368 Q_UNUSED(animation);
369 Q_UNUSED(flag);
370 if (self.onAnimationDidStop)
371 self.onAnimationDidStop();
372}
373
374- (void)setVisible:(BOOL)visible
375{
376 if (visible == _visible)
377 return;
378
379 _visible = visible;
380
381 self.handleScale = visible ? 1 : 0;
382}
383
384- (void)setCursorRectangle:(CGRect)cursorRect
385{
386 if (CGRectEqualToRect(_cursorRectangle, cursorRect))
387 return;
388
389 _cursorRectangle = cursorRect;
390
391 executeBlockWithoutAnimation(^{
392 [self setNeedsDisplay];
393 [self displayIfNeeded];
394 });
395}
396
397- (void)display
398{
399 CGFloat cursorWidth = 2;
400 CGPoint origin = _cursorRectangle.origin;
401 CGSize size = _cursorRectangle.size;
402 CGFloat scale = ((QIOSHandleLayer *)[self presentationLayer]).handleScale;
403 CGFloat edgeAdjustment = (_selectionEdge == Qt::LeftEdge) ? 0.5 - cursorWidth : -0.5;
404
405 CGFloat cursorX = origin.x + (size.width / 2) + edgeAdjustment;
406 CGFloat cursorY = origin.y;
407 CGFloat knobX = cursorX - (kKnobWidth - cursorWidth) / 2;
408 CGFloat knobY = origin.y + ((_selectionEdge == Qt::LeftEdge) ? -kKnobWidth : size.height);
409
410 _handleCursorLayer.frame = CGRectMake(cursorX, cursorY, cursorWidth, size.height);
411 _handleKnobLayer.frame = CGRectMake(knobX, knobY, kKnobWidth, kKnobWidth);
412 _handleCursorLayer.transform = CATransform3DMakeScale(1, scale, scale);
413 _handleKnobLayer.transform = CATransform3DMakeScale(scale, scale, scale);
414}
415
416@end
417
418// -------------------------------------------------------------------------
419
420/**
421 QIOSLoupeRecognizer is only a base class from which other recognisers
422 below will inherit. It takes care of creating and showing a magnifier
423 glass depending on the current gesture state.
424 */
425@interface QIOSLoupeRecognizer : UIGestureRecognizer <UIGestureRecognizerDelegate>
426@property (nonatomic, assign) QPointF focalPoint;
427@property (nonatomic, assign) BOOL dragTriggersGesture;
428@property (nonatomic, readonly) UIView *focusView;
429@end
430
431@implementation QIOSLoupeRecognizer {
432 QIOSLoupeLayer *_loupeLayer;
433 UIView *_desktopView;
434 CGPoint _firstTouchPoint;
435 CGPoint _lastTouchPoint;
436 QTimer _triggerStateBeganTimer;
437 int _originalCursorFlashTime;
438}
439
440- (instancetype)init
441{
442 if (self = [super initWithTarget:self action:@selector(gestureStateChanged)]) {
443 self.enabled = NO;
444 _triggerStateBeganTimer.setInterval(QGuiApplication::styleHints()->startDragTime());
445 _triggerStateBeganTimer.setSingleShot(true);
446 QObject::connect(&_triggerStateBeganTimer, &QTimer::timeout, [=](){
447 self.state = UIGestureRecognizerStateBegan;
448 });
449 }
450
451 return self;
452}
453
454- (void)setEnabled:(BOOL)enabled
455{
456 if (enabled == self.enabled)
457 return;
458
459 [super setEnabled:enabled];
460
461 if (enabled) {
462 _focusView = [reinterpret_cast<UIView *>(qApp->focusWindow()->winId()) retain];
463 _desktopView = [presentationWindow(nullptr).rootViewController.view retain];
464 Q_ASSERT(_focusView && _desktopView && _desktopView.superview);
465 [_desktopView addGestureRecognizer:self];
466 } else {
467 [_desktopView removeGestureRecognizer:self];
468 [_desktopView release];
469 _desktopView = nil;
470 [_focusView release];
471 _focusView = nil;
472 _triggerStateBeganTimer.stop();
473 if (_loupeLayer) {
474 [_loupeLayer removeFromSuperlayer];
475 [_loupeLayer release];
476 _loupeLayer = nil;
477 }
478 }
479}
480
481- (void)gestureStateChanged
482{
483 switch (self.state) {
484 case UIGestureRecognizerStateBegan:
485 // Stop cursor blinking, and show the loupe
486 _originalCursorFlashTime = QGuiApplication::styleHints()->cursorFlashTime();
487 QGuiApplication::styleHints()->setCursorFlashTime(0);
488 if (!_loupeLayer)
489 [self createLoupe];
490 [self updateFocalPoint:QPointF::fromCGPoint(_lastTouchPoint)];
491 _loupeLayer.visible = YES;
492 QIOSTextInputOverlay::s_editMenu.visible = NO;
493 break;
494 case UIGestureRecognizerStateChanged:
495 // Tell the sub class to move the loupe to the correct position
496 [self updateFocalPoint:QPointF::fromCGPoint(_lastTouchPoint)];
497 break;
498 case UIGestureRecognizerStateEnded: {
499 // Restore cursor blinking, and hide the loupe
500 QGuiApplication::styleHints()->setCursorFlashTime(_originalCursorFlashTime);
501 const QPoint touchPos = QPointF::fromCGPoint(_lastTouchPoint).toPoint();
502 showEditMenu(_focusView, touchPos);
503 _loupeLayer.visible = NO;
504 break;
505 }
506 default:
507 _loupeLayer.visible = NO;
508 break;
509 }
510}
511
512- (void)createLoupe
513{
514 // We magnify the desktop view. But the loupe itself will be added as a child
515 // of the desktop view's parent, so it doesn't become a part of what we magnify.
516 _loupeLayer = [[self createLoupeLayer] retain];
517 _loupeLayer.targetView = _desktopView;
518 [_desktopView.superview.layer addSublayer:_loupeLayer];
519}
520
521- (QPointF)focalPoint
522{
523 return QPointF::fromCGPoint([_loupeLayer.targetView convertPoint:_loupeLayer.focalPoint toView:_focusView]);
524}
525
526- (void)setFocalPoint:(QPointF)point
527{
528 _loupeLayer.focalPoint = [_loupeLayer.targetView convertPoint:point.toCGPoint() fromView:_focusView];
529}
530
531- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
532{
533 [super touchesBegan:touches withEvent:event];
534 if ([event allTouches].count > 1) {
535 // We only support text selection with one finger
536 self.state = UIGestureRecognizerStateFailed;
537 return;
538 }
539
540 _firstTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
541 _lastTouchPoint = _firstTouchPoint;
542
543 // If the touch point is accepted by the sub class (e.g touch on cursor), we start a
544 // press'n'hold timer that eventually will move the state to UIGestureRecognizerStateBegan.
545 if ([self acceptTouchesBegan:QPointF::fromCGPoint(_firstTouchPoint)])
546 _triggerStateBeganTimer.start();
547 else
548 self.state = UIGestureRecognizerStateFailed;
549}
550
551- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
552{
553 [super touchesMoved:touches withEvent:event];
554 _lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
555
556 if (self.state == UIGestureRecognizerStatePossible) {
557 // If the touch was moved too far before the timer triggered (meaning that this
558 // is a drag, not a press'n'hold), we should either fail, or trigger the gesture
559 // immediately, depending on self.dragTriggersGesture.
560 int startDragDistance = QGuiApplication::styleHints()->startDragDistance();
561 int dragDistance = hypot(_firstTouchPoint.x - _lastTouchPoint.x, _firstTouchPoint.y - _lastTouchPoint.y);
562 if (dragDistance > startDragDistance) {
563 _triggerStateBeganTimer.stop();
564 self.state = self.dragTriggersGesture ? UIGestureRecognizerStateBegan : UIGestureRecognizerStateFailed;
565 }
566 } else {
567 self.state = UIGestureRecognizerStateChanged;
568 }
569}
570
571- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
572{
573 [super touchesEnded:touches withEvent:event];
574 _triggerStateBeganTimer.stop();
575 _lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
576 self.state = self.state == UIGestureRecognizerStatePossible ? UIGestureRecognizerStateFailed : UIGestureRecognizerStateEnded;
577}
578
579- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
580{
581 [super touchesCancelled:touches withEvent:event];
582 _triggerStateBeganTimer.stop();
583 _lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
584 self.state = UIGestureRecognizerStateCancelled;
585}
586
587// Methods implemented by subclasses:
588
589- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
590{
591 Q_UNUSED(touchPoint);
592 Q_UNREACHABLE();
593 return NO;
594}
595
596- (QIOSLoupeLayer *)createLoupeLayer
597{
598 Q_UNREACHABLE();
599 return nullptr;
600}
601
602- (void)updateFocalPoint:(QPointF)touchPoint
603{
604 Q_UNUSED(touchPoint);
605 Q_UNREACHABLE();
606}
607
608@end
609
610// -------------------------------------------------------------------------
611
612/**
613 This recognizer will be active when there's no selection. It will trigger if
614 the user does a press and hold, which will start a session where the user can move
615 the cursor around with his finger together with a magnifier glass.
616 */
617@interface QIOSCursorRecognizer : QIOSLoupeRecognizer
618@end
619
620@implementation QIOSCursorRecognizer
621
622- (QIOSLoupeLayer *)createLoupeLayer
623{
624 return [[[QIOSLoupeLayer alloc] initWithSize:CGSizeMake(120, 120) cornerRadius:60 bottomOffset:4] autorelease];
625}
626
627- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
628{
629 QRectF inputRect = QPlatformInputContext::inputItemRectangle();
630 return !hasSelection() && inputRect.contains(touchPoint);
631}
632
633- (void)updateFocalPoint:(QPointF)touchPoint
634{
635 self.focalPoint = touchPoint;
636
637 const int currentCursorPos = QInputMethod::queryFocusObject(Qt::ImCursorPosition, QVariant()).toInt();
638 const int newCursorPos = QPlatformInputContext::queryFocusObject(Qt::ImCursorPosition, touchPoint).toInt();
639 if (newCursorPos != currentCursorPos)
640 QPlatformInputContext::setSelectionOnFocusObject(touchPoint, touchPoint);
641}
642
643@end
644
645// -------------------------------------------------------------------------
646
647/**
648 This recognizer will watch for selections, and draw handles as overlay
649 on the sides. If the user starts dragging on a handle (or do a press and
650 hold), it will show a magnifier glass that follows the handle as it moves.
651 */
652@interface QIOSSelectionRecognizer : QIOSLoupeRecognizer
653@end
654
655@implementation QIOSSelectionRecognizer {
656 CALayer *_clipRectLayer;
657 QIOSHandleLayer *_cursorLayer;
658 QIOSHandleLayer *_anchorLayer;
659 QPointF _touchOffset;
660 bool _dragOnCursor;
661 bool _dragOnAnchor;
662 bool _multiLine;
663 QTimer _updateSelectionTimer;
664 QMetaObject::Connection _cursorConnection;
665 QMetaObject::Connection _anchorConnection;
666 QMetaObject::Connection _clipRectConnection;
667}
668
669- (instancetype)init
670{
671 if (self = [super init]) {
672 self.delaysTouchesBegan = YES;
673 self.dragTriggersGesture = YES;
674 _multiLine = QInputMethod::queryFocusObject(Qt::ImHints, QVariant()).toUInt() & Qt::ImhMultiLine;
675 _updateSelectionTimer.setInterval(1);
676 _updateSelectionTimer.setSingleShot(true);
677 QObject::connect(&_updateSelectionTimer, &QTimer::timeout, [self](){ [self updateSelection]; });
678 }
679
680 return self;
681}
682
683- (void)setEnabled:(BOOL)enabled
684{
685 if (enabled == self.enabled)
686 return;
687
688 [super setEnabled:enabled];
689
690 if (enabled) {
691 // Create a layer that clips the handles inside the input field
692 _clipRectLayer = [CALayer new];
693 _clipRectLayer.masksToBounds = YES;
694 [self.focusView.layer addSublayer:_clipRectLayer];
695
696 // Create the handle layers, and add them to the clipped input rect layer
697 _cursorLayer = [[[QIOSHandleLayer alloc] initWithKnobAtEdge:Qt::RightEdge] autorelease];
698 _anchorLayer = [[[QIOSHandleLayer alloc] initWithKnobAtEdge:Qt::LeftEdge] autorelease];
699 bool selection = hasSelection();
700 _cursorLayer.visible = selection;
701 _anchorLayer.visible = selection;
702 [_clipRectLayer addSublayer:_cursorLayer];
703 [_clipRectLayer addSublayer:_anchorLayer];
704
705 // iOS text input will sometimes set a temporary text selection to perform operations
706 // such as backspace (select last character + cut selection). To avoid briefly showing
707 // the selection handles for such cases, and to avoid calling updateSelection when
708 // both handles and clip rectangle change, we use a timer to wait a cycle before we update.
709 // (Note that since QTimer::start is overloaded, we need some extra syntax for the connections).
710 QInputMethod *im = QGuiApplication::inputMethod();
711 void(QTimer::*start)(void) = &QTimer::start;
712 _cursorConnection = QObject::connect(im, &QInputMethod::cursorRectangleChanged, &_updateSelectionTimer, start);
713 _anchorConnection = QObject::connect(im, &QInputMethod::anchorRectangleChanged, &_updateSelectionTimer, start);
714 _clipRectConnection = QObject::connect(im, &QInputMethod::inputItemClipRectangleChanged, &_updateSelectionTimer, start);
715
716 [self updateSelection];
717 } else {
718 // Fade out the handles by setting visible to NO, and wait for the animations
719 // to finish before removing the clip rect layer, including the handles.
720 // Create a local variable to hold the clipRectLayer while the animation is
721 // ongoing to ensure that any subsequent calls to setEnabled does not interfere.
722 // Also, declare it as __block to stop it from being automatically retained, which
723 // would cause a cyclic dependency between clipRectLayer and the block.
724 __block CALayer *clipRectLayer = _clipRectLayer;
725 __block int handleCount = 2;
726 Block block = ^{
727 if (--handleCount == 0) {
728 [clipRectLayer removeFromSuperlayer];
729 [clipRectLayer release];
730 }
731 };
732
733 _cursorLayer.onAnimationDidStop = block;
734 _anchorLayer.onAnimationDidStop = block;
735 _cursorLayer.visible = NO;
736 _anchorLayer.visible = NO;
737
738 _clipRectLayer = 0;
739 _cursorLayer = 0;
740 _anchorLayer = 0;
741 _updateSelectionTimer.stop();
742
743 QObject::disconnect(_cursorConnection);
744 QObject::disconnect(_anchorConnection);
745 QObject::disconnect(_clipRectConnection);
746
747 if (QIOSTextInputOverlay::s_editMenu.shownByUs)
748 QIOSTextInputOverlay::s_editMenu.visible = NO;
749 }
750}
751
752- (QIOSLoupeLayer *)createLoupeLayer
753{
754 CGSize loupeSize = CGSizeMake(123, 33);
755 CGSize arrowSize = CGSizeMake(25, 12);
756 CGFloat loupeOffset = arrowSize.height + 20;
757
758 // Create loupe and arrow layers
759 QIOSLoupeLayer *loupeLayer = [[[QIOSLoupeLayer alloc] initWithSize:loupeSize cornerRadius:5 bottomOffset:loupeOffset] autorelease];
760 CAShapeLayer *arrowLayer = [[[CAShapeLayer alloc] init] autorelease];
761
762 // Build a triangular path to both draw and mask the arrow layer as a triangle
763 UIBezierPath *path = [[UIBezierPath new] autorelease];
764 [path moveToPoint:CGPointMake(0, 0)];
765 [path addLineToPoint:CGPointMake(arrowSize.width / 2, arrowSize.height)];
766 [path addLineToPoint:CGPointMake(arrowSize.width, 0)];
767
768 arrowLayer.frame = CGRectMake((loupeSize.width - arrowSize.width) / 2, loupeSize.height - 1, arrowSize.width, arrowSize.height);
769 arrowLayer.path = path.CGPath;
770 arrowLayer.backgroundColor = [[UIColor whiteColor] CGColor];
771 arrowLayer.strokeColor = [[UIColor lightGrayColor] CGColor];
772 arrowLayer.lineWidth = 0.75 * 2;
773 arrowLayer.fillColor = nil;
774
775 CAShapeLayer *mask = [[CAShapeLayer new] autorelease];
776 mask.frame = arrowLayer.bounds;
777 mask.path = path.CGPath;
778 arrowLayer.mask = mask;
779
780 [loupeLayer addSublayer:arrowLayer];
781
782 return loupeLayer;
783}
784
785- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
786{
787 if (!hasSelection())
788 return NO;
789
790 // Accept the touch if it "overlaps" with any of the handles
791 const int handleRadius = 50;
792 QPointF cursorCenter = QPlatformInputContext::cursorRectangle().center();
793 QPointF anchorCenter = QPlatformInputContext::anchorRectangle().center();
794 QPointF cursorOffset = QPointF(cursorCenter.x() - touchPoint.x(), cursorCenter.y() - touchPoint.y());
795 QPointF anchorOffset = QPointF(anchorCenter.x() - touchPoint.x(), anchorCenter.y() - touchPoint.y());
796 double cursorDist = hypot(cursorOffset.x(), cursorOffset.y());
797 double anchorDist = hypot(anchorOffset.x(), anchorOffset.y());
798
799 if (cursorDist > handleRadius && anchorDist > handleRadius)
800 return NO;
801
802 if (cursorDist < anchorDist) {
803 _touchOffset = cursorOffset;
804 _dragOnCursor = YES;
805 _dragOnAnchor = NO;
806 } else {
807 _touchOffset = anchorOffset;
808 _dragOnCursor = NO;
809 _dragOnAnchor = YES;
810 }
811
812 return YES;
813}
814
815- (void)updateFocalPoint:(QPointF)touchPoint
816{
817 touchPoint += _touchOffset;
818
819 // Get the text position under the touch
820 SelectionPair selection = querySelection();
821 int touchTextPos = QPlatformInputContext::queryFocusObject(Qt::ImCursorPosition, touchPoint).toInt();
822
823 // Ensure that the handles cannot be dragged past each other
824 if (_dragOnCursor)
825 selection.second = (touchTextPos > selection.first) ? touchTextPos : selection.first + 1;
826 else
827 selection.first = (touchTextPos < selection.second) ? touchTextPos : selection.second - 1;
828
829 // Set new selection
830 QList<QInputMethodEvent::Attribute> imAttributes;
831 imAttributes.append(QInputMethodEvent::Attribute(
832 QInputMethodEvent::Selection, selection.first, selection.second - selection.first, QVariant()));
833 QInputMethodEvent event(QString(), imAttributes);
834 QGuiApplication::sendEvent(qApp->focusObject(), &event);
835
836 // Move loupe to new position
837 QRectF handleRect = _dragOnCursor ?
838 QPlatformInputContext::cursorRectangle() :
839 QPlatformInputContext::anchorRectangle();
840 self.focalPoint = QPointF(touchPoint.x(), handleRect.center().y());
841}
842
843- (void)updateSelection
844{
845 if (!hasSelection()) {
846 if (_cursorLayer.visible) {
847 _cursorLayer.visible = NO;
848 _anchorLayer.visible = NO;
849 }
850 if (QIOSTextInputOverlay::s_editMenu.shownByUs)
851 QIOSTextInputOverlay::s_editMenu.visible = NO;
852 return;
853 }
854
855 if (!_cursorLayer.visible && QIOSTextInputOverlay::s_editMenu.isHiding) {
856 // Since the edit menu is hiding and this is the first selection thereafter, we
857 // assume that the selection came from the user tapping on a menu item. In that
858 // case, we reshow the menu after it has closed (but then with selection based
859 // menu items, as specified by first responder).
860 QIOSTextInputOverlay::s_editMenu.reshowAfterHidden = YES;
861 }
862
863 // Adjust handles and input rect to match the new selection
864 QRectF inputRect = QPlatformInputContext::inputItemClipRectangle();
865 CGRect cursorRect = QPlatformInputContext::cursorRectangle().toCGRect();
866 CGRect anchorRect = QPlatformInputContext::anchorRectangle().toCGRect();
867
868 if (!_multiLine) {
869 // Resize the layer a bit bigger to ensure that the handles are
870 // not cut if if they are otherwise visible inside the clip rect.
871 int margin = kKnobWidth + 5;
872 inputRect.adjust(-margin / 2, -margin, margin / 2, margin);
873 }
874
875 executeBlockWithoutAnimation(^{ _clipRectLayer.frame = inputRect.toCGRect(); });
876 _cursorLayer.cursorRectangle = [self.focusView.layer convertRect:cursorRect toLayer:_clipRectLayer];
877 _anchorLayer.cursorRectangle = [self.focusView.layer convertRect:anchorRect toLayer:_clipRectLayer];
878 _cursorLayer.visible = YES;
879 _anchorLayer.visible = YES;
880}
881
882@end
883
884// -------------------------------------------------------------------------
885
886/**
887 This recognizer will show the edit menu if the user taps inside the input
888 item without changing the cursor position, or hide it if it's already visible
889 and the user taps anywhere on the screen.
890 */
891@interface QIOSTapRecognizer : UITapGestureRecognizer
892@end
893
894@implementation QIOSTapRecognizer {
895 int _cursorPosOnPress;
896 bool _menuShouldBeVisible;
897 UIView *_focusView;
898}
899
900- (instancetype)init
901{
902 if (self = [super initWithTarget:self action:@selector(gestureStateChanged)]) {
903 self.enabled = NO;
904 }
905
906 return self;
907}
908
909- (void)setEnabled:(BOOL)enabled
910{
911 if (enabled == self.enabled)
912 return;
913
914 [super setEnabled:enabled];
915
916 if (enabled) {
917 _focusView = [reinterpret_cast<UIView *>(qApp->focusWindow()->winId()) retain];
918 [_focusView addGestureRecognizer:self];
919 } else {
920 [_focusView removeGestureRecognizer:self];
921 [_focusView release];
922 _focusView = nil;
923 }
924}
925
926- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
927{
928 [super touchesBegan:touches withEvent:event];
929
930 QRectF inputRect = QPlatformInputContext::inputItemClipRectangle();
931 QPointF touchPos = QPointF::fromCGPoint([static_cast<UITouch *>([touches anyObject]) locationInView:_focusView]);
932 const bool touchInsideInputArea = inputRect.contains(touchPos);
933
934 if (touchInsideInputArea && hasSelection()) {
935 // When we have a selection and the user taps inside the input area, we stop
936 // tracking, and let Qt handle the event like normal. Unless the selection
937 // recogniser is triggered instead (if the touch is on top of the selection
938 // handles) this will typically result in Qt clearing the selection, which in
939 // turn will make the selection recogniser hide the menu.
940 self.state = UIGestureRecognizerStateFailed;
941 return;
942 }
943
944 if (QIOSTextInputOverlay::s_editMenu.visible) {
945 // When the menu is visible and there is no selection, we should always
946 // hide it, regardless of where the user tapped on the screen. We achieve
947 // this by continue tracking so that we receive a touchesEnded call.
948 // But note, we only want to hide the menu, and not clear the selection.
949 // Only when the user taps inside the input area do we want to clear the
950 // selection as well. This is different from native behavior, but done so
951 // deliberately for cross-platform consistency. This will let the user click on
952 // e.g "Bold" and "Italic" buttons elsewhere in the UI to modify the selected text.
953 return;
954 }
955
956 if (!touchInsideInputArea) {
957 // If the menu is not showing, and the touch is outside the input
958 // area, there is nothing left for this recogniser to do.
959 self.state = UIGestureRecognizerStateFailed;
960 return;
961 }
962
963 // When no menu is showing, and the touch is inside the input
964 // area, we check if we should show it. We want to do so if
965 // the tap doesn't result in the cursor changing position.
966 _cursorPosOnPress = QInputMethod::queryFocusObject(Qt::ImCursorPosition, QVariant()).toInt();
967}
968
969- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
970{
971 if (QIOSTextInputOverlay::s_editMenu.visible) {
972 _menuShouldBeVisible = false;
973 } else {
974 QPointF touchPos = QPointF::fromCGPoint([static_cast<UITouch *>([touches anyObject]) locationInView:_focusView]);
975 int cursorPosOnRelease = QPlatformInputContext::queryFocusObject(Qt::ImCursorPosition, touchPos).toInt();
976
977 if (cursorPosOnRelease == _cursorPosOnPress) {
978 // We've recognized a gesture to open the menu, but we don't know
979 // whether the user tapped a control that was overlaid our input
980 // area, since we don't do any granular hit-testing in touchesBegan.
981 // To ensure that the gesture doesn't eat touch events that should
982 // have reached another UI control we report the gesture as failed
983 // here, and then manually show the menu at the next runloop pass.
984 _menuShouldBeVisible = true;
985 self.state = UIGestureRecognizerStateFailed;
986 dispatch_async(dispatch_get_main_queue(), ^{
987 if (_menuShouldBeVisible)
988 showEditMenu(_focusView, touchPos.toPoint());
989 else
990 QIOSTextInputOverlay::s_editMenu.visible = false;
991 });
992 } else {
993 // The menu is hidden, and the cursor will change position once
994 // Qt receive the touch release. We therefore fail so that we
995 // don't block the touch event from further processing.
996 self.state = UIGestureRecognizerStateFailed;
997 }
998 }
999
1000 [super touchesEnded:touches withEvent:event];
1001}
1002
1003- (void)gestureStateChanged
1004{
1005 if (self.state != UIGestureRecognizerStateEnded)
1006 return;
1007
1008 QIOSTextInputOverlay::s_editMenu.visible = _menuShouldBeVisible;
1009}
1010
1011@end
1012
1013// -------------------------------------------------------------------------
1014
1015QT_BEGIN_NAMESPACE
1016
1017QIOSEditMenu *QIOSTextInputOverlay::s_editMenu = nullptr;
1018
1019QIOSTextInputOverlay::QIOSTextInputOverlay()
1020 : m_cursorRecognizer(nullptr)
1021 , m_selectionRecognizer(nullptr)
1022 , m_openMenuOnTapRecognizer(nullptr)
1023{
1024 if (qt_apple_isApplicationExtension()) {
1025 qWarning() << "text input overlays disabled in application extensions";
1026 return;
1027 }
1028
1029 connect(qApp, &QGuiApplication::focusObjectChanged, this, &QIOSTextInputOverlay::updateFocusObject);
1030}
1031
1032QIOSTextInputOverlay::~QIOSTextInputOverlay()
1033{
1034 if (qApp)
1035 disconnect(qApp, 0, this, 0);
1036}
1037
1038void QIOSTextInputOverlay::updateFocusObject()
1039{
1040 // Destroy old recognizers since they were created with
1041 // dependencies to the old focus object (focus view).
1042 if (m_cursorRecognizer) {
1043 m_cursorRecognizer.enabled = NO;
1044 [m_cursorRecognizer release];
1045 m_cursorRecognizer = nullptr;
1046 }
1047 if (m_selectionRecognizer) {
1048 m_selectionRecognizer.enabled = NO;
1049 [m_selectionRecognizer release];
1050 m_selectionRecognizer = nullptr;
1051 }
1052 if (m_openMenuOnTapRecognizer) {
1053 m_openMenuOnTapRecognizer.enabled = NO;
1054 [m_openMenuOnTapRecognizer release];
1055 m_openMenuOnTapRecognizer = nullptr;
1056 }
1057
1058 if (s_editMenu) {
1059 [s_editMenu release];
1060 s_editMenu = nullptr;
1061 }
1062
1063 const QVariant hintsVariant = QGuiApplication::inputMethod()->queryFocusObject(Qt::ImHints, QVariant());
1064 const Qt::InputMethodHints hints = Qt::InputMethodHints(hintsVariant.toUInt());
1065 if (hints & Qt::ImhNoTextHandles)
1066 return;
1067
1068 // The focus object can emit selection updates (e.g from mouse drag), and
1069 // accept modifying it through IM when dragging on the handles, even if it
1070 // doesn't accept text input and IM in general (and hence return false from
1071 // inputMethodAccepted()). This is the case for read-only text fields.
1072 // Therefore, listen for selection changes also when the focus object
1073 // reports that it's ImReadOnly (which we take as a hint that it's actually
1074 // a text field, and that selections therefore might happen). But since
1075 // we have no guarantee that the focus object can actually accept new selections
1076 // through IM (and since we also need to respect if the input accepts selections
1077 // in the first place), we only support selections started by the text field (e.g from
1078 // mouse drag), even if we in theory could also start selections from a loupe.
1079
1080 const bool inputAccepted = platformInputContext()->inputMethodAccepted();
1081 const bool readOnly = QGuiApplication::inputMethod()->queryFocusObject(Qt::ImReadOnly, QVariant()).toBool();
1082
1083 if (inputAccepted || readOnly) {
1084 if (!(hints & Qt::ImhNoEditMenu))
1085 s_editMenu = [QIOSEditMenu new];
1086 m_selectionRecognizer = [QIOSSelectionRecognizer new];
1087 m_openMenuOnTapRecognizer = [QIOSTapRecognizer new];
1088 m_selectionRecognizer.enabled = YES;
1089 m_openMenuOnTapRecognizer.enabled = YES;
1090 }
1091
1092 if (inputAccepted) {
1093 m_cursorRecognizer = [QIOSCursorRecognizer new];
1094 m_cursorRecognizer.enabled = YES;
1095 }
1096}
1097
1098QT_END_NAMESPACE
static SelectionPair querySelection()
static const CGFloat kKnobWidth
static bool hasSelection()
std::pair< int, int > SelectionPair
static void executeBlockWithoutAnimation(Block block)
void(^ Block)(void)
static QPlatformInputContext * platformInputContext()