~ubuntu-branches/debian/sid/calibre/sid

« back to all changes in this revision

Viewing changes to src/calibre/gui2/viewer/gestures.py

  • Committer: Package Import Robot
  • Author(s): Martin Pitt
  • Date: 2014-02-27 07:48:06 UTC
  • mto: This revision was merged to the branch mainline in revision 74.
  • Revision ID: package-import@ubuntu.com-20140227074806-64wdebb3ptosxhhx
Tags: upstream-1.25.0+dfsg
ImportĀ upstreamĀ versionĀ 1.25.0+dfsg

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#!/usr/bin/env python
 
2
# vim:fileencoding=utf-8
 
3
from __future__ import (unicode_literals, division, absolute_import,
 
4
                        print_function)
 
5
 
 
6
__license__ = 'GPL v3'
 
7
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
 
8
 
 
9
import time, ctypes, sys
 
10
from functools import partial
 
11
from PyQt4.Qt import (
 
12
    QObject, QPointF, pyqtSignal, QEvent, QApplication, QMouseEvent, Qt,
 
13
    QContextMenuEvent, QDialog, QDialogButtonBox, QLabel, QVBoxLayout)
 
14
 
 
15
from calibre.constants import iswindows
 
16
 
 
17
touch_supported = False
 
18
if iswindows and sys.getwindowsversion()[:2] >= (6, 2):  # At least windows 7
 
19
    from ctypes import wintypes
 
20
    try:
 
21
        RegisterTouchWindow = ctypes.windll.user32.RegisterTouchWindow
 
22
        RegisterTouchWindow.argtypes = (wintypes.HWND, wintypes.ULONG)
 
23
        RegisterTouchWindow.restype = wintypes.BOOL
 
24
        touch_supported = True
 
25
    except Exception:
 
26
        pass
 
27
 
 
28
SWIPE_HOLD_INTERVAL = 0.5  # seconds
 
29
HOLD_THRESHOLD = 1.0  # seconds
 
30
TAP_THRESHOLD  = 50   # manhattan pixels
 
31
SWIPE_DISTANCE = 100  # manhattan pixels
 
32
PINCH_CENTER_THRESHOLD = 150  # manhattan pixels
 
33
PINCH_SQUEEZE_FACTOR = 2.5  # smaller length must be less that larger length / squeeze factor
 
34
 
 
35
Tap, TapAndHold, Pinch, Swipe, SwipeAndHold = 'Tap', 'TapAndHold', 'Pinch', 'Swipe', 'SwipeAndHold'
 
36
Left, Right, Up, Down = 'Left', 'Right', 'Up', 'Down'
 
37
In, Out = 'In', 'Out'
 
38
 
 
39
class Help(QDialog):  # {{{
 
40
 
 
41
    def __init__(self, parent=None):
 
42
        QDialog.__init__(self, parent=parent)
 
43
        self.l = l = QVBoxLayout(self)
 
44
        self.setLayout(l)
 
45
 
 
46
        self.la = la = QLabel(
 
47
        '''
 
48
            <style>
 
49
            h2 { text-align: center }
 
50
            dt { font-weight: bold }
 
51
            dd { margin-bottom: 1.5em }
 
52
            </style>
 
53
 
 
54
        ''' + _(
 
55
            '''
 
56
            <h2>The list of available gestures</h2>
 
57
            <dl>
 
58
            <dt>Single tap</dt>
 
59
            <dd>A single tap on the right two thirds of the page will turn to the next page
 
60
            and on the left one-third of the page will turn to the previous page. Single tapping
 
61
            on a link will activate the link.</dd>
 
62
 
 
63
            <dt>Swipe</dt>
 
64
            <dd>Swipe to the left to go to the next page and to the right to go to the previous page.
 
65
            This mimics turning pages in a paper book.</dd>
 
66
 
 
67
            <dt>Pinch</dt>
 
68
            <dd>Pinch in or out to decrease or increase the font size</dd>
 
69
 
 
70
            <dt>Swipe and hold</dt>
 
71
            <dd>If you swipe and the hold your finger down instead of lifting it, pages will be turned
 
72
            rapidly allowing for quickly scanning through large numbers of pages.</dd>
 
73
 
 
74
            <dt>Tap and hold</dt>
 
75
            <dd>Bring up the context (right-click) menu</dd>
 
76
            </dl>
 
77
            '''
 
78
        ))
 
79
        la.setAlignment(Qt.AlignTop | Qt.AlignLeft)
 
80
        la.setWordWrap(True)
 
81
        l.addWidget(la, Qt.AlignTop|Qt.AlignLeft)
 
82
        self.bb = bb = QDialogButtonBox(QDialogButtonBox.Close)
 
83
        bb.accepted.connect(self.accept)
 
84
        bb.rejected.connect(self.reject)
 
85
        l.addWidget(bb)
 
86
        self.resize(600, 500)
 
87
# }}}
 
88
 
 
89
class TouchPoint(object):
 
90
 
 
91
    def __init__(self, tp):
 
92
        self.creation_time = self.last_update_time = self.time_of_last_move = time.time()
 
93
        self.start_screen_position = self.current_screen_position = self.previous_screen_position = QPointF(tp.screenPos())
 
94
        self.time_since_last_update = -1
 
95
        self.total_movement = 0
 
96
 
 
97
    def update(self, tp):
 
98
        now = time.time()
 
99
        self.time_since_last_update = now - self.last_update_time
 
100
        self.last_update_time = now
 
101
        self.previous_screen_position, self.current_screen_position = self.current_screen_position, QPointF(tp.screenPos())
 
102
        movement = (self.current_screen_position - self.previous_screen_position).manhattanLength()
 
103
        self.total_movement += movement
 
104
        if movement > 5:
 
105
            self.time_of_last_move = now
 
106
 
 
107
    @property
 
108
    def swipe_type(self):
 
109
        x_movement = self.current_screen_position.x() - self.start_screen_position.x()
 
110
        y_movement = self.current_screen_position.y() - self.start_screen_position.y()
 
111
        xabs, yabs = map(abs, (x_movement, y_movement))
 
112
        if max(xabs, yabs) < SWIPE_DISTANCE or min(xabs/max(yabs, 0.01), yabs/max(xabs, 0.01)) > 0.3:
 
113
            return
 
114
        d = x_movement if xabs > yabs else y_movement
 
115
        axis = (Left, Right) if xabs > yabs else (Up, Down)
 
116
        return axis[0 if d < 0 else 1]
 
117
 
 
118
def get_pinch(p1, p2):
 
119
    starts = [p1.start_screen_position, p2.start_screen_position]
 
120
    ends = [p1.current_screen_position, p2.current_screen_position]
 
121
    start_center = (starts[0] + starts[1]) / 2.0
 
122
    end_center = (ends[0] + ends[1]) / 2.0
 
123
    if (start_center - end_center).manhattanLength() > PINCH_CENTER_THRESHOLD:
 
124
        return None
 
125
    start_length = (starts[0] - starts[1]).manhattanLength()
 
126
    end_length = (ends[0] - ends[1]).manhattanLength()
 
127
    if min(start_length, end_length) > max(start_length, end_length) / PINCH_SQUEEZE_FACTOR:
 
128
        return None
 
129
    return In if start_length > end_length else Out
 
130
 
 
131
class State(QObject):
 
132
 
 
133
    tapped = pyqtSignal(object)
 
134
    swiped = pyqtSignal(object)
 
135
    pinched = pyqtSignal(object)
 
136
    tap_hold_started = pyqtSignal(object)
 
137
    tap_hold_updated = pyqtSignal(object)
 
138
    swipe_hold_started = pyqtSignal(object)
 
139
    swipe_hold_updated = pyqtSignal(object)
 
140
    tap_hold_finished = pyqtSignal(object)
 
141
    swipe_hold_finished = pyqtSignal(object)
 
142
 
 
143
    def __init__(self):
 
144
        QObject.__init__(self)
 
145
        self.clear()
 
146
 
 
147
    def clear(self):
 
148
        self.possible_gestures = set()
 
149
        self.touch_points = {}
 
150
        self.hold_started = False
 
151
        self.hold_data = None
 
152
 
 
153
    def start(self):
 
154
        self.clear()
 
155
        self.possible_gestures = {Tap, TapAndHold, Swipe, Pinch, SwipeAndHold}
 
156
 
 
157
    def update(self, ev, boundary='update'):
 
158
        if boundary == 'start':
 
159
            self.start()
 
160
 
 
161
        for tp in ev.touchPoints():
 
162
            tpid = tp.id()
 
163
            if tpid not in self.touch_points:
 
164
                self.touch_points[tpid] = TouchPoint(tp)
 
165
            else:
 
166
                self.touch_points[tpid].update(tp)
 
167
 
 
168
        if len(self.touch_points) > 2:
 
169
            self.possible_gestures.clear()
 
170
        elif len(self.touch_points) > 1:
 
171
            self.possible_gestures &= {Pinch}
 
172
 
 
173
        if boundary == 'end':
 
174
            self.finalize()
 
175
            self.clear()
 
176
        else:
 
177
            self.check_for_holds()
 
178
 
 
179
    def check_for_holds(self):
 
180
        if not {SwipeAndHold, TapAndHold} & self.possible_gestures:
 
181
            return
 
182
        now = time.time()
 
183
        tp = next(self.touch_points.itervalues())
 
184
        if now - tp.time_of_last_move < HOLD_THRESHOLD:
 
185
            return
 
186
        if self.hold_started:
 
187
            if TapAndHold in self.possible_gestures:
 
188
                self.tap_hold_updated.emit(tp)
 
189
            if SwipeAndHold in self.possible_gestures:
 
190
                self.swipe_hold_updated.emit(self.hold_data[1])
 
191
        else:
 
192
            self.possible_gestures &= {TapAndHold, SwipeAndHold}
 
193
            if tp.total_movement > TAP_THRESHOLD:
 
194
                st = tp.swipe_type
 
195
                if st is None:
 
196
                    self.possible_gestures.clear()
 
197
                else:
 
198
                    self.hold_started = True
 
199
                    self.possible_gestures = {SwipeAndHold}
 
200
                    self.hold_data = (now, st)
 
201
                    self.swipe_hold_started.emit(st)
 
202
            else:
 
203
                self.possible_gestures = {TapAndHold}
 
204
                self.hold_started = True
 
205
                self.hold_data = now
 
206
                self.tap_hold_started.emit(tp)
 
207
 
 
208
    def finalize(self):
 
209
        if Tap in self.possible_gestures:
 
210
            tp = next(self.touch_points.itervalues())
 
211
            if tp.total_movement <= TAP_THRESHOLD:
 
212
                self.tapped.emit(tp)
 
213
                return
 
214
 
 
215
        if Swipe in self.possible_gestures:
 
216
            tp = next(self.touch_points.itervalues())
 
217
            st = tp.swipe_type
 
218
            if st is not None:
 
219
                self.swiped.emit(st)
 
220
                return
 
221
 
 
222
        if Pinch in self.possible_gestures:
 
223
            points = tuple(self.touch_points.itervalues())
 
224
            if len(points) == 2:
 
225
                pinch_dir = get_pinch(*points)
 
226
                if pinch_dir is not None:
 
227
                    self.pinched.emit(pinch_dir)
 
228
 
 
229
        if not self.hold_started:
 
230
            return
 
231
 
 
232
        if TapAndHold in self.possible_gestures:
 
233
            tp = next(self.touch_points.itervalues())
 
234
            self.tap_hold_finished.emit(tp)
 
235
            return
 
236
 
 
237
        if SwipeAndHold in self.possible_gestures:
 
238
            self.swipe_hold_finished.emit(self.hold_data[1])
 
239
            return
 
240
 
 
241
 
 
242
class GestureHandler(QObject):
 
243
 
 
244
    def __init__(self, view):
 
245
        QObject.__init__(self, view)
 
246
        self.state = State()
 
247
        self.last_swipe_hold_update = None
 
248
        self.state.swiped.connect(self.handle_swipe)
 
249
        self.state.tapped.connect(self.handle_tap)
 
250
        self.state.tap_hold_started.connect(partial(self.handle_tap_hold, 'start'))
 
251
        self.state.tap_hold_updated.connect(partial(self.handle_tap_hold, 'update'))
 
252
        self.state.tap_hold_finished.connect(partial(self.handle_tap_hold, 'end'))
 
253
        self.state.swipe_hold_started.connect(partial(self.handle_swipe_hold, 'start'))
 
254
        self.state.swipe_hold_updated.connect(partial(self.handle_swipe_hold, 'update'))
 
255
        self.state.swipe_hold_finished.connect(partial(self.handle_swipe_hold, 'end'))
 
256
        self.state.pinched.connect(self.handle_pinch)
 
257
        self.evmap = {QEvent.TouchBegin: 'start', QEvent.TouchUpdate: 'update', QEvent.TouchEnd: 'end'}
 
258
 
 
259
        # Ignore fake mouse events generated by the window system from touch
 
260
        # events. At least on windows, we know how to identify these fake
 
261
        # events. See http://msdn.microsoft.com/en-us/library/windows/desktop/ms703320(v=vs.85).aspx
 
262
        self.is_fake_mouse_event = lambda : False
 
263
        if touch_supported and iswindows:
 
264
            MI_WP_SIGNATURE = 0xFF515700
 
265
            SIGNATURE_MASK = 0xFFFFFF00
 
266
            try:
 
267
                f = ctypes.windll.user32.GetMessageExtraInfo
 
268
                f.restype = wintypes.LPARAM
 
269
                def is_fake_mouse_event():
 
270
                    val = f()
 
271
                    ans = (val & SIGNATURE_MASK) == MI_WP_SIGNATURE
 
272
                    return ans
 
273
                self.is_fake_mouse_event = is_fake_mouse_event
 
274
                QApplication.instance().focusChanged.connect(self.register_for_wm_touch)
 
275
            except Exception:
 
276
                import traceback
 
277
                traceback.print_exc()
 
278
 
 
279
    def register_for_wm_touch(self, *args):
 
280
        if touch_supported and iswindows:
 
281
            # For some reason performing certain actions like toggling the ToC
 
282
            # view causes windows to stop sending WM_TOUCH events. This works
 
283
            # around that bug.
 
284
            # This might need to be changed for Qt 5 and effectivewinid returns
 
285
            # a different kind of object.
 
286
            hwnd = int(self.parent().effectiveWinId())
 
287
            RegisterTouchWindow(hwnd, 0)
 
288
 
 
289
    def __call__(self, ev):
 
290
        if not touch_supported:
 
291
            return False
 
292
        etype = ev.type()
 
293
        if etype in (
 
294
                QEvent.MouseMove, QEvent.MouseButtonPress,
 
295
                QEvent.MouseButtonRelease, QEvent.MouseButtonDblClick,
 
296
                QEvent.ContextMenu) and self.is_fake_mouse_event():
 
297
            # swallow fake mouse events that the windowing system generates from touch events
 
298
            ev.accept()
 
299
            return True
 
300
        boundary = self.evmap.get(etype, None)
 
301
        if boundary is None:
 
302
            return False
 
303
        self.state.update(ev, boundary=boundary)
 
304
        ev.accept()
 
305
        return True
 
306
 
 
307
    def close_open_menu(self):
 
308
        m = getattr(self.parent(), 'context_menu', None)
 
309
        if m is not None and m.isVisible():
 
310
            m.close()
 
311
            return True
 
312
 
 
313
    def handle_swipe(self, direction):
 
314
        if self.close_open_menu():
 
315
            return
 
316
        view = self.parent()
 
317
        func = {Left:'next_page', Right: 'previous_page', Up:'goto_previous_section', Down:'goto_next_section'}[direction]
 
318
        getattr(view, func)()
 
319
 
 
320
    def current_position(self, tp):
 
321
        return self.parent().mapFromGlobal(tp.current_screen_position.toPoint())
 
322
 
 
323
    def handle_tap(self, tp):
 
324
        if self.close_open_menu():
 
325
            return
 
326
        view = self.parent()
 
327
        mf = view.document.mainFrame()
 
328
        r = mf.hitTestContent(self.current_position(tp))
 
329
        if r.linkElement().isNull():
 
330
            threshold = view.width() / 3.0
 
331
            attr = 'previous' if self.current_position(tp).x() <= threshold else 'next'
 
332
            getattr(view, '%s_page'%attr)()
 
333
        else:
 
334
            for etype in (QEvent.MouseButtonPress, QEvent.MouseButtonRelease):
 
335
                ev = QMouseEvent(etype, self.current_position(tp), tp.current_screen_position.toPoint(), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
 
336
                QApplication.sendEvent(view, ev)
 
337
 
 
338
    def handle_tap_hold(self, action, tp):
 
339
        etype = {'start':QEvent.MouseButtonPress, 'update':QEvent.MouseMove, 'end':QEvent.MouseButtonRelease}[action]
 
340
        ev = QMouseEvent(etype, self.current_position(tp), tp.current_screen_position.toPoint(), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
 
341
        QApplication.sendEvent(self.parent(), ev)
 
342
        if action == 'end':
 
343
            ev = QContextMenuEvent(QContextMenuEvent.Other, self.current_position(tp), tp.current_screen_position.toPoint())
 
344
            # We have to use post event otherwise the popup remains an alien widget and does not receive events
 
345
            QApplication.postEvent(self.parent(), ev)
 
346
 
 
347
    def handle_swipe_hold(self, action, direction):
 
348
        view = self.parent()
 
349
        if action == 'start':
 
350
            self.last_swipe_hold_update = time.time()
 
351
            try:
 
352
                self.handle_swipe(direction)
 
353
            finally:
 
354
                view.is_auto_repeat_event = False
 
355
        elif action == 'update' and self.last_swipe_hold_update is not None and time.time() - self.last_swipe_hold_update > SWIPE_HOLD_INTERVAL:
 
356
            view.is_auto_repeat_event = True
 
357
            self.last_swipe_hold_update = time.time()
 
358
            try:
 
359
                self.handle_swipe(direction)
 
360
            finally:
 
361
                view.is_auto_repeat_event = False
 
362
        elif action == 'end':
 
363
            self.last_swipe_hold_update = None
 
364
 
 
365
    def handle_pinch(self, direction):
 
366
        attr = 'magnify' if direction is Out else 'shrink'
 
367
        getattr(self.parent(), '%s_fonts' % attr)()
 
368
 
 
369
    def show_help(self):
 
370
        Help(self.parent()).exec_()
 
371
 
 
372
if __name__ == '__main__':
 
373
    app = QApplication([])
 
374
    Help().exec_()