Drop use of the Qt gestures system

It is a buggy mess, so implement our own gesture recognizer based on the
raw touch events
This commit is contained in:
Kovid Goyal 2017-07-31 14:01:11 +05:30
parent d095bd4f83
commit 0a62b1f98d
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 227 additions and 67 deletions

View File

@ -0,0 +1,217 @@
#!/usr/bin/env python2
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
from __future__ import absolute_import, division, print_function, unicode_literals
import sys
from functools import partial
from PyQt5.Qt import (
QApplication, QEvent, QMouseEvent, QObject, QPointF, Qt, QTouchDevice,
pyqtSignal
)
from calibre.constants import iswindows
from calibre.utils.monotonic import monotonic
touch_supported = False
if iswindows and sys.getwindowsversion()[:2] >= (6, 2): # At least windows 7
touch_supported = True
HOLD_THRESHOLD = 1.0 # seconds
TAP_THRESHOLD = 50 # manhattan pixels
FLICK_DISTANCE = 100 # manhattan pixels
Tap, TapAndHold, Flick = 'Tap', 'TapAndHold', 'Flick'
Left, Right, Up, Down = 'Left', 'Right', 'Up', 'Down'
class TouchPoint(object):
def __init__(self, tp):
self.creation_time = self.last_update_time = self.time_of_last_move = monotonic()
self.start_screen_position = self.current_screen_position = self.previous_screen_position = QPointF(tp.screenPos())
self.time_since_last_update = -1
self.total_movement = 0
def update(self, tp):
now = monotonic()
self.time_since_last_update = now - self.last_update_time
self.last_update_time = now
self.previous_screen_position, self.current_screen_position = self.current_screen_position, QPointF(tp.screenPos())
movement = (self.current_screen_position - self.previous_screen_position).manhattanLength()
self.total_movement += movement
if movement > 5:
self.time_of_last_move = now
@property
def flick_type(self):
x_movement = self.current_screen_position.x() - self.start_screen_position.x()
y_movement = self.current_screen_position.y() - self.start_screen_position.y()
xabs, yabs = map(abs, (x_movement, y_movement))
if max(xabs, yabs) < FLICK_DISTANCE or min(xabs/max(yabs, 0.01), yabs/max(xabs, 0.01)) > 0.3:
return
d = x_movement if xabs > yabs else y_movement
axis = (Left, Right) if xabs > yabs else (Up, Down)
return axis[0 if d < 0 else 1]
@property
def flick_live(self):
x_movement = self.current_screen_position.x() - self.previous_screen_position.x()
y_movement = self.current_screen_position.y() - self.previous_screen_position.y()
return x_movement, y_movement
class State(QObject):
tapped = pyqtSignal(object)
flicked = pyqtSignal(object)
flicking = pyqtSignal(object, object)
tap_hold_started = pyqtSignal(object)
tap_hold_updated = pyqtSignal(object)
tap_hold_finished = pyqtSignal(object)
def __init__(self):
QObject.__init__(self)
self.clear()
def clear(self):
self.possible_gestures = set()
self.touch_points = {}
self.hold_started = False
self.hold_data = None
def start(self):
self.clear()
self.possible_gestures = {Tap, TapAndHold, Flick}
def update(self, ev, boundary='update'):
if boundary == 'start':
self.start()
for tp in ev.touchPoints():
tpid = tp.id()
if tpid not in self.touch_points:
self.touch_points[tpid] = TouchPoint(tp)
else:
self.touch_points[tpid].update(tp)
if len(self.touch_points) > 1:
self.possible_gestures.clear()
if boundary == 'end':
self.check_for_holds()
self.finalize()
self.clear()
else:
self.check_for_holds()
if {Flick} & self.possible_gestures:
tp = next(self.touch_points.itervalues())
self.flicking.emit(*tp.flick_live)
def check_for_holds(self):
if not {TapAndHold} & self.possible_gestures:
return
now = monotonic()
tp = next(self.touch_points.itervalues())
if now - tp.time_of_last_move < HOLD_THRESHOLD:
return
if self.hold_started:
if TapAndHold in self.possible_gestures:
self.tap_hold_updated.emit(tp)
else:
self.possible_gestures &= {TapAndHold}
if tp.total_movement > TAP_THRESHOLD:
self.possible_gestures.clear()
else:
self.possible_gestures = {TapAndHold}
self.hold_started = True
self.hold_data = now
self.tap_hold_started.emit(tp)
def finalize(self):
if Tap in self.possible_gestures:
tp = next(self.touch_points.itervalues())
if tp.total_movement <= TAP_THRESHOLD:
self.tapped.emit(tp)
return
if Flick in self.possible_gestures:
tp = next(self.touch_points.itervalues())
st = tp.flick_type
if st is not None:
self.flicked.emit(st)
return
if not self.hold_started:
return
if TapAndHold in self.possible_gestures:
tp = next(self.touch_points.itervalues())
self.tap_hold_finished.emit(tp)
return
def send_click(view, pos, button=Qt.LeftButton, double_click=False):
if double_click:
ev = QMouseEvent(QEvent.MouseButtonDblClick, pos, button, button, QApplication.keyboardModifiers())
QApplication.postEvent(view.viewport(), ev)
return
ev = QMouseEvent(QEvent.MouseButtonPress, pos, button, button, QApplication.keyboardModifiers())
QApplication.postEvent(view.viewport(), ev)
ev = QMouseEvent(QEvent.MouseButtonRelease, pos, button, button, QApplication.keyboardModifiers())
QApplication.postEvent(view.viewport(), ev)
class GestureManager(QObject):
def __init__(self, view):
QObject.__init__(self, view)
self.state = State()
self.state.flicked.connect(self.handle_flick)
self.state.tapped.connect(self.handle_tap)
self.state.flicking.connect(self.handle_flicking)
self.state.tap_hold_started.connect(partial(self.handle_tap_hold, 'start'))
self.state.tap_hold_updated.connect(partial(self.handle_tap_hold, 'update'))
self.state.tap_hold_finished.connect(partial(self.handle_tap_hold, 'end'))
self.evmap = {QEvent.TouchBegin: 'start', QEvent.TouchUpdate: 'update', QEvent.TouchEnd: 'end'}
def handle_event(self, ev):
if not touch_supported:
return
etype = ev.type()
if etype in (QEvent.MouseButtonPress, QEvent.MouseMove, QEvent.MouseButtonRelease, QEvent.MouseButtonDblClick):
if ev.source() in (Qt.MouseEventSynthesizedBySystem, Qt.MouseEventSynthesizedByQt):
# swallow fake mouse events generated from touch events
ev.ignore()
return False
return
boundary = self.evmap.get(etype, None)
if boundary is None or ev.device().type() != QTouchDevice.TouchScreen:
return
self.state.update(ev, boundary=boundary)
ev.accept()
return True
def close_open_menu(self):
m = getattr(self.parent(), 'context_menu', None)
if m is not None and m.isVisible():
m.close()
return True
def handle_flick(self, direction):
if self.close_open_menu():
return
raise NotImplementedError('TODO: Implement')
def handle_flicking(self, x, y):
raise NotImplementedError('TODO: Implement')
def handle_tap(self, tp):
if self.close_open_menu():
return
raise NotImplementedError('TODO: Implement')
def handle_tap_hold(self, action, tp):
if action == 'end':
raise NotImplementedError('TODO: Implement')

View File

@ -15,18 +15,18 @@ from textwrap import wrap
from PyQt5.Qt import (
QListView, QSize, QStyledItemDelegate, QModelIndex, Qt, QImage, pyqtSignal,
QTimer, QPalette, QColor, QItemSelection, QPixmap, QApplication, QScroller,
QTimer, QPalette, QColor, QItemSelection, QPixmap, QApplication,
QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent,
QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView,
QStyleOptionViewItem, QToolTip, QByteArray, QBuffer, QBrush, qRed, qGreen,
qBlue, QItemSelectionModel, QIcon, QFont, QMouseEvent)
qBlue, QItemSelectionModel, QIcon, QFont)
from calibre import fit_image, prints, prepare_string_for_xml, human_readable
from calibre.constants import DEBUG, config_dir, islinux, iswindows
from calibre.constants import DEBUG, config_dir, islinux
from calibre.ebooks.metadata import fmt_sidx, rating_to_stars
from calibre.utils import join_with_timeout
from calibre.utils.monotonic import monotonic
from calibre.gui2 import gprefs, config, rating_font, empty_index
from calibre.gui2.gestures import GestureManager
from calibre.gui2.library.caches import CoverCache, ThumbnailCache
from calibre.utils.config import prefs, tweaks
@ -626,66 +626,8 @@ class CoverDelegate(QStyledItemDelegate):
# }}}
has_gestures = iswindows
# Enabling gesture support on X11/OS X causes Qt to send fake touch events when
# right clicking/dragging with the mouse, which breaks things, for example:
# https://bugs.launchpad.net/bugs/1707282 and
# https://www.mobileread.com/forums/showthread.php?t=289057
def send_click(view, pos, button=Qt.LeftButton, double_click=False):
if double_click:
ev = QMouseEvent(QEvent.MouseButtonDblClick, pos, button, button, QApplication.keyboardModifiers())
QApplication.postEvent(view.viewport(), ev)
return
ev = QMouseEvent(QEvent.MouseButtonPress, pos, button, button, QApplication.keyboardModifiers())
QApplication.postEvent(view.viewport(), ev)
ev = QMouseEvent(QEvent.MouseButtonRelease, pos, button, button, QApplication.keyboardModifiers())
QApplication.postEvent(view.viewport(), ev)
def handle_gesture(ev, view):
tap = ev.gesture(Qt.TapGesture)
if tap and tap.state() == Qt.GestureFinished:
p, view.last_tap_at = view.last_tap_at, monotonic()
interval = QApplication.instance().doubleClickInterval() / 1000
double_click = monotonic() - p < interval
send_click(view, tap.position(), double_click=double_click)
ev.accept(Qt.TapGesture)
return True
th = ev.gesture(Qt.TapAndHoldGesture)
if th and th.state() in (Qt.GestureStarted, Qt.GestureUpdated, Qt.GestureFinished):
if th.state() == Qt.GestureFinished:
send_click(view, th.position(), button=Qt.RightButton)
ev.accept(Qt.TapAndHoldGesture)
return True
return True
def setup_gestures(view):
if has_gestures:
v = view.viewport()
view.scroller = QScroller.grabGesture(v, QScroller.TouchGesture)
v.grabGesture(Qt.TapGesture)
v.grabGesture(Qt.TapAndHoldGesture)
view.last_tap_at = 0
def gesture_viewport_event(view, ev):
if not has_gestures:
return
et = ev.type()
if et in (QEvent.MouseButtonPress, QEvent.MouseMove, QEvent.MouseButtonRelease, QEvent.MouseButtonDblClick):
if ev.source() in (Qt.MouseEventSynthesizedBySystem, Qt.MouseEventSynthesizedByQt):
ev.ignore()
return False
elif et == QEvent.Gesture:
return handle_gesture(ev, view)
# The View {{{
@setup_dnd_interface
class GridView(QListView):
@ -694,7 +636,7 @@ class GridView(QListView):
def __init__(self, parent):
QListView.__init__(self, parent)
setup_gestures(self)
self.gesture_manager = GestureManager(self)
setup_dnd_interface(self)
self.setUniformItemSizes(True)
self.setWrapping(True)
@ -731,7 +673,7 @@ class GridView(QListView):
t.timeout.connect(self.update_memory_cover_cache_size)
def viewportEvent(self, ev):
ret = gesture_viewport_event(self, ev)
ret = self.gesture_manager.handle_event(ev)
if ret is not None:
return ret
return QListView.viewportEvent(self, ev)

View File

@ -21,7 +21,8 @@ from calibre.gui2.library.delegates import (RatingDelegate, PubDateDelegate,
CcBoolDelegate, CcCommentsDelegate, CcDateDelegate, CcTemplateDelegate,
CcEnumDelegate, CcNumberDelegate, LanguagesDelegate)
from calibre.gui2.library.models import BooksModel, DeviceBooksModel
from calibre.gui2.library.alternate_views import AlternateViews, setup_dnd_interface, setup_gestures, gesture_viewport_event
from calibre.gui2.library.alternate_views import AlternateViews, setup_dnd_interface
from calibre.gui2.gestures import GestureManager
from calibre.utils.config import tweaks, prefs
from calibre.gui2 import error_dialog, gprefs, FunctionDispatcher
from calibre.gui2.library import DEFAULT_SORT
@ -193,14 +194,14 @@ class BooksView(QTableView): # {{{
def viewportEvent(self, event):
if (event.type() == event.ToolTip and not gprefs['book_list_tooltips']):
return False
ret = gesture_viewport_event(self, event)
ret = self.gesture_manager.handle_event(event)
if ret is not None:
return ret
return QTableView.viewportEvent(self, event)
def __init__(self, parent, modelcls=BooksModel, use_edit_metadata_dialog=True):
QTableView.__init__(self, parent)
setup_gestures(self)
self.gesture_manager = GestureManager(self)
self.default_row_height = self.verticalHeader().defaultSectionSize()
self.gui = parent
self.setProperty('highlight_current_item', 150)