From 0a62b1f98d11d3d8c91a0ee4dca2740b55b4b7c3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 31 Jul 2017 14:01:11 +0530 Subject: [PATCH] Drop use of the Qt gestures system It is a buggy mess, so implement our own gesture recognizer based on the raw touch events --- src/calibre/gui2/gestures.py | 217 ++++++++++++++++++++ src/calibre/gui2/library/alternate_views.py | 70 +------ src/calibre/gui2/library/views.py | 7 +- 3 files changed, 227 insertions(+), 67 deletions(-) create mode 100644 src/calibre/gui2/gestures.py diff --git a/src/calibre/gui2/gestures.py b/src/calibre/gui2/gestures.py new file mode 100644 index 0000000000..5d7969b531 --- /dev/null +++ b/src/calibre/gui2/gestures.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2017, Kovid Goyal +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') diff --git a/src/calibre/gui2/library/alternate_views.py b/src/calibre/gui2/library/alternate_views.py index 2294c4ae9c..f604cd0d12 100644 --- a/src/calibre/gui2/library/alternate_views.py +++ b/src/calibre/gui2/library/alternate_views.py @@ -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) diff --git a/src/calibre/gui2/library/views.py b/src/calibre/gui2/library/views.py index 64ca6edce9..f1acb55309 100644 --- a/src/calibre/gui2/library/views.py +++ b/src/calibre/gui2/library/views.py @@ -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)