mirror of
https://gitlab.com/eql/lqml.git
synced 2025-12-06 10:31:34 -08:00
1039 lines
37 KiB
Text
1039 lines
37 KiB
Text
/****************************************************************************
|
|
**
|
|
** Copyright (C) 2017 The Qt Company Ltd.
|
|
** Contact: https://www.qt.io/licensing/
|
|
**
|
|
** This file is part of the plugins of the Qt Toolkit.
|
|
**
|
|
** $QT_BEGIN_LICENSE:LGPL$
|
|
** Commercial License Usage
|
|
** Licensees holding valid commercial Qt licenses may use this file in
|
|
** accordance with the commercial license agreement provided with the
|
|
** Software or, alternatively, in accordance with the terms contained in
|
|
** a written agreement between you and The Qt Company. For licensing terms
|
|
** and conditions see https://www.qt.io/terms-conditions. For further
|
|
** information use the contact form at https://www.qt.io/contact-us.
|
|
**
|
|
** GNU Lesser General Public License Usage
|
|
** Alternatively, this file may be used under the terms of the GNU Lesser
|
|
** General Public License version 3 as published by the Free Software
|
|
** Foundation and appearing in the file LICENSE.LGPL3 included in the
|
|
** packaging of this file. Please review the following information to
|
|
** ensure the GNU Lesser General Public License version 3 requirements
|
|
** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
|
|
**
|
|
** GNU General Public License Usage
|
|
** Alternatively, this file may be used under the terms of the GNU
|
|
** General Public License version 2.0 or (at your option) the GNU General
|
|
** Public license version 3 or any later version approved by the KDE Free
|
|
** Qt Foundation. The licenses are as published by the Free Software
|
|
** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
|
|
** included in the packaging of this file. Please review the following
|
|
** information to ensure the GNU General Public License requirements will
|
|
** be met: https://www.gnu.org/licenses/gpl-2.0.html and
|
|
** https://www.gnu.org/licenses/gpl-3.0.html.
|
|
**
|
|
** $QT_END_LICENSE$
|
|
**
|
|
****************************************************************************/
|
|
|
|
#import <UIKit/UIGestureRecognizerSubclass.h>
|
|
#import <UIKit/UITextView.h>
|
|
|
|
#include <QtGui/QGuiApplication>
|
|
#include <QtGui/QInputMethod>
|
|
#include <QtGui/QStyleHints>
|
|
|
|
#include <QtGui/private/qinputmethod_p.h>
|
|
#include <QtCore/private/qobject_p.h>
|
|
#include <QtCore/private/qcore_mac_p.h>
|
|
|
|
#include "qiosglobal.h"
|
|
#include "qiostextinputoverlay.h"
|
|
|
|
typedef QPair<int, int> SelectionPair;
|
|
typedef void (^Block)(void);
|
|
|
|
static const CGFloat kKnobWidth = 10;
|
|
|
|
static QPlatformInputContext *platformInputContext()
|
|
{
|
|
return static_cast<QInputMethodPrivate *>(QObjectPrivate::get(QGuiApplication::inputMethod()))->platformInputContext();
|
|
}
|
|
|
|
static SelectionPair querySelection()
|
|
{
|
|
QInputMethodQueryEvent query(Qt::ImAnchorPosition | Qt::ImCursorPosition);
|
|
QGuiApplication::sendEvent(QGuiApplication::focusObject(), &query);
|
|
int anchorPos = query.value(Qt::ImAnchorPosition).toInt();
|
|
int cursorPos = query.value(Qt::ImCursorPosition).toInt();
|
|
return qMakePair<int, int>(anchorPos, cursorPos);
|
|
}
|
|
|
|
static bool hasSelection()
|
|
{
|
|
SelectionPair selection = querySelection();
|
|
return selection.first != selection.second;
|
|
}
|
|
|
|
static void executeBlockWithoutAnimation(Block block)
|
|
{
|
|
[CATransaction begin];
|
|
[CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
|
|
block();
|
|
[CATransaction commit];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
/**
|
|
QIOSEditMenu is just a wrapper class around UIMenuController to
|
|
ease showing and hiding it correcly.
|
|
*/
|
|
@interface QIOSEditMenu : NSObject
|
|
@property (nonatomic, assign) BOOL visible;
|
|
@property (nonatomic, readonly) BOOL isHiding;
|
|
@property (nonatomic, assign) BOOL reshowAfterHidden;
|
|
@end
|
|
|
|
@implementation QIOSEditMenu
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super init]) {
|
|
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
|
|
|
|
[center addObserverForName:UIMenuControllerWillHideMenuNotification
|
|
object:nil queue:nil usingBlock:^(NSNotification *) {
|
|
_isHiding = YES;
|
|
}];
|
|
|
|
[center addObserverForName:UIMenuControllerDidHideMenuNotification
|
|
object:nil queue:nil usingBlock:^(NSNotification *) {
|
|
_isHiding = NO;
|
|
if (self.reshowAfterHidden) {
|
|
// To not abort an ongoing hide transition when showing the menu, you can set
|
|
// reshowAfterHidden to wait until the transition finishes before reshowing it.
|
|
self.reshowAfterHidden = NO;
|
|
dispatch_async(dispatch_get_main_queue (), ^{ self.visible = YES; });
|
|
}
|
|
}];
|
|
[center addObserverForName:UIKeyboardDidHideNotification object:nil queue:nil
|
|
usingBlock:^(NSNotification *) {
|
|
self.visible = NO;
|
|
}];
|
|
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:nil object:nil];
|
|
[super dealloc];
|
|
}
|
|
|
|
- (BOOL)visible
|
|
{
|
|
return [UIMenuController sharedMenuController].menuVisible;
|
|
}
|
|
|
|
- (void)setVisible:(BOOL)visible
|
|
{
|
|
if (visible == self.visible)
|
|
return;
|
|
|
|
if (visible) {
|
|
// Note that the contents of the edit menu is decided by
|
|
// first responder, which is normally QIOSTextResponder.
|
|
QRectF cr = qApp->inputMethod()->cursorRectangle();
|
|
QRectF ar = qApp->inputMethod()->anchorRectangle();
|
|
CGRect targetRect = cr.united(ar).toCGRect();
|
|
UIView *focusView = reinterpret_cast<UIView *>(qApp->focusWindow()->winId());
|
|
[[UIMenuController sharedMenuController] setTargetRect:targetRect inView:focusView];
|
|
[[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
|
|
} else {
|
|
[[UIMenuController sharedMenuController] setMenuVisible:NO animated:YES];
|
|
}
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
@interface QIOSLoupeLayer : CALayer
|
|
@property (nonatomic, retain) UIView *targetView;
|
|
@property (nonatomic, assign) CGPoint focalPoint;
|
|
@property (nonatomic, assign) BOOL visible;
|
|
@end
|
|
|
|
@implementation QIOSLoupeLayer {
|
|
UIView *_snapshotView;
|
|
BOOL _pendingSnapshotUpdate;
|
|
UIView *_loupeImageView;
|
|
CALayer *_containerLayer;
|
|
CGFloat _loupeOffset;
|
|
QTimer _updateTimer;
|
|
}
|
|
|
|
- (instancetype)initWithSize:(CGSize)size cornerRadius:(CGFloat)cornerRadius bottomOffset:(CGFloat)bottomOffset
|
|
{
|
|
if (self = [super init]) {
|
|
_loupeOffset = bottomOffset + (size.height / 2);
|
|
_snapshotView = nil;
|
|
_pendingSnapshotUpdate = YES;
|
|
_updateTimer.setInterval(100);
|
|
_updateTimer.setSingleShot(true);
|
|
QObject::connect(&_updateTimer, &QTimer::timeout, [self](){ [self updateSnapshot]; });
|
|
|
|
// Create own geometry and outer shadow
|
|
self.frame = CGRectMake(0, 0, size.width, size.height);
|
|
self.cornerRadius = cornerRadius;
|
|
self.shadowColor = [[UIColor grayColor] CGColor];
|
|
self.shadowOffset = CGSizeMake(0, 1);
|
|
self.shadowRadius = 2.0;
|
|
self.shadowOpacity = 0.75;
|
|
self.transform = CATransform3DMakeScale(0, 0, 0);
|
|
|
|
// Create container view for the snapshots
|
|
_containerLayer = [[CALayer new] autorelease];
|
|
_containerLayer.frame = self.bounds;
|
|
_containerLayer.cornerRadius = cornerRadius;
|
|
_containerLayer.masksToBounds = YES;
|
|
[self addSublayer:_containerLayer];
|
|
|
|
// Create inner loupe shadow
|
|
const CGFloat inset = 30;
|
|
CALayer *topShadeLayer = [[CALayer new] autorelease];
|
|
topShadeLayer.frame = CGRectOffset(CGRectInset(self.bounds, -inset, -inset), 0, inset / 2);
|
|
topShadeLayer.borderWidth = inset / 2;
|
|
topShadeLayer.cornerRadius = cornerRadius;
|
|
topShadeLayer.borderColor = [[UIColor blackColor] CGColor];
|
|
topShadeLayer.shadowColor = [[UIColor blackColor] CGColor];
|
|
topShadeLayer.shadowOffset = CGSizeMake(0, 0);
|
|
topShadeLayer.shadowRadius = 15.0;
|
|
topShadeLayer.shadowOpacity = 0.6;
|
|
// Keep the shadow inside the loupe
|
|
CALayer *mask = [[CALayer new] autorelease];
|
|
mask.frame = CGRectOffset(self.bounds, inset, inset / 2);
|
|
mask.backgroundColor = [[UIColor blackColor] CGColor];
|
|
mask.cornerRadius = cornerRadius;
|
|
topShadeLayer.mask = mask;
|
|
[self addSublayer:topShadeLayer];
|
|
|
|
// Create border around the loupe. We need to do this in a separate
|
|
// layer (as opposed to on self) to not draw the border on top of
|
|
// overlapping external children (arrow).
|
|
CALayer *borderLayer = [[CALayer new] autorelease];
|
|
borderLayer.frame = self.bounds;
|
|
borderLayer.borderWidth = 0.75;
|
|
borderLayer.cornerRadius = cornerRadius;
|
|
borderLayer.borderColor = [[UIColor lightGrayColor] CGColor];
|
|
[self addSublayer:borderLayer];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)dealloc
|
|
{
|
|
_targetView = nil;
|
|
[super dealloc];
|
|
}
|
|
|
|
- (void)setVisible:(BOOL)visible
|
|
{
|
|
if (_visible == visible)
|
|
return;
|
|
|
|
_visible = visible;
|
|
|
|
dispatch_async(dispatch_get_main_queue (), ^{
|
|
// Setting transform later, since CA will not perform an animation if
|
|
// changing values directly after init, and if the scale ends up empty.
|
|
self.transform = _visible ? CATransform3DMakeScale(1, 1, 1) : CATransform3DMakeScale(0.0, 0.0, 1);
|
|
});
|
|
}
|
|
|
|
- (void)updateSnapshot
|
|
{
|
|
_pendingSnapshotUpdate = YES;
|
|
[self setNeedsDisplay];
|
|
}
|
|
|
|
- (void)setFocalPoint:(CGPoint)point
|
|
{
|
|
_focalPoint = point;
|
|
[self updateSnapshot];
|
|
|
|
// Schedule a delayed update as well to ensure that we end up with a correct
|
|
// snapshot of the cursor, since QQuickRenderThread lags a bit behind
|
|
_updateTimer.start();
|
|
}
|
|
|
|
- (void)display
|
|
{
|
|
// Take a snapshow of the target view, magnify the area around the focal
|
|
// point, and add the snapshow layer as a child of the container layer
|
|
// to make it look like a loupe. Then place this layer at the position of
|
|
// the focal point with the requested offset.
|
|
executeBlockWithoutAnimation(^{
|
|
if (_pendingSnapshotUpdate) {
|
|
UIView *newSnapshot = [_targetView snapshotViewAfterScreenUpdates:NO];
|
|
[_snapshotView.layer removeFromSuperlayer];
|
|
[_snapshotView release];
|
|
_snapshotView = [newSnapshot retain];
|
|
[_containerLayer addSublayer:_snapshotView.layer];
|
|
_pendingSnapshotUpdate = NO;
|
|
}
|
|
|
|
self.position = CGPointMake(_focalPoint.x, _focalPoint.y - _loupeOffset);
|
|
|
|
const CGFloat loupeScale = 1.5;
|
|
CGFloat x = -(_focalPoint.x * loupeScale) + self.frame.size.width / 2;
|
|
CGFloat y = -(_focalPoint.y * loupeScale) + self.frame.size.height / 2;
|
|
CGFloat w = _targetView.frame.size.width * loupeScale;
|
|
CGFloat h = _targetView.frame.size.height * loupeScale;
|
|
_snapshotView.layer.frame = CGRectMake(x, y, w, h);
|
|
});
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
@interface QIOSHandleLayer : CALayer <CAAnimationDelegate>
|
|
@property (nonatomic, assign) CGRect cursorRectangle;
|
|
@property (nonatomic, assign) CGFloat handleScale;
|
|
@property (nonatomic, assign) BOOL visible;
|
|
@property (nonatomic, copy) Block onAnimationDidStop;
|
|
@end
|
|
|
|
@implementation QIOSHandleLayer {
|
|
CALayer *_handleCursorLayer;
|
|
CALayer *_handleKnobLayer;
|
|
Qt::Edge _selectionEdge;
|
|
}
|
|
|
|
@dynamic handleScale;
|
|
|
|
- (instancetype)initWithKnobAtEdge:(Qt::Edge)selectionEdge
|
|
{
|
|
if (self = [super init]) {
|
|
CGColorRef bgColor = [UIColor colorWithRed:0.1 green:0.4 blue:0.9 alpha:1].CGColor;
|
|
_selectionEdge = selectionEdge;
|
|
self.handleScale = 0;
|
|
|
|
_handleCursorLayer = [[CALayer new] autorelease];
|
|
_handleCursorLayer.masksToBounds = YES;
|
|
_handleCursorLayer.backgroundColor = bgColor;
|
|
[self addSublayer:_handleCursorLayer];
|
|
|
|
_handleKnobLayer = [[CALayer new] autorelease];
|
|
_handleKnobLayer.masksToBounds = YES;
|
|
_handleKnobLayer.backgroundColor = bgColor;
|
|
_handleKnobLayer.cornerRadius = kKnobWidth / 2;
|
|
[self addSublayer:_handleKnobLayer];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
+ (BOOL)needsDisplayForKey:(NSString *)key
|
|
{
|
|
if ([key isEqualToString:@"handleScale"])
|
|
return YES;
|
|
return [super needsDisplayForKey:key];
|
|
}
|
|
|
|
- (id<CAAction>)actionForKey:(NSString *)key
|
|
{
|
|
if ([key isEqualToString:@"handleScale"]) {
|
|
if (_visible) {
|
|
// The handle should "bounce" in when becoming visible
|
|
CAKeyframeAnimation * animation = [CAKeyframeAnimation animationWithKeyPath:key];
|
|
[animation setDuration:0.5];
|
|
animation.values = @[@(0.0f), @(1.3f), @(1.3f), @(1.0f)];
|
|
animation.keyTimes = @[@(0.0f), @(0.3f), @(0.9f), @(1.0f)];
|
|
return animation;
|
|
} else {
|
|
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
|
|
[animation setDelegate:self];
|
|
animation.fromValue = [self valueForKey:key];
|
|
[animation setDuration:0.2];
|
|
return animation;
|
|
}
|
|
}
|
|
return [super actionForKey:key];
|
|
}
|
|
|
|
- (void)animationDidStop:(CAAnimation *)animation finished:(BOOL)flag
|
|
{
|
|
Q_UNUSED(animation);
|
|
Q_UNUSED(flag);
|
|
if (self.onAnimationDidStop)
|
|
self.onAnimationDidStop();
|
|
}
|
|
|
|
- (void)setVisible:(BOOL)visible
|
|
{
|
|
if (visible == _visible)
|
|
return;
|
|
|
|
_visible = visible;
|
|
|
|
self.handleScale = visible ? 1 : 0;
|
|
}
|
|
|
|
- (void)setCursorRectangle:(CGRect)cursorRect
|
|
{
|
|
if (CGRectEqualToRect(_cursorRectangle, cursorRect))
|
|
return;
|
|
|
|
_cursorRectangle = cursorRect;
|
|
|
|
executeBlockWithoutAnimation(^{
|
|
[self setNeedsDisplay];
|
|
[self displayIfNeeded];
|
|
});
|
|
}
|
|
|
|
- (void)display
|
|
{
|
|
CGFloat cursorWidth = 2;
|
|
CGPoint origin = _cursorRectangle.origin;
|
|
CGSize size = _cursorRectangle.size;
|
|
CGFloat scale = ((QIOSHandleLayer *)[self presentationLayer]).handleScale;
|
|
CGFloat edgeAdjustment = (_selectionEdge == Qt::LeftEdge) ? 0.5 - cursorWidth : -0.5;
|
|
|
|
CGFloat cursorX = origin.x + (size.width / 2) + edgeAdjustment;
|
|
CGFloat cursorY = origin.y;
|
|
CGFloat knobX = cursorX - (kKnobWidth - cursorWidth) / 2;
|
|
CGFloat knobY = origin.y + ((_selectionEdge == Qt::LeftEdge) ? -kKnobWidth : size.height);
|
|
|
|
_handleCursorLayer.frame = CGRectMake(cursorX, cursorY, cursorWidth, size.height);
|
|
_handleKnobLayer.frame = CGRectMake(knobX, knobY, kKnobWidth, kKnobWidth);
|
|
_handleCursorLayer.transform = CATransform3DMakeScale(1, scale, scale);
|
|
_handleKnobLayer.transform = CATransform3DMakeScale(scale, scale, scale);
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
QIOSLoupeRecognizer is only a base class from which other recognisers
|
|
below will inherit. It takes care of creating and showing a magnifier
|
|
glass depending on the current gesture state.
|
|
*/
|
|
@interface QIOSLoupeRecognizer : UIGestureRecognizer <UIGestureRecognizerDelegate>
|
|
@property (nonatomic, assign) QPointF focalPoint;
|
|
@property (nonatomic, assign) BOOL dragTriggersGesture;
|
|
@property (nonatomic, readonly) UIView *focusView;
|
|
@end
|
|
|
|
@implementation QIOSLoupeRecognizer {
|
|
QIOSLoupeLayer *_loupeLayer;
|
|
UIView *_desktopView;
|
|
CGPoint _firstTouchPoint;
|
|
CGPoint _lastTouchPoint;
|
|
QTimer _triggerStateBeganTimer;
|
|
int _originalCursorFlashTime;
|
|
}
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super initWithTarget:self action:@selector(gestureStateChanged)]) {
|
|
self.enabled = NO;
|
|
_triggerStateBeganTimer.setInterval(QGuiApplication::styleHints()->startDragTime());
|
|
_triggerStateBeganTimer.setSingleShot(true);
|
|
QObject::connect(&_triggerStateBeganTimer, &QTimer::timeout, [=](){
|
|
self.state = UIGestureRecognizerStateBegan;
|
|
});
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)setEnabled:(BOOL)enabled
|
|
{
|
|
if (qApp->property("disable-clipboard-menu").toBool()) // hack
|
|
enabled = NO;
|
|
|
|
if (enabled == self.enabled)
|
|
return;
|
|
|
|
[super setEnabled:enabled];
|
|
|
|
if (enabled) {
|
|
_focusView = [reinterpret_cast<UIView *>(qApp->focusWindow()->winId()) retain];
|
|
_desktopView = [qt_apple_sharedApplication().keyWindow.rootViewController.view retain];
|
|
Q_ASSERT(_focusView && _desktopView && _desktopView.superview);
|
|
[_desktopView addGestureRecognizer:self];
|
|
} else {
|
|
[_desktopView removeGestureRecognizer:self];
|
|
[_desktopView release];
|
|
_desktopView = nil;
|
|
[_focusView release];
|
|
_focusView = nil;
|
|
_triggerStateBeganTimer.stop();
|
|
if (_loupeLayer) {
|
|
[_loupeLayer removeFromSuperlayer];
|
|
[_loupeLayer release];
|
|
_loupeLayer = nil;
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)gestureStateChanged
|
|
{
|
|
switch (self.state) {
|
|
case UIGestureRecognizerStateBegan:
|
|
// Stop cursor blinking, and show the loupe
|
|
_originalCursorFlashTime = QGuiApplication::styleHints()->cursorFlashTime();
|
|
QGuiApplication::styleHints()->setCursorFlashTime(0);
|
|
if (!_loupeLayer)
|
|
[self createLoupe];
|
|
[self updateFocalPoint:QPointF::fromCGPoint(_lastTouchPoint)];
|
|
_loupeLayer.visible = YES;
|
|
break;
|
|
case UIGestureRecognizerStateChanged:
|
|
// Tell the sub class to move the loupe to the correct position
|
|
[self updateFocalPoint:QPointF::fromCGPoint(_lastTouchPoint)];
|
|
break;
|
|
case UIGestureRecognizerStateEnded:
|
|
// Restore cursor blinking, and hide the loupe
|
|
QGuiApplication::styleHints()->setCursorFlashTime(_originalCursorFlashTime);
|
|
QIOSTextInputOverlay::s_editMenu.visible = YES;
|
|
_loupeLayer.visible = NO;
|
|
break;
|
|
default:
|
|
_loupeLayer.visible = NO;
|
|
break;
|
|
}
|
|
}
|
|
|
|
- (void)createLoupe
|
|
{
|
|
// We magnify the desktop view. But the loupe itself will be added as a child
|
|
// of the desktop view's parent, so it doesn't become a part of what we magnify.
|
|
_loupeLayer = [[self createLoupeLayer] retain];
|
|
_loupeLayer.targetView = _desktopView;
|
|
[_desktopView.superview.layer addSublayer:_loupeLayer];
|
|
}
|
|
|
|
- (QPointF)focalPoint
|
|
{
|
|
return QPointF::fromCGPoint([_loupeLayer.targetView convertPoint:_loupeLayer.focalPoint toView:_focusView]);
|
|
}
|
|
|
|
- (void)setFocalPoint:(QPointF)point
|
|
{
|
|
_loupeLayer.focalPoint = [_loupeLayer.targetView convertPoint:point.toCGPoint() fromView:_focusView];
|
|
}
|
|
|
|
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesBegan:touches withEvent:event];
|
|
if ([event allTouches].count > 1) {
|
|
// We only support text selection with one finger
|
|
self.state = UIGestureRecognizerStateFailed;
|
|
return;
|
|
}
|
|
|
|
_firstTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
|
|
_lastTouchPoint = _firstTouchPoint;
|
|
|
|
// If the touch point is accepted by the sub class (e.g touch on cursor), we start a
|
|
// press'n'hold timer that eventually will move the state to UIGestureRecognizerStateBegan.
|
|
if ([self acceptTouchesBegan:QPointF::fromCGPoint(_firstTouchPoint)])
|
|
_triggerStateBeganTimer.start();
|
|
else
|
|
self.state = UIGestureRecognizerStateFailed;
|
|
}
|
|
|
|
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesMoved:touches withEvent:event];
|
|
_lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
|
|
|
|
if (self.state == UIGestureRecognizerStatePossible) {
|
|
// If the touch was moved too far before the timer triggered (meaning that this
|
|
// is a drag, not a press'n'hold), we should either fail, or trigger the gesture
|
|
// immediately, depending on self.dragTriggersGesture.
|
|
int startDragDistance = QGuiApplication::styleHints()->startDragDistance();
|
|
int dragDistance = hypot(_firstTouchPoint.x - _lastTouchPoint.x, _firstTouchPoint.y - _lastTouchPoint.y);
|
|
if (dragDistance > startDragDistance) {
|
|
_triggerStateBeganTimer.stop();
|
|
self.state = self.dragTriggersGesture ? UIGestureRecognizerStateBegan : UIGestureRecognizerStateFailed;
|
|
}
|
|
} else {
|
|
self.state = UIGestureRecognizerStateChanged;
|
|
}
|
|
}
|
|
|
|
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesEnded:touches withEvent:event];
|
|
_triggerStateBeganTimer.stop();
|
|
_lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
|
|
self.state = self.state == UIGestureRecognizerStatePossible ? UIGestureRecognizerStateFailed : UIGestureRecognizerStateEnded;
|
|
}
|
|
|
|
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesCancelled:touches withEvent:event];
|
|
_triggerStateBeganTimer.stop();
|
|
_lastTouchPoint = [static_cast<UITouch *>([touches anyObject]) locationInView:_focusView];
|
|
self.state = UIGestureRecognizerStateCancelled;
|
|
}
|
|
|
|
// Methods implemented by subclasses:
|
|
|
|
- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
|
|
{
|
|
Q_UNUSED(touchPoint)
|
|
Q_UNREACHABLE();
|
|
return NO;
|
|
}
|
|
|
|
- (QIOSLoupeLayer *)createLoupeLayer
|
|
{
|
|
Q_UNREACHABLE();
|
|
return nullptr;
|
|
}
|
|
|
|
- (void)updateFocalPoint:(QPointF)touchPoint
|
|
{
|
|
Q_UNUSED(touchPoint)
|
|
Q_UNREACHABLE();
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
This recognizer will be active when there's no selection. It will trigger if
|
|
the user does a press and hold, which will start a session where the user can move
|
|
the cursor around with his finger together with a magnifier glass.
|
|
*/
|
|
@interface QIOSCursorRecognizer : QIOSLoupeRecognizer
|
|
@end
|
|
|
|
@implementation QIOSCursorRecognizer
|
|
|
|
- (QIOSLoupeLayer *)createLoupeLayer
|
|
{
|
|
return [[[QIOSLoupeLayer alloc] initWithSize:CGSizeMake(120, 120) cornerRadius:60 bottomOffset:4] autorelease];
|
|
}
|
|
|
|
- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
|
|
{
|
|
QRectF inputRect = QGuiApplication::inputMethod()->inputItemClipRectangle();
|
|
return !hasSelection() && inputRect.contains(touchPoint);
|
|
}
|
|
|
|
- (void)updateFocalPoint:(QPointF)touchPoint
|
|
{
|
|
platformInputContext()->setSelectionOnFocusObject(touchPoint, touchPoint);
|
|
self.focalPoint = touchPoint;
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
This recognizer will watch for selections, and draw handles as overlay
|
|
on the sides. If the user starts dragging on a handle (or do a press and
|
|
hold), it will show a magnifier glass that follows the handle as it moves.
|
|
*/
|
|
@interface QIOSSelectionRecognizer : QIOSLoupeRecognizer
|
|
@end
|
|
|
|
@implementation QIOSSelectionRecognizer {
|
|
CALayer *_clipRectLayer;
|
|
QIOSHandleLayer *_cursorLayer;
|
|
QIOSHandleLayer *_anchorLayer;
|
|
QPointF _touchOffset;
|
|
bool _dragOnCursor;
|
|
bool _multiLine;
|
|
QTimer _updateSelectionTimer;
|
|
QMetaObject::Connection _cursorConnection;
|
|
QMetaObject::Connection _anchorConnection;
|
|
QMetaObject::Connection _clipRectConnection;
|
|
}
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super init]) {
|
|
self.delaysTouchesBegan = YES;
|
|
self.dragTriggersGesture = YES;
|
|
_multiLine = QInputMethod::queryFocusObject(Qt::ImHints, QVariant()).toUInt() & Qt::ImhMultiLine;
|
|
_updateSelectionTimer.setInterval(1);
|
|
_updateSelectionTimer.setSingleShot(true);
|
|
QObject::connect(&_updateSelectionTimer, &QTimer::timeout, [self](){ [self updateSelection]; });
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)setEnabled:(BOOL)enabled
|
|
{
|
|
if (qApp->property("disable-clipboard-menu").toBool()) // hack
|
|
enabled = NO;
|
|
|
|
if (enabled == self.enabled)
|
|
return;
|
|
|
|
[super setEnabled:enabled];
|
|
|
|
if (enabled) {
|
|
// Create a layer that clips the handles inside the input field
|
|
_clipRectLayer = [CALayer new];
|
|
_clipRectLayer.masksToBounds = YES;
|
|
[self.focusView.layer addSublayer:_clipRectLayer];
|
|
|
|
// Create the handle layers, and add them to the clipped input rect layer
|
|
_cursorLayer = [[[QIOSHandleLayer alloc] initWithKnobAtEdge:Qt::RightEdge] autorelease];
|
|
_anchorLayer = [[[QIOSHandleLayer alloc] initWithKnobAtEdge:Qt::LeftEdge] autorelease];
|
|
bool selection = hasSelection();
|
|
_cursorLayer.visible = selection;
|
|
_anchorLayer.visible = selection;
|
|
[_clipRectLayer addSublayer:_cursorLayer];
|
|
[_clipRectLayer addSublayer:_anchorLayer];
|
|
|
|
// iOS text input will sometimes set a temporary text selection to perform operations
|
|
// such as backspace (select last character + cut selection). To avoid briefly showing
|
|
// the selection handles for such cases, and to avoid calling updateSelection when
|
|
// both handles and clip rectangle change, we use a timer to wait a cycle before we update.
|
|
// (Note that since QTimer::start is overloaded, we need some extra syntax for the connections).
|
|
QInputMethod *im = QGuiApplication::inputMethod();
|
|
void(QTimer::*start)(void) = &QTimer::start;
|
|
_cursorConnection = QObject::connect(im, &QInputMethod::cursorRectangleChanged, &_updateSelectionTimer, start);
|
|
_anchorConnection = QObject::connect(im, &QInputMethod::anchorRectangleChanged, &_updateSelectionTimer, start);
|
|
_clipRectConnection = QObject::connect(im, &QInputMethod::inputItemClipRectangleChanged, &_updateSelectionTimer, start);
|
|
|
|
[self updateSelection];
|
|
} else {
|
|
// Fade out the handles by setting visible to NO, and wait for the animations
|
|
// to finish before removing the clip rect layer, including the handles.
|
|
// Create a local variable to hold the clipRectLayer while the animation is
|
|
// ongoing to ensure that any subsequent calls to setEnabled does not interfere.
|
|
// Also, declare it as __block to stop it from being automatically retained, which
|
|
// would cause a cyclic dependency between clipRectLayer and the block.
|
|
__block CALayer *clipRectLayer = _clipRectLayer;
|
|
__block int handleCount = 2;
|
|
Block block = ^{
|
|
if (--handleCount == 0) {
|
|
[clipRectLayer removeFromSuperlayer];
|
|
[clipRectLayer release];
|
|
}
|
|
};
|
|
|
|
_cursorLayer.onAnimationDidStop = block;
|
|
_anchorLayer.onAnimationDidStop = block;
|
|
_cursorLayer.visible = NO;
|
|
_anchorLayer.visible = NO;
|
|
|
|
_clipRectLayer = 0;
|
|
_cursorLayer = 0;
|
|
_anchorLayer = 0;
|
|
_updateSelectionTimer.stop();
|
|
|
|
QObject::disconnect(_cursorConnection);
|
|
QObject::disconnect(_anchorConnection);
|
|
QObject::disconnect(_clipRectConnection);
|
|
}
|
|
}
|
|
|
|
- (QIOSLoupeLayer *)createLoupeLayer
|
|
{
|
|
CGSize loupeSize = CGSizeMake(123, 33);
|
|
CGSize arrowSize = CGSizeMake(25, 12);
|
|
CGFloat loupeOffset = arrowSize.height + 20;
|
|
|
|
// Create loupe and arrow layers
|
|
QIOSLoupeLayer *loupeLayer = [[[QIOSLoupeLayer alloc] initWithSize:loupeSize cornerRadius:5 bottomOffset:loupeOffset] autorelease];
|
|
CAShapeLayer *arrowLayer = [[[CAShapeLayer alloc] init] autorelease];
|
|
|
|
// Build a triangular path to both draw and mask the arrow layer as a triangle
|
|
UIBezierPath *path = [[UIBezierPath new] autorelease];
|
|
[path moveToPoint:CGPointMake(0, 0)];
|
|
[path addLineToPoint:CGPointMake(arrowSize.width / 2, arrowSize.height)];
|
|
[path addLineToPoint:CGPointMake(arrowSize.width, 0)];
|
|
|
|
arrowLayer.frame = CGRectMake((loupeSize.width - arrowSize.width) / 2, loupeSize.height - 1, arrowSize.width, arrowSize.height);
|
|
arrowLayer.path = path.CGPath;
|
|
arrowLayer.backgroundColor = [[UIColor whiteColor] CGColor];
|
|
arrowLayer.strokeColor = [[UIColor lightGrayColor] CGColor];
|
|
arrowLayer.lineWidth = 0.75 * 2;
|
|
arrowLayer.fillColor = nil;
|
|
|
|
CAShapeLayer *mask = [[CAShapeLayer new] autorelease];
|
|
mask.frame = arrowLayer.bounds;
|
|
mask.path = path.CGPath;
|
|
arrowLayer.mask = mask;
|
|
|
|
[loupeLayer addSublayer:arrowLayer];
|
|
|
|
return loupeLayer;
|
|
}
|
|
|
|
- (BOOL)acceptTouchesBegan:(QPointF)touchPoint
|
|
{
|
|
if (!hasSelection())
|
|
return NO;
|
|
|
|
// Accept the touch if it "overlaps" with any of the handles
|
|
const int handleRadius = 50;
|
|
QPointF cursorCenter = qApp->inputMethod()->cursorRectangle().center();
|
|
QPointF anchorCenter = qApp->inputMethod()->anchorRectangle().center();
|
|
QPointF cursorOffset = QPointF(cursorCenter.x() - touchPoint.x(), cursorCenter.y() - touchPoint.y());
|
|
QPointF anchorOffset = QPointF(anchorCenter.x() - touchPoint.x(), anchorCenter.y() - touchPoint.y());
|
|
double cursorDist = hypot(cursorOffset.x(), cursorOffset.y());
|
|
double anchorDist = hypot(anchorOffset.x(), anchorOffset.y());
|
|
|
|
if (cursorDist > handleRadius && anchorDist > handleRadius)
|
|
return NO;
|
|
|
|
if (cursorDist < anchorDist) {
|
|
_touchOffset = cursorOffset;
|
|
_dragOnCursor = YES;
|
|
} else {
|
|
_touchOffset = anchorOffset;
|
|
_dragOnCursor = NO;
|
|
}
|
|
|
|
return YES;
|
|
}
|
|
|
|
- (void)updateFocalPoint:(QPointF)touchPoint
|
|
{
|
|
touchPoint += _touchOffset;
|
|
|
|
// Get the text position under the touch
|
|
SelectionPair selection = querySelection();
|
|
const QTransform mapToLocal = QGuiApplication::inputMethod()->inputItemTransform().inverted();
|
|
int touchTextPos = QInputMethod::queryFocusObject(Qt::ImCursorPosition, touchPoint * mapToLocal).toInt();
|
|
|
|
// Ensure that the handels cannot be dragged past each other
|
|
if (_dragOnCursor)
|
|
selection.second = (touchTextPos > selection.first) ? touchTextPos : selection.first + 1;
|
|
else
|
|
selection.first = (touchTextPos < selection.second) ? touchTextPos : selection.second - 1;
|
|
|
|
// Set new selection
|
|
QList<QInputMethodEvent::Attribute> imAttributes;
|
|
imAttributes.append(QInputMethodEvent::Attribute(
|
|
QInputMethodEvent::Selection, selection.first, selection.second - selection.first, QVariant()));
|
|
QInputMethodEvent event(QString(), imAttributes);
|
|
QGuiApplication::sendEvent(qApp->focusObject(), &event);
|
|
|
|
// Move loupe to new position
|
|
QRectF handleRect = _dragOnCursor ?
|
|
qApp->inputMethod()->cursorRectangle() :
|
|
qApp->inputMethod()->anchorRectangle();
|
|
self.focalPoint = QPointF(touchPoint.x(), handleRect.center().y());
|
|
}
|
|
|
|
- (void)updateSelection
|
|
{
|
|
if (!hasSelection()) {
|
|
if (_cursorLayer.visible) {
|
|
_cursorLayer.visible = NO;
|
|
_anchorLayer.visible = NO;
|
|
// Only hide the edit menu if we had a selection from before, since
|
|
// the edit menu can also be used for other purposes by others (in
|
|
// which case we try our best not to interfere).
|
|
QIOSTextInputOverlay::s_editMenu.visible = NO;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_cursorLayer.visible && QIOSTextInputOverlay::s_editMenu.isHiding) {
|
|
// Since the edit menu is hiding and this is the first selection thereafter, we
|
|
// assume that the selection came from the user tapping on a menu item. In that
|
|
// case, we reshow the menu after it has closed (but then with selection based
|
|
// menu items, as specified by first responder).
|
|
QIOSTextInputOverlay::s_editMenu.reshowAfterHidden = YES;
|
|
}
|
|
|
|
// Adjust handles and input rect to match the new selection
|
|
QRectF inputRect = QGuiApplication::inputMethod()->inputItemClipRectangle();
|
|
CGRect cursorRect = QGuiApplication::inputMethod()->cursorRectangle().toCGRect();
|
|
CGRect anchorRect = QGuiApplication::inputMethod()->anchorRectangle().toCGRect();
|
|
|
|
if (!_multiLine) {
|
|
// Resize the layer a bit bigger to ensure that the handles are
|
|
// not cut if if they are otherwise visible inside the clip rect.
|
|
int margin = kKnobWidth + 5;
|
|
inputRect.adjust(-margin / 2, -margin, margin / 2, margin);
|
|
}
|
|
|
|
executeBlockWithoutAnimation(^{ _clipRectLayer.frame = inputRect.toCGRect(); });
|
|
_cursorLayer.cursorRectangle = [self.focusView.layer convertRect:cursorRect toLayer:_clipRectLayer];
|
|
_anchorLayer.cursorRectangle = [self.focusView.layer convertRect:anchorRect toLayer:_clipRectLayer];
|
|
_cursorLayer.visible = YES;
|
|
_anchorLayer.visible = YES;
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
This recognizer will trigger if the user taps inside the edit rectangle.
|
|
If there's no selection, and the tap doesn't change the cursor position, the
|
|
visibility of the edit menu will be toggled. Otherwise, if there's a selection, a
|
|
first tap will close the edit menu (if any), and a second tap will remove the selection.
|
|
*/
|
|
@interface QIOSTapRecognizer : UITapGestureRecognizer
|
|
@end
|
|
|
|
@implementation QIOSTapRecognizer {
|
|
int _cursorPosOnPress;
|
|
UIView *_focusView;
|
|
}
|
|
|
|
- (instancetype)init
|
|
{
|
|
if (self = [super initWithTarget:self action:@selector(gestureStateChanged)]) {
|
|
self.enabled = NO;
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
- (void)setEnabled:(BOOL)enabled
|
|
{
|
|
if (qApp->property("disable-clipboard-menu").toBool()) // hack
|
|
enabled = NO;
|
|
|
|
if (enabled == self.enabled)
|
|
return;
|
|
|
|
[super setEnabled:enabled];
|
|
|
|
if (enabled) {
|
|
_focusView = [reinterpret_cast<UIView *>(qApp->focusWindow()->winId()) retain];
|
|
[_focusView addGestureRecognizer:self];
|
|
} else {
|
|
[_focusView removeGestureRecognizer:self];
|
|
[_focusView release];
|
|
_focusView = nil;
|
|
}
|
|
}
|
|
|
|
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
[super touchesBegan:touches withEvent:event];
|
|
|
|
if (hasSelection() && !QIOSTextInputOverlay::s_editMenu.isHiding) {
|
|
// If there's a selection and the menu is visible, UIKit will hide the menu on the
|
|
// first tap. But if we get a second tap while the menu is hidden, we choose to diverge
|
|
// a bit from native behavior and instead fail the tap and forward the touch
|
|
// to Qt. This will effectively move the cursor (and remove the selection).
|
|
// This is needed to ensure that the user can remove the selection at any time, but
|
|
// at the same time, also be able to tap on other items in the UI while keeping the
|
|
// selection (e.g make the selection bold by tapping on a bold button in the UI).
|
|
self.state = UIGestureRecognizerStateFailed;
|
|
return;
|
|
}
|
|
|
|
QRectF inputRect = QGuiApplication::inputMethod()->inputItemClipRectangle();
|
|
QPointF touchPos = QPointF::fromCGPoint([static_cast<UITouch *>([touches anyObject]) locationInView:_focusView]);
|
|
if (!inputRect.contains(touchPos))
|
|
self.state = UIGestureRecognizerStateFailed;
|
|
|
|
_cursorPosOnPress = QInputMethod::queryFocusObject(Qt::ImCursorPosition, QVariant()).toInt();
|
|
}
|
|
|
|
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
|
|
{
|
|
QPointF touchPos = QPointF::fromCGPoint([static_cast<UITouch *>([touches anyObject]) locationInView:_focusView]);
|
|
const QTransform mapToLocal = QGuiApplication::inputMethod()->inputItemTransform().inverted();
|
|
int cursorPosOnRelease = QInputMethod::queryFocusObject(Qt::ImCursorPosition, touchPos * mapToLocal).toInt();
|
|
|
|
if (!QIOSTextInputOverlay::s_editMenu.isHiding && cursorPosOnRelease != _cursorPosOnPress) {
|
|
// We also want to track if the user taps on the screen to close the edit menu. And
|
|
// the way we detect that is to check if the edit menu is hiding when we receive this
|
|
// call. If that's the case, we leave the state as-is to allow a tap to be recognized.
|
|
// Otherwise, if we also see that the cursor will change position, we fail, so that
|
|
// touch events for Qt are not cancelled.
|
|
self.state = UIGestureRecognizerStateFailed;
|
|
}
|
|
|
|
[super touchesEnded:touches withEvent:event];
|
|
}
|
|
|
|
- (void)gestureStateChanged
|
|
{
|
|
if (self.state != UIGestureRecognizerStateEnded)
|
|
return;
|
|
|
|
if (QIOSTextInputOverlay::s_editMenu.isHiding) {
|
|
// Closing the menu is what we want for the first tap, so just return
|
|
return;
|
|
}
|
|
|
|
QIOSTextInputOverlay::s_editMenu.visible = !QIOSTextInputOverlay::s_editMenu.visible;
|
|
}
|
|
|
|
@end
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
QT_BEGIN_NAMESPACE
|
|
|
|
QIOSEditMenu *QIOSTextInputOverlay::s_editMenu = nullptr;
|
|
|
|
QIOSTextInputOverlay::QIOSTextInputOverlay()
|
|
: m_cursorRecognizer(nullptr)
|
|
, m_selectionRecognizer(nullptr)
|
|
, m_openMenuOnTapRecognizer(nullptr)
|
|
{
|
|
if (qt_apple_isApplicationExtension()) {
|
|
qWarning() << "text input overlays disabled in application extensions";
|
|
return;
|
|
}
|
|
|
|
connect(qApp, &QGuiApplication::focusObjectChanged, this, &QIOSTextInputOverlay::updateFocusObject);
|
|
}
|
|
|
|
QIOSTextInputOverlay::~QIOSTextInputOverlay()
|
|
{
|
|
if (qApp)
|
|
disconnect(qApp, 0, this, 0);
|
|
}
|
|
|
|
void QIOSTextInputOverlay::updateFocusObject()
|
|
{
|
|
if (m_cursorRecognizer) {
|
|
// Destroy old recognizers since they were created with
|
|
// dependencies to the old focus object (focus view).
|
|
m_cursorRecognizer.enabled = NO;
|
|
m_selectionRecognizer.enabled = NO;
|
|
m_openMenuOnTapRecognizer.enabled = NO;
|
|
[m_cursorRecognizer release];
|
|
[m_selectionRecognizer release];
|
|
[m_openMenuOnTapRecognizer release];
|
|
[s_editMenu release];
|
|
m_cursorRecognizer = nullptr;
|
|
m_selectionRecognizer = nullptr;
|
|
m_openMenuOnTapRecognizer = nullptr;
|
|
s_editMenu = nullptr;
|
|
}
|
|
|
|
if (platformInputContext()->inputMethodAccepted()) {
|
|
s_editMenu = [QIOSEditMenu new];
|
|
m_cursorRecognizer = [QIOSCursorRecognizer new];
|
|
m_selectionRecognizer = [QIOSSelectionRecognizer new];
|
|
m_openMenuOnTapRecognizer = [QIOSTapRecognizer new];
|
|
m_cursorRecognizer.enabled = YES;
|
|
m_selectionRecognizer.enabled = YES;
|
|
m_openMenuOnTapRecognizer.enabled = YES;
|
|
}
|
|
}
|
|
|
|
QT_END_NAMESPACE
|