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 ( from PyQt5.Qt import (
QListView, QSize, QStyledItemDelegate, QModelIndex, Qt, QImage, pyqtSignal, 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, QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent,
QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView, QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView,
QStyleOptionViewItem, QToolTip, QByteArray, QBuffer, QBrush, qRed, qGreen, 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 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.ebooks.metadata import fmt_sidx, rating_to_stars
from calibre.utils import join_with_timeout 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 import gprefs, config, rating_font, empty_index
from calibre.gui2.gestures import GestureManager
from calibre.gui2.library.caches import CoverCache, ThumbnailCache from calibre.gui2.library.caches import CoverCache, ThumbnailCache
from calibre.utils.config import prefs, tweaks 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 {{{ # The View {{{
@setup_dnd_interface @setup_dnd_interface
class GridView(QListView): class GridView(QListView):
@ -694,7 +636,7 @@ class GridView(QListView):
def __init__(self, parent): def __init__(self, parent):
QListView.__init__(self, parent) QListView.__init__(self, parent)
setup_gestures(self) self.gesture_manager = GestureManager(self)
setup_dnd_interface(self) setup_dnd_interface(self)
self.setUniformItemSizes(True) self.setUniformItemSizes(True)
self.setWrapping(True) self.setWrapping(True)
@ -731,7 +673,7 @@ class GridView(QListView):
t.timeout.connect(self.update_memory_cover_cache_size) t.timeout.connect(self.update_memory_cover_cache_size)
def viewportEvent(self, ev): def viewportEvent(self, ev):
ret = gesture_viewport_event(self, ev) ret = self.gesture_manager.handle_event(ev)
if ret is not None: if ret is not None:
return ret return ret
return QListView.viewportEvent(self, ev) return QListView.viewportEvent(self, ev)

View File

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