├── src ├── __init__.py └── gestures4kivy │ ├── __init__.py │ └── commongestures.py ├── .gitignore ├── setup.py ├── pyproject.toml ├── setup.cfg ├── LICENSE ├── python-publish.yml └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | dist/*.tar.gz 3 | dist/*.whl 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup; 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/gestures4kivy/__init__.py: -------------------------------------------------------------------------------- 1 | from .commongestures import CommonGestures 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = gestures4kivy 3 | version = 0.1.4 4 | author = Robert Flatt 5 | description = Detect common touch gestures in Kivy apps 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | url = https://github.com/Android-for-Python/gestures4kivy 9 | classifiers = 10 | Intended Audience :: Developers 11 | Topic :: Software Development :: Build Tools 12 | Programming Language :: Python :: 3 13 | License :: OSI Approved :: MIT License 14 | Operating System :: OS Independent 15 | 16 | [options] 17 | package_dir = 18 | = src 19 | packages = find: 20 | python_requires = >=3.6 21 | 22 | [options.packages.find] 23 | where = src -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Android-for-Python 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gestures for Kivy 2 | ================= 3 | 4 | *Detect common touch gestures in Kivy apps* 5 | 6 | **2023-11-13 This repository is archived.** 7 | 8 | **Now with an all new, simpler, input device independent api. The classic api is still implemented, but will be depreciated.** 9 | 10 | ## Install 11 | 12 | Desktop OS: 13 | ``` 14 | pip3 install gestures4kivy 15 | ``` 16 | 17 | Android: 18 | 19 | Add `gestures4kivy` to `buildozer.spec` requirements. 20 | 21 | iOS: 22 | ``` 23 | toolchain pip3 install gestures4kivy 24 | ``` 25 | 26 | ## Usage 27 | 28 | Import using: 29 | ``` 30 | from gestures4kivy import CommonGestures 31 | ``` 32 | 33 | The following is required at the top of the app's main.py to disable Kivy's multitouch emulation feature: 34 | ``` 35 | Config.set('input', 'mouse', 'mouse, disable_multitouch') 36 | ``` 37 | 38 | The class `CommonGestures` detects the common gestures for primary event, secondary event, select, drag, scroll, pan, zoom, rotate, and page. These are reported in an input device independent way, see below for details. 39 | 40 | Each gesture results in a callback, which defines the required action. These gestures can be **added** to Kivy widgets by subclassing a Kivy Widget and `CommonGestures`, and then including the methods for the required gestures. 41 | 42 | A minimal example is `SwipeScreen`, where we implement one callback method: 43 | ```python 44 | # A swipe sensitive Screen 45 | 46 | class SwipeScreen(Screen, CommonGestures): 47 | 48 | def cgb_horizontal_page(self, touch, right): 49 | # here we add the user defined behavior for the gesture 50 | # this method controls the ScreenManager in response to a swipe 51 | App.get_running_app().swipe_screen(right) 52 | ``` 53 | Where the `swipe_screen()` method configures the screen manager. This is fully implemented along with the other gestures [here](https://github.com/Android-for-Python/Common-Gestures-Example). 54 | 55 | `CommonGestures` callback methods detect gestures; they do not implement behaviors. 56 | 57 | ## Widget Interaction 58 | 59 | In the example above gesture detection is *added* to the Widget, however some Kivy widgets consume events so they are not passed to CommonGestures. For example `ScrollView` consumes mouse wheel events so a `cgb_pan` is not detected. 60 | 61 | ```python 62 | class HScrollView(ScrollView, CommonGestures): 63 | 64 | def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): 65 | print('pan') 66 | # this is never called 67 | ``` 68 | 69 | If this is not the required behavior, change the module resolution order. CommonGestures and ScrollView events will be called. 70 | 71 | ```python 72 | class HScrollView(CommonGestures, ScrollView): 73 | 74 | def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): 75 | print('pan') 76 | # this is always called 77 | ``` 78 | 79 | ## API 80 | 81 | `CommonGestures` implements the following gesture callbacks, a child class may use any subset. The callbacks are initiated by input device events as described below. 82 | 83 | Callback arguments report the original Kivy touch event(s), the focus of a gesture (the location of a cursor, finger, or mid point between two fingers) in Widget coordinates, and parameters representing the change described by a gesture. 84 | 85 | Gesture sensitivities can be adjusted by setting values in the class that inherits from `CommonGestures`. These values are contained in the `self._SOME_NAME` variables declared in the `__init__()` method of `CommonGestures`. 86 | 87 | For backwards compatibility a legacy api is implemented (method names begin with 'cg_' not 'cgb_'). The legacy api will eventually be depreciated, and is not documented. 88 | 89 | ### Primary event 90 | ```python 91 | def cgb_primary(self, touch, focus_x, focus_y): 92 | pass 93 | ``` 94 | - Mouse - Left button click 95 | - Touchpad - one finger tap 96 | - Mobile - one finger tap 97 | 98 | ### Secondary event 99 | ```python 100 | def cgb_secondary(self, touch, focus_x, focus_y): 101 | pass 102 | ``` 103 | - Mouse - Right button click 104 | - Touchpad - two finger tap 105 | - Mobile - two finger tap 106 | 107 | ### Select 108 | ```python 109 | def cgb_select(self, touch, focus_x, focus_y, long_press): 110 | # If long_press == True 111 | # Then on a mobile device set visual feedback. 112 | pass 113 | 114 | def cgb_long_press_end(self, touch, focus_x, focus_y): 115 | # Only called if cgb_select() long_press argument was True 116 | # On mobile device reset visual feedback. 117 | pass 118 | ``` 119 | - Mouse - double click 120 | - Touchpad - double tap, or long deep press 121 | - Mobile - double tap, long press 122 | 123 | `cgb_long_press_end()` is called when a user raises a finger after a long press. This may occur after a select or after a drag initiated by a long press. 124 | 125 | ### Drag 126 | ```python 127 | def cgb_drag(self, touch, focus_x, focus_y, delta_x, delta_y): 128 | pass 129 | ``` 130 | - Mouse - hold mouse button and move mouse 131 | - Touchpad - deep press (or one and a half taps) and move finger 132 | - Mobile - long press (provide visual feeback) and move finger 133 | 134 | ### Scroll 135 | ```python 136 | def cgb_scroll(self, touch, focus_x, focus_y, delta_y, velocity): 137 | pass 138 | ``` 139 | - Mouse - rotate scroll wheel 140 | - Touchpad - two finger vertical motion 141 | - Mobile - one finger vertical motion 142 | 143 | A scroll gesture is very similar to a vertical page gesture, using the two in the same layout may be a challenge particularly on a touchpad. 144 | 145 | ### Pan 146 | ```python 147 | def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): 148 | pass 149 | ``` 150 | - Mouse - Press Shift key, and rotate scroll wheel 151 | - Touchpad - two finger horizontal motion 152 | - Mobile - one finger horizontal motion 153 | 154 | A pan gesture is very similar to a horizontal page gesture, using the two in the same layout may be a challenge particularly on a touchpad. 155 | 156 | ### Zoom 157 | ```python 158 | def cgb_zoom(self, touch0, touch1, focus_x, focus_y, delta_scale): 159 | pass 160 | ``` 161 | - Mouse - Press Ctrl key, and rotate scroll wheel 162 | - Touchpad - two finger pinch/spread 163 | - Mobile - two finger pinch/spread 164 | 165 | On a Mac, the Command key is the convention for zoom, either Command or Ctrl can be used. 166 | 167 | The touch1 parameter may be `None`. 168 | 169 | ### Rotate 170 | ```python 171 | def cgb_rotate(self, touch0, touch1, focus_x, focus_y, delta_angle): 172 | pass 173 | ``` 174 | - Mouse - Press Alt key, and rotate scroll wheel 175 | - Touchpad - Press Alt key, plus two finger vertical motion 176 | - Mobile - two finger twist 177 | 178 | On a Mac, Alt is the key labeled Option 179 | 180 | On Linux, Alt is not available as a modifier, use the sequence CapsLock,Scroll,CapsLock. 181 | 182 | The touch1 parameter may be `None`. 183 | 184 | ### Horizontal Page 185 | ```python 186 | def cgb_horizontal_page(self, touch, left_to_right): 187 | pass 188 | ``` 189 | - Mouse - hold mouse button and fast horizontal move mouse 190 | - Touchpad - fast two finger horizontal motion 191 | - Mobile - fast one finger horizontal motion 192 | 193 | See [Pan](#pan) for possible interactions. 194 | 195 | ### Vertical Page 196 | ```python 197 | def cgb_vertical_page(self, touch, bottom_to_top): 198 | pass 199 | ``` 200 | - Mouse - hold mouse button and fast vertical move mouse 201 | - Touchpad - fast two finger vertical motion 202 | - Mobile - fast one finger vertical motion 203 | 204 | See [Scroll](#scroll) for possible interactions. 205 | 206 | 207 | ## Known Issues: 208 | 209 | ### Kivy Multitouch 210 | 211 | Kivy multitouch must be disabled. A ctrl-scroll with a mouse (the common convention for zoom), a pinch-spread with a touchpad, a right click, or a two finger tap will place an orange dot on the screen and inhibit zoom functionality. 212 | 213 | ```python 214 | Config.set('input', 'mouse', 'mouse, disable_multitouch') 215 | ``` 216 | 217 | ### Mac 218 | 219 | Trackpap two finger pinch/spread is not available. Use `Command` or `Ctrl` and `Scroll`. This is apparently an SDl2 issue. 220 | 221 | ### Linux 222 | 223 | Alt is not a keyboard modifier on Linux. For the rotate operation set CapsLock, scroll, and unset CapsLock. 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /src/gestures4kivy/commongestures.py: -------------------------------------------------------------------------------- 1 | ######################################################################## 2 | # 3 | # Common Gestures 4 | # 5 | # Detects the common gestures for primary event, secondary event, select, 6 | # drag, scroll, pan, page, zoom, and rotate. 7 | # 8 | # These gestures can be added to Kivy widgets by subclassing a 9 | # Kivy Widget and `CommonGestures`, and then including the methods for 10 | # the required gestures. 11 | # 12 | # CommonGestures methods detect gestures, and do not define behaviors. 13 | # 14 | # Source https://github.com/Android-for-Python/Common-Gestures-Example 15 | # 16 | ########################################################################### 17 | 18 | 19 | from kivy.core.window import Window 20 | from kivy.uix.widget import Widget 21 | from kivy.clock import Clock 22 | from kivy.metrics import Metrics 23 | from kivy.config import Config 24 | from kivy.utils import platform 25 | from functools import partial 26 | from time import time 27 | from math import sqrt, atan, degrees 28 | 29 | # This must be global so that the state is shared between instances 30 | # For example, a SwipeScreen instance must know about the previous one. 31 | PREVIOUS_PAGE_START = 0 32 | 33 | 34 | class CommonGestures(Widget): 35 | 36 | def __init__(self, **kwargs): 37 | super().__init__(**kwargs) 38 | self.mobile = platform == 'android' or platform == 'ios' 39 | if not self.mobile: 40 | Window.bind(on_key_down=self._modifier_key_down) 41 | Window.bind(on_key_up=self._modifier_key_up) 42 | 43 | # Gesture state 44 | self._CTRL = False 45 | self._SHIFT = False 46 | self._ALT = False 47 | self._finger_distance = 0 48 | self._finger_angle = 0 49 | self._wheel_enabled = True 50 | self._previous_wheel_time = 0 51 | self._persistent_pos = [(0, 0), (0, 0)] 52 | self._new_gesture() 53 | 54 | # Tap Sensitivity 55 | self._DOUBLE_TAP_TIME = Config.getint('postproc', 56 | 'double_tap_time') / 1000 57 | if self.mobile: 58 | self._DOUBLE_TAP_TIME = self._DOUBLE_TAP_TIME * 2 59 | 60 | self._DOUBLE_TAP_DISTANCE = Config.getint('postproc', 61 | 'double_tap_distance') 62 | self._LONG_MOVE_THRESHOLD = self._DOUBLE_TAP_DISTANCE / 2 63 | self._LONG_PRESS = 0.4 # sec, convention 64 | 65 | # one finger motion sensitivity 66 | self._MOVE_VELOCITY_SAMPLE = 0.2 # sec 67 | self._SWIPE_TIME = 0.3 # sec 68 | if self.mobile: 69 | self._SWIPE_VELOCITY = 5 # inches/sec, heuristic 70 | else: 71 | self._SWIPE_VELOCITY = 6 # inches/sec, heuristic 72 | 73 | # two finger motion and wheel sensitivity 74 | self._TWO_FINGER_SWIPE_START = 1/25 # 1/Hz 75 | self._TWO_FINGER_SWIPE_END = 1/2 # 1/Hz 76 | self._WHEEL_SENSITIVITY = 1.1 # heuristic 77 | 78 | ''' 79 | ##################### 80 | # Kivy Touch Events 81 | ##################### 82 | 1) In the case of a RelativeLayout, the touch.pos value is not persistent. 83 | Because the same Touch is called twice, once with Window relative and 84 | once with the RelativeLayout relative values. 85 | The on_touch_* callbacks have the required value because of collide_point 86 | but only within the scope of that touch callback. 87 | 88 | This is an issue for gestures with persistence, for example two touches. 89 | So if we have a RelativeLayout we can't rely on the value in touch.pos . 90 | So regardless of there being a RelativeLayout, we save each touch.pos 91 | in self._persistent_pos[] and use that when the current value is 92 | required. 93 | 94 | 2) A ModalView will inhibit touch events to this underlying Widget. 95 | If this Widget saw an on_touch_down() and a ModalView inhibits the partner 96 | on_touch_up() then this state machine will not reset. 97 | For single touch events, not reset, the invalid state will be 'Long Pressed' 98 | because this has the longest timer and this timer was not reset. 99 | We recover on next on_touch down() with a test for this state. 100 | ''' 101 | 102 | # touch down 103 | ################## 104 | def on_touch_down(self, touch): 105 | if self.collide_point(touch.x, touch.y): 106 | if len(self._touches) == 1 and touch.id == self._touches[0].id: 107 | # Filter noise from Kivy, one touch.id touches down twice 108 | pass 109 | elif platform == 'ios' and 'mouse' in str(touch.id): 110 | # Filter more noise from Kivy, extra mouse events 111 | return super().on_touch_down(touch) 112 | else: 113 | if len(self._touches) == 1 and\ 114 | self._gesture_state in ['Long Pressed']: 115 | # Case 2) Previous on_touch_up() was not seen, reset. 116 | self._touches = [] 117 | self._gesture_state = 'None' 118 | self._single_tap_schedule = None 119 | self._long_press_schedule = None 120 | self._touches.append(touch) 121 | 122 | if touch.is_mouse_scrolling: 123 | self._gesture_state = 'Wheel' 124 | x, y = self._pos_to_widget(touch.x, touch.y) 125 | scale = self._WHEEL_SENSITIVITY 126 | delta_scale = scale - 1 127 | if touch.button in ['scrollup', 'scrollleft']: 128 | scale = 1/scale 129 | delta_scale = -delta_scale 130 | vertical = touch.button in ['scrollup', 'scrolldown'] 131 | horizontal = touch.button in ['scrollleft', 'scrollright'] 132 | 133 | # Event filter 134 | global PREVIOUS_PAGE_START 135 | delta_t = touch.time_start - PREVIOUS_PAGE_START 136 | PREVIOUS_PAGE_START = touch.time_start 137 | if delta_t > self._TWO_FINGER_SWIPE_END: 138 | # end with slow scroll, or other event 139 | self._wheel_enabled = True 140 | 141 | # Page event 142 | if self._wheel_enabled and\ 143 | delta_t < self._TWO_FINGER_SWIPE_START: 144 | # start with fast scroll 145 | self._wheel_enabled = False 146 | if horizontal: 147 | self.cg_swipe_horizontal(touch, 148 | touch.button == 'scrollright') 149 | self.cgb_horizontal_page(touch, 150 | touch.button == 'scrollright') 151 | else: 152 | self.cg_swipe_vertical(touch, 153 | touch.button == 'scrollup') 154 | self.cgb_vertical_page(touch, 155 | touch.button == 'scrollup') 156 | 157 | # Scroll events 158 | if vertical: 159 | vertical_scroll = True 160 | if self._CTRL: 161 | vertical_scroll = False 162 | self.cg_ctrl_wheel(touch, scale, x, y) 163 | self.cgb_zoom(touch, None, x, y, scale) 164 | if self._SHIFT: 165 | vertical_scroll = False 166 | self.cg_shift_wheel(touch, scale, x, y) 167 | distance = x * delta_scale 168 | period = touch.time_update - self._previous_wheel_time 169 | velocity = 0 170 | if period: 171 | velocity = distance / (period * Metrics.dpi) 172 | self.cgb_pan(touch, x, y, distance, velocity) 173 | if self._ALT: 174 | vertical_scroll = False 175 | delta_angle = -5 176 | if touch.button == 'scrollup': 177 | delta_angle = - delta_angle 178 | self.cgb_rotate(touch, None, x, y, delta_angle) 179 | if vertical_scroll: 180 | self.cg_wheel(touch, scale, x, y) 181 | distance = y * delta_scale 182 | period = touch.time_update - self._previous_wheel_time 183 | velocity = 0 184 | if period: 185 | velocity = distance / (period * Metrics.dpi) 186 | self.cgb_scroll(touch, x, y, distance, velocity) 187 | elif horizontal: 188 | self.cg_shift_wheel(touch, scale, x, y) 189 | distance = x * delta_scale 190 | period = touch.time_update - self._previous_wheel_time 191 | velocity = 0 192 | if period: 193 | velocity = distance / (period * Metrics.dpi) 194 | self.cgb_pan(touch, x, y, distance, velocity) 195 | self._previous_wheel_time = touch.time_update 196 | 197 | elif len(self._touches) == 1: 198 | ox, oy = self._pos_to_widget(touch.ox, touch.oy) 199 | self._last_x = ox 200 | self._last_y = oy 201 | self._wheel_enabled = True 202 | if 'button' in touch.profile and touch.button == 'right': 203 | # Two finger tap or right click 204 | self._gesture_state = 'Right' 205 | else: 206 | self._gesture_state = 'Left' 207 | # schedule a posssible tap 208 | if not self._single_tap_schedule: 209 | self._single_tap_schedule =\ 210 | Clock.create_trigger(partial(self._single_tap_event, 211 | touch, 212 | touch.x, touch.y), 213 | self._DOUBLE_TAP_TIME) 214 | # schedule a posssible long press 215 | if not self._long_press_schedule: 216 | self._long_press_schedule = Clock.create_trigger( 217 | partial(self._long_press_event, 218 | touch, touch.x, touch.y, 219 | touch.ox, touch.oy), 220 | self._LONG_PRESS) 221 | # Hopefully schedules both from the same timestep 222 | if self._single_tap_schedule: 223 | self._single_tap_schedule() 224 | if self._long_press_schedule: 225 | self._long_press_schedule() 226 | 227 | self._persistent_pos[0] = tuple(touch.pos) 228 | elif len(self._touches) == 2: 229 | self._wheel_enabled = True 230 | self._gesture_state = 'Right' # scale, or rotate 231 | # If two fingers it cant be a long press, swipe or tap 232 | self._not_long_press() 233 | self._not_single_tap() 234 | self._persistent_pos[1] = tuple(touch.pos) 235 | x, y = self._scale_midpoint() 236 | self.cg_scale_start(self._touches[0], self._touches[1], x, y) 237 | elif len(self._touches) == 3: 238 | # Another bogus Kivy event 239 | # Occurs on desktop pinch/spread when touchpad reports 240 | # the touch points and not ctrl-scroll 241 | td = None 242 | for t in self._touches: 243 | if 'mouse' in str(t.id): 244 | td = t 245 | if td: 246 | self._remove_gesture(td) 247 | self._persistent_pos[0] = tuple(self._touches[0].pos) 248 | self._persistent_pos[1] = tuple(self._touches[1].pos) 249 | 250 | return super().on_touch_down(touch) 251 | 252 | # touch move 253 | ################# 254 | def on_touch_move(self, touch): 255 | if touch in self._touches and self.collide_point(touch.x, touch.y): 256 | # Old Android screens give noisy touch events 257 | # which can kill a long press. 258 | if (not self.mobile and (touch.dx or touch.dy)) or\ 259 | (self.mobile and not self._long_press_schedule and 260 | (touch.dx or touch.dy)) or\ 261 | (self.mobile and (abs(touch.dx) > self._LONG_MOVE_THRESHOLD or 262 | abs(touch.dy) > self._LONG_MOVE_THRESHOLD)): 263 | # If moving it cant be a pending long press or tap 264 | self._not_long_press() 265 | self._not_single_tap() 266 | # State changes 267 | if self._gesture_state == 'Long Pressed': 268 | self._gesture_state = 'Long Press Move' 269 | x, y = self._pos_to_widget(touch.ox, touch.oy) 270 | self._velocity_start(touch) 271 | self.cg_long_press_move_start(touch, x, y) 272 | 273 | elif self._gesture_state == 'Left': 274 | # Moving 'Left' is a drag, or a page 275 | self._gesture_state = 'Disambiguate' 276 | x, y = self._pos_to_widget(touch.ox, touch.oy) 277 | self._velocity_start(touch) 278 | self.cg_move_start(touch, x, y) 279 | 280 | if self._gesture_state == 'Disambiguate' and\ 281 | len(self._touches) == 1: 282 | self._gesture_state = 'Move' 283 | # schedule a posssible swipe 284 | if not self._swipe_schedule: 285 | self._swipe_schedule = Clock.schedule_once( 286 | partial(self._possible_swipe, touch), 287 | self._SWIPE_TIME) 288 | 289 | if self._gesture_state in ['Right', 'Scale']: 290 | if len(self._touches) <= 2: 291 | indx = self._touches.index(touch) 292 | self._persistent_pos[indx] = tuple(touch.pos) 293 | if len(self._touches) > 1: 294 | self._gesture_state = 'Scale' # and rotate 295 | finger_distance = self._scale_distance() 296 | f = self._scale_angle() 297 | if f >= 0: 298 | finger_angle = f 299 | else: # Div zero in angle calc 300 | finger_angle = self._finger_angle 301 | if self._finger_distance: 302 | scale = finger_distance / self._finger_distance 303 | x, y = self._scale_midpoint() 304 | if abs(scale) != 1: 305 | self.cg_scale(self._touches[0], 306 | self._touches[1], 307 | scale, x, y) 308 | self.cgb_zoom(self._touches[0], 309 | self._touches[1], 310 | x, y, scale) 311 | delta_angle = self._finger_angle - finger_angle 312 | # wrap around 313 | if delta_angle < -170: 314 | delta_angle += 180 315 | if delta_angle > 170: 316 | delta_angle -= 180 317 | if delta_angle: 318 | self.cgb_rotate(self._touches[0], 319 | self._touches[1], 320 | x, y, delta_angle) 321 | self._finger_distance = finger_distance 322 | self._finger_angle = finger_angle 323 | 324 | else: 325 | x, y = self._pos_to_widget(touch.x, touch.y) 326 | delta_x = x - self._last_x 327 | delta_y = y - self._last_y 328 | if self._gesture_state == 'Move' and self.mobile: 329 | v = self._velocity_now(touch) 330 | self.cg_move_to(touch, x, y, v) 331 | ox, oy = self._pos_to_widget(touch.ox, touch.oy) 332 | if abs(x - ox) > abs(y - oy): 333 | self.cgb_pan(touch, x, y, delta_x, v) 334 | else: 335 | self.cgb_scroll(touch, x, y, delta_y, v) 336 | elif self._gesture_state == 'Long Press Move' or\ 337 | (self._gesture_state == 'Move' and not self.mobile): 338 | self.cg_long_press_move_to(touch, x, y, 339 | self._velocity_now(touch)) 340 | self.cgb_drag(touch, x, y, delta_x, delta_y) 341 | self._last_x = x 342 | self._last_y = y 343 | 344 | return super().on_touch_move(touch) 345 | 346 | # touch up 347 | ############### 348 | def on_touch_up(self, touch): 349 | if touch in self._touches: 350 | 351 | self._not_long_press() 352 | x, y = self._pos_to_widget(touch.x, touch.y) 353 | 354 | if self._gesture_state == 'Left': 355 | if touch.is_double_tap: 356 | self._not_single_tap() 357 | self.cg_double_tap(touch, x, y) 358 | self.cgb_select(touch, x, y, False) 359 | self._new_gesture() 360 | else: 361 | self._remove_gesture(touch) 362 | 363 | elif self._gesture_state == 'Right': 364 | self.cg_two_finger_tap(touch, x, y) 365 | self.cgb_secondary(touch, x, y) 366 | self._new_gesture() 367 | 368 | elif self._gesture_state == 'Scale': 369 | self.cg_scale_end(self._touches[0], self._touches[1]) 370 | self._new_gesture() 371 | 372 | elif self._gesture_state == 'Long Press Move': 373 | self.cg_long_press_move_end(touch, x, y) 374 | self.cgb_long_press_end(touch, x, y) 375 | self._new_gesture() 376 | 377 | elif self._gesture_state == 'Move': 378 | self.cg_move_end(touch, x, y) 379 | self._new_gesture() 380 | 381 | elif self._gesture_state == 'Long Pressed': 382 | self.cg_long_press_end(touch, x, y) 383 | self.cgb_long_press_end(touch, x, y) 384 | self._new_gesture() 385 | 386 | elif self._gesture_state in ['Wheel', 'Disambiguate', 'Swipe']: 387 | self._new_gesture() 388 | 389 | return super().on_touch_up(touch) 390 | 391 | ############################################ 392 | # gesture utilities 393 | ############################################ 394 | 395 | # long press clock 396 | ######################## 397 | 398 | def _long_press_event(self, touch, x, y, ox, oy, dt): 399 | self._long_press_schedule = None 400 | distance_squared = (x - ox) ** 2 + (y - oy) ** 2 401 | if distance_squared < self._DOUBLE_TAP_DISTANCE ** 2: 402 | x, y = self._pos_to_widget(x, y) 403 | self.cg_long_press(touch, x, y) 404 | self.cgb_select(touch, x, y, True) 405 | self._gesture_state = 'Long Pressed' 406 | 407 | def _not_long_press(self): 408 | if self._long_press_schedule: 409 | Clock.unschedule(self._long_press_schedule) 410 | self._long_press_schedule = None 411 | 412 | # single tap clock 413 | ####################### 414 | def _single_tap_event(self, touch, x, y, dt): 415 | if self._gesture_state == 'Left': 416 | if not self._long_press_schedule: 417 | x, y = self._pos_to_widget(x, y) 418 | self.cg_tap(touch, x, y) 419 | self.cgb_primary(touch, x, y) 420 | self._new_gesture() 421 | 422 | def _not_single_tap(self): 423 | if self._single_tap_schedule: 424 | Clock.unschedule(self._single_tap_schedule) 425 | self._single_tap_schedule = None 426 | 427 | # swipe clock 428 | ####################### 429 | def _possible_swipe(self, touch, dt): 430 | self._swipe_schedule = None 431 | x, y = touch.pos 432 | ox, oy = touch.opos 433 | period = touch.time_update - touch.time_start 434 | distance = sqrt((x - ox) ** 2 + (y - oy) ** 2) 435 | if period: 436 | velocity = distance / (period * Metrics.dpi) 437 | else: 438 | velocity = 0 439 | 440 | if velocity > self._SWIPE_VELOCITY: 441 | # A Swipe pre-empts a Move, so reset the Move 442 | wox, woy = self._pos_to_widget(ox, oy) 443 | self.cg_move_to(touch, wox, woy, self._velocity) 444 | self.cg_move_end(touch, wox, woy) 445 | if self.touch_horizontal(touch): 446 | self.cg_swipe_horizontal(touch, x-ox > 0) 447 | self.cgb_horizontal_page(touch, x-ox > 0) 448 | else: 449 | self.cg_swipe_vertical(touch, y-oy > 0) 450 | self.cgb_vertical_page(touch, y-oy > 0) 451 | self._new_gesture() 452 | 453 | def _velocity_start(self, touch): 454 | self._velx, self._vely = touch.opos 455 | self._velt = touch.time_start 456 | 457 | def _velocity_now(self, touch): 458 | period = touch.time_update - self._velt 459 | x, y = touch.pos 460 | distance = sqrt((x - self._velx) ** 2 + (y - self._vely) ** 2) 461 | self._velt = touch.time_update 462 | self._velx, self._vely = touch.pos 463 | if period: 464 | return distance / (period * Metrics.dpi) 465 | else: 466 | return 0 467 | 468 | # Touch direction 469 | ###################### 470 | def touch_horizontal(self, touch): 471 | return abs(touch.x-touch.ox) > abs(touch.y-touch.oy) 472 | 473 | def touch_vertical(self, touch): 474 | return abs(touch.y-touch.oy) > abs(touch.x-touch.ox) 475 | 476 | # Two finger touch 477 | ###################### 478 | def _scale_distance(self): 479 | x0, y0 = self._persistent_pos[0] 480 | x1, y1 = self._persistent_pos[1] 481 | return sqrt((x0 - x1) ** 2 + (y0 - y1) ** 2) 482 | 483 | def _scale_angle(self): 484 | x0, y0 = self._persistent_pos[0] 485 | x1, y1 = self._persistent_pos[1] 486 | if y0 == y1: 487 | return -90 # NOP 488 | return 90 + degrees(atan((x0 - x1) / (y0 - y1))) 489 | 490 | def _scale_midpoint(self): 491 | x0, y0 = self._persistent_pos[0] 492 | x1, y1 = self._persistent_pos[1] 493 | midx = abs(x0 - x1) / 2 + min(x0, x1) 494 | midy = abs(y0 - y1) / 2 + min(y0, y1) 495 | # convert to widget 496 | x = midx - self.x 497 | y = midy - self.y 498 | return x, y 499 | 500 | # Every result is in the self frame 501 | ######################################### 502 | def _pos_to_widget(self, x, y): 503 | return (x - self.x, y - self.y) 504 | 505 | # gesture utilities 506 | ######################## 507 | def _remove_gesture(self, touch): 508 | if touch and len(self._touches): 509 | if touch in self._touches: 510 | self._touches.remove(touch) 511 | 512 | def _new_gesture(self): 513 | self._touches = [] 514 | self._long_press_schedule = None 515 | self._single_tap_schedule = None 516 | self._velocity_schedule = None 517 | self._swipe_schedule = None 518 | self._gesture_state = 'None' 519 | self._finger_distance = 0 520 | self._velocity = 0 521 | 522 | # Modiier key detect 523 | def _modifier_key_down(self, a, b, c, d, modifiers): 524 | command_key = platform == 'macosx' and 'meta' in modifiers 525 | self._linux_caps_key = platform == 'linux' and 'capslock' in modifiers 526 | if 'ctrl' in modifiers or command_key: 527 | self._CTRL = True 528 | elif 'shift' in modifiers: 529 | self._SHIFT = True 530 | elif 'alt' in modifiers or self._linux_caps_key: 531 | self._ALT = True 532 | 533 | def _modifier_key_up(self,a, b, c): 534 | self._CTRL = False 535 | self._SHIFT = False 536 | self._ALT = self._linux_caps_key 537 | 538 | ############################################ 539 | # User Events 540 | # define some subset in the derived class 541 | ############################################ 542 | 543 | # 544 | # Common Gestures Behavioral API 545 | # 546 | # primary, secondary, select, drag, scroll, pan, page, zoom, rotate 547 | # 548 | # focus_x, focus_y are locations in widget coordinates, representing 549 | # the location of a cursor, finger, or mid point between two fingers. 550 | 551 | # Click, tap, or deep press events 552 | def cgb_primary(self, touch, focus_x, focus_y): 553 | pass 554 | 555 | def cgb_secondary(self, touch, focus_x, focus_y): 556 | pass 557 | 558 | def cgb_select(self, touch, focus_x, focus_y, long_press): 559 | # If long_press == True 560 | # Then on a mobile device set visual feedback. 561 | pass 562 | 563 | def cgb_long_press_end(self, touch, focus_x, focus_y): 564 | # Only called if cgb_select() long_press argument was True 565 | # On mobile device reset visual feedback. 566 | pass 567 | 568 | def cgb_drag(self, touch, focus_x, focus_y, delta_x, delta_y): 569 | pass 570 | 571 | # Scroll 572 | def cgb_scroll(self, touch, focus_x, focus_y, delta_y, velocity): 573 | # do not use in combination with cgb_vertical_page() 574 | pass 575 | 576 | def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): 577 | # do not use in combination with cgb_horizontal_page() 578 | pass 579 | 580 | # Page 581 | def cgb_vertical_page(self, touch, bottom_to_top): 582 | # do not use in combination with cgb_scroll() 583 | pass 584 | 585 | def cgb_horizontal_page(self, touch, left_to_right): 586 | # do not use in combination with cgb_pan() 587 | pass 588 | 589 | # Zoom 590 | def cgb_zoom(self, touch0, touch1, focus_x, focus_y, delta_scale): 591 | # touch1 may be None 592 | pass 593 | 594 | # Rotate 595 | def cgb_rotate(self, touch0, touch1, focus_x, focus_y, delta_angle): 596 | # touch1 may be None 597 | pass 598 | 599 | # 600 | # Classic Common Gestures API 601 | # (will be depreciated) 602 | 603 | # Tap, Double Tap, and Long Press 604 | def cg_tap(self, touch, x, y): 605 | pass 606 | 607 | def cg_two_finger_tap(self, touch, x, y): 608 | # also a mouse right click, desktop only 609 | pass 610 | 611 | def cg_double_tap(self, touch, x, y): 612 | pass 613 | 614 | def cg_long_press(self, touch, x, y): 615 | pass 616 | 617 | def cg_long_press_end(self, touch, x, y): 618 | pass 619 | 620 | # Move 621 | def cg_move_start(self, touch, x, y): 622 | pass 623 | 624 | def cg_move_to(self, touch, x, y, velocity): 625 | # velocity is average of the last self._MOVE_VELOCITY_SAMPLE sec, 626 | # in inches/sec :) 627 | pass 628 | 629 | def cg_move_end(self, touch, x, y): 630 | pass 631 | 632 | # Move preceded by a long press. 633 | # cg_long_press() called first, cg_long_press_end() is not called 634 | def cg_long_press_move_start(self, touch, x, y): 635 | pass 636 | 637 | def cg_long_press_move_to(self, touch, x, y, velocity): 638 | # velocity is average of the last self._MOVE_VELOCITY_SAMPLE, 639 | # in inches/sec :) 640 | pass 641 | 642 | def cg_long_press_move_end(self, touch, x, y): 643 | pass 644 | 645 | # a fast move 646 | def cg_swipe_horizontal(self, touch, left_to_right): 647 | pass 648 | 649 | def cg_swipe_vertical(self, touch, bottom_to_top): 650 | pass 651 | 652 | # pinch/spread 653 | def cg_scale_start(self, touch0, touch1, x, y): 654 | pass 655 | 656 | def cg_scale(self, touch0, touch1, scale, x, y): 657 | pass 658 | 659 | def cg_scale_end(self, touch0, touch1): 660 | pass 661 | 662 | # Mouse Wheel, or Windows touch pad two finger vertical move 663 | 664 | # a common shortcut for scroll 665 | def cg_wheel(self, touch, scale, x, y): 666 | pass 667 | 668 | # a common shortcut for pinch/spread 669 | def cg_ctrl_wheel(self, touch, scale, x, y): 670 | pass 671 | 672 | # a common shortcut for horizontal scroll 673 | def cg_shift_wheel(self, touch, scale, x, y): 674 | pass 675 | 676 | --------------------------------------------------------------------------------