diff --git a/README.md b/README.md index 9a57898..6d15344 100644 --- a/README.md +++ b/README.md @@ -3,196 +3,224 @@ Gestures for Kivy *Detect common touch gestures in Kivy apps* +**2023-11-13 This repository is archived.** + +**Now with an all new, simpler, input device independent api. The classic api is still implemented, but will be depreciated.** + ## Install -For a desktop OS: +Desktop OS: ``` pip3 install gestures4kivy ``` -For Android: +Android: Add `gestures4kivy` to `buildozer.spec` requirements. +iOS: +``` +toolchain pip3 install gestures4kivy +``` + +## Usage + Import using: ``` from gestures4kivy import CommonGestures ``` -This is required at the top of the app's main.py to disable a Kivy feature: +The following is required at the top of the app's main.py to disable Kivy's multitouch emulation feature: ``` Config.set('input', 'mouse', 'mouse, disable_multitouch') ``` -## Behavior - -The class `CommonGestures` detects the common gestures for `scale`, `move`, `swipe`, `long press move`, `long press`, `tap`, and `double tap`. A `long press move` is initiated with a `long press`. On the desktop the class also detects `mouse wheel` and the touchpad equivalent `two finger move`. +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. -Designed for use on Android, the gestures can be used on any Kivy supported platform and input device. To be clear these are Android style gestures that are available across platforms and input devices. - -In addition, for platforms with a mouse scroll wheel the usual conventions are detected: `scroll wheel` can be used for vertical scroll, `shift-scroll wheel` can be used for horizontal scroll, and `ctrl-scroll wheel` can be used for zoom. Also on touch pads, vertical or horizontal two finger movement emulates a mouse scroll wheel. In addition a mouse right click, or pad two finger tap is detected. - -Each gesture results in a callback, which will contain 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. +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. A minimal example is `SwipeScreen`, where we implement one callback method: -``` -### A swipe sensitive Screen +```python +# A swipe sensitive Screen + class SwipeScreen(Screen, CommonGestures): - def cg_swipe_horizontal(self, touch, right): + def cgb_horizontal_page(self, touch, right): # here we add the user defined behavior for the gesture # this method controls the ScreenManager in response to a swipe App.get_running_app().swipe_screen(right) ``` 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). -`CommonGestures` callback methods detect gestures, they do not define behaviors. +`CommonGestures` callback methods detect gestures; they do not implement behaviors. -## API +## Widget Interaction + +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. -`CommonGestures` implements these gesture callbacks, a child class may use any subset: +```python +class HScrollView(ScrollView, CommonGestures): + def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): + print('pan') + # this is never called ``` - ############################################ - # User Events - # define some subset in the derived class - ############################################ - ############# Tap and Long Press - def cg_tap(self, touch, x, y): - pass +If this is not the required behavior, change the module resolution order. CommonGestures and ScrollView events will be called. - def cg_two_finger_tap(self, touch, x, y): - # also a mouse right click, desktop only - pass +```python +class HScrollView(CommonGestures, ScrollView): - def cg_double_tap(self, touch, x, y): - pass + def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): + print('pan') + # this is always called +``` - def cg_long_press(self, touch, x, y): - pass +## API - def cg_long_press_end(self, touch, x, y): - pass +`CommonGestures` implements the following gesture callbacks, a child class may use any subset. The callbacks are initiated by input device events as described below. - ############## Move - def cg_move_start(self, touch, x, y): - pass +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. - def cg_move_to(self, touch, x, y, velocity): - # velocity is average of the last 0.2 sec, in inches/sec :) - pass +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`. - def cg_move_end(self, touch, x, y): - pass +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. - ############### Move preceded by a long press - def cg_long_press_move_start(self, touch, x, y): +### Primary event +```python + def cgb_primary(self, touch, focus_x, focus_y): pass +``` + - Mouse - Left button click + - Touchpad - one finger tap + - Mobile - one finger tap - def cg_long_press_move_to(self, touch, x, y, velocity): - # velocity is average of the last 0.2 sec, in inches/sec :) +### Secondary event +```python + def cgb_secondary(self, touch, focus_x, focus_y): pass +``` + - Mouse - Right button click + - Touchpad - two finger tap + - Mobile - two finger tap - def cg_long_press_move_end(self, touch, x, y): +### Select +```python + def cgb_select(self, touch, focus_x, focus_y, long_press): + # If long_press == True + # Then on a mobile device set visual feedback. pass - ############### fast horizontal movement - def cg_swipe_horizontal(self, touch, left_to_right): + def cgb_long_press_end(self, touch, focus_x, focus_y): + # Only called if cgb_select() long_press argument was True + # On mobile device reset visual feedback. pass +``` + - Mouse - double click + - Touchpad - double tap, or long deep press + - Mobile - double tap, long press - def cg_swipe_vertical(self, touch, bottom_to_top): - pass +`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. - ############### pinch/spread - def cg_scale_start(self, touch0, touch1, x, y): +### Drag +```python + def cgb_drag(self, touch, focus_x, focus_y, delta_x, delta_y): pass +``` + - Mouse - hold mouse button and move mouse + - Touchpad - deep press (or one and a half taps) and move finger + - Mobile - long press (provide visual feeback) and move finger - def cg_scale(self, touch0, touch1, scale, x, y): +### Scroll +```python + def cgb_scroll(self, touch, focus_x, focus_y, delta_y, velocity): pass +``` + - Mouse - rotate scroll wheel + - Touchpad - two finger vertical motion + - Mobile - one finger vertical motion - def cg_scale_end(self, touch0, touch1): - pass +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. - ############# Mouse Wheel, or Windows touch pad two finger vertical move - - ############# a common shortcut for scroll - def cg_wheel(self, touch, scale, x, y): +### Pan +```python + def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): pass +``` + - Mouse - Press Shift key, and rotate scroll wheel + - Touchpad - two finger horizontal motion + - Mobile - one finger horizontal motion - ############# a common shortcut for pinch/spread - def cg_ctrl_wheel(self, touch, scale, x, y): - pass +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. - ############# a common shortcut for horizontal scroll - def cg_shift_wheel(self, touch, scale, x, y): +### Zoom +```python + def cgb_zoom(self, touch0, touch1, focus_x, focus_y, delta_scale): pass - ``` + - Mouse - Press Ctrl key, and rotate scroll wheel + - Touchpad - two finger pinch/spread + - Mobile - two finger pinch/spread -## Hardware Considerations +On a Mac, the Command key is the convention for zoom, either Command or Ctrl can be used. -#### Mouse +The touch1 parameter may be `None`. -As usual, `Move`, `Long Press Move`, `Swipe`, and `Long Press` are initiated with press the left mouse button, and end when the press ends. - -The right mouse button generates a `cg_two_finger_tap()` callback. - -Mouse wheel movement generates t `cg_wheel()`, `cg_shift_wheel()`, and `cg_ctrl_wheel()` callbacks. - -#### Touch Pad - -As usual, `Move`, `Long Press Move`, `Swipe`, and `Long Press` are initiated with **'one and a half taps'**, or a press on the bottom left corner of the trackpad. - -A `Swipe` callback is also generated by a two finger horizonal move. A two finger vertical move initaiates a scroll callback. +### Rotate +```python + def cgb_rotate(self, touch0, touch1, focus_x, focus_y, delta_angle): + pass +``` + - Mouse - Press Alt key, and rotate scroll wheel + - Touchpad - Press Alt key, plus two finger vertical motion + - Mobile - two finger twist -A two finger tap generates a `cg_two_finger_tap()` callback. +On a Mac, Alt is the key labeled Option -Two finger pinch/spread uses the cursor location as focus. Note that the cursor may move significantly during a pinch/spread. +On Linux, Alt is not available as a modifier, use the sequence CapsLock,Scroll,CapsLock. -## OS Considerations +The touch1 parameter may be `None`. -### Android +### Horizontal Page +```python + def cgb_horizontal_page(self, touch, left_to_right): + pass +``` + - Mouse - hold mouse button and fast horizontal move mouse + - Touchpad - fast two finger horizontal motion + - Mobile - fast one finger horizontal motion -Pinch/spread focus is the mid point between two fingers. The mouse wheel callbacks are not generated. +See [Pan](#pan) for possible interactions. -Mobile users are not used to a double tap, so use long press. If you do use double tap, the Kivy default detection time is too short for reliable detection of finger taps. -You can change this from the default 250mS, for example: -``` - from kivy.config import Config - Config.set('postproc', 'double_tap_time', '500') +### Vertical Page +```python + def cgb_vertical_page(self, touch, bottom_to_top): + pass ``` + - Mouse - hold mouse button and fast vertical move mouse + - Touchpad - fast two finger vertical motion + - Mobile - fast one finger vertical motion -### Windows - -On some touchpads pinch/spread will not be detected the if 'mouse, disable_multitouch' feature is not disabled. +See [Scroll](#scroll) for possible interactions. -Some touch pads report a pinch/spread as a finger movement `cg_scale()`, and some detect the gesture internally and report it as a `cg_ctrl_wheel()`. The safe thing to do is handle both cases in an application. -A two finger horizontal move is inhibited for 2 second following the previous horizontal move [https://github.com/kivy/kivy/issues/7707](https://github.com/kivy/kivy/issues/7707). +## Known Issues: -### Mac +### Kivy Multitouch -Two finger pinch/spread is not available. Use `Command` and `vertical scroll`. +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. -Force Click (deep press) is reported as a long press, this is a happy coincidence and not by design. +```python +Config.set('input', 'mouse', 'mouse, disable_multitouch') +``` -See [https://github.com/kivy/kivy/issues/7708](https://github.com/kivy/kivy/issues/7708). +### Mac -### iOS -Not tested +Trackpap two finger pinch/spread is not available. Use `Command` or `Ctrl` and `Scroll`. This is apparently an SDl2 issue. ### Linux -Tested on Raspberry Desktop. Using a touchpad the behavior is non-deterministic, and cannot be used. Use a mouse. +Alt is not a keyboard modifier on Linux. For the rotate operation set CapsLock, scroll, and unset CapsLock. -Using a mouse on 'Buster' the `[input]` section in `~/.kivy/config.ini` should contain only one entry: -``` -[input] -mouse = mouse -``` -Using 'Bullseye' the Kivy default config is good when using a mouse. -## Acknowledgement -A big thank you to Elliot for his analysis, constructive suggestions, and testing. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 0d9815a..7addbd2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.0.4 +version = 0.1.4 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md diff --git a/src/gestures4kivy/commongestures.py b/src/gestures4kivy/commongestures.py index c6bab0a..a06a4ee 100644 --- a/src/gestures4kivy/commongestures.py +++ b/src/gestures4kivy/commongestures.py @@ -2,13 +2,10 @@ # # Common Gestures # -# Detects the common gestures for `scale`, `move`, `swipe`, `long press`, -# `long press move`, `tap`, and `double tap`. -# A `long press move move` is initiated with a `long press`. -# On the desktop it also detects `mouse wheel` and the touchpad equivalent -# `two finger move`. +# Detects the common gestures for primary event, secondary event, select, +# drag, scroll, pan, page, zoom, and rotate. # -# These gestures can be **added** to Kivy widgets by subclassing a +# These gestures can be added to Kivy widgets by subclassing a # Kivy Widget and `CommonGestures`, and then including the methods for # the required gestures. # @@ -27,10 +24,12 @@ from kivy.utils import platform from functools import partial from time import time -from math import sqrt +from math import sqrt, atan, degrees + +# This must be global so that the state is shared between instances +# For example, a SwipeScreen instance must know about the previous one. +PREVIOUS_PAGE_START = 0 -# This is a workaround for a Kivy issue described below. -ENABLE_HORIZONTAL_PAGE = True class CommonGestures(Widget): @@ -38,118 +37,232 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.mobile = platform == 'android' or platform == 'ios' if not self.mobile: - Window.bind(on_key_down=self._ctrl_key_down) - Window.bind(on_key_down=self._shift_key_down) - Window.bind(on_key_up=self._key_up) + Window.bind(on_key_down=self._modifier_key_down) + Window.bind(on_key_up=self._modifier_key_up) + + # Gesture state self._CTRL = False self._SHIFT = False + self._ALT = False + self._finger_distance = 0 + self._finger_angle = 0 + self._wheel_enabled = True + self._previous_wheel_time = 0 + self._persistent_pos = [(0, 0), (0, 0)] self._new_gesture() - #### Sensitivity - self._DOUBLE_TAP_TIME = Config.getint('postproc', - 'double_tap_time') / 1000 + + # Tap Sensitivity + self._DOUBLE_TAP_TIME = Config.getint('postproc', + 'double_tap_time') / 1000 + if self.mobile: + self._DOUBLE_TAP_TIME = self._DOUBLE_TAP_TIME * 2 + self._DOUBLE_TAP_DISTANCE = Config.getint('postproc', 'double_tap_distance') - self._LONG_PRESS = 0.4 # sec, convention - self._MOVE_VELOCITY_SAMPLE = 0.2 # sec - self._SWIPE_TIME = 0.3 # sec - self._SWIPE_VELOCITY = 5 # inches/sec, heuristic - self._WHEEL_SENSITIVITY = 1.1 # heuristic - self._PAGE_FILTER = 2.0 # heuristic - self._persistent_pos = [(0,0),(0,0)] - self._LONG_MOVE_THRESHOLD = self._DOUBLE_TAP_DISTANCE /2 + self._LONG_MOVE_THRESHOLD = self._DOUBLE_TAP_DISTANCE / 2 + self._LONG_PRESS = 0.4 # sec, convention + + # one finger motion sensitivity + self._MOVE_VELOCITY_SAMPLE = 0.2 # sec + self._SWIPE_TIME = 0.3 # sec + if self.mobile: + self._SWIPE_VELOCITY = 5 # inches/sec, heuristic + else: + self._SWIPE_VELOCITY = 6 # inches/sec, heuristic + # two finger motion and wheel sensitivity + self._TWO_FINGER_SWIPE_START = 1/25 # 1/Hz + self._TWO_FINGER_SWIPE_END = 1/2 # 1/Hz + self._WHEEL_SENSITIVITY = 1.1 # heuristic + ''' ##################### # Kivy Touch Events ##################### - # In the case of a RelativeLayout, the touch.pos value is not persistent. - # Because the same Touch is called twice, once with Window relative and - # once with the RelativeLayout relative values. - # The on_touch_* callbacks have the required value because of collide_point - # but only within the scope of that touch callback. - # - # This is an issue for gestures with persistence, for example two touches. - # So if we have a RelativeLayout we can't rely on the value in touch.pos . - # So regardless of there being a RelativeLayout, we save each touch.pos - # in self._persistent_pos[] and use that when the current value is - # required. - - ### touch down ### + 1) In the case of a RelativeLayout, the touch.pos value is not persistent. + Because the same Touch is called twice, once with Window relative and + once with the RelativeLayout relative values. + The on_touch_* callbacks have the required value because of collide_point + but only within the scope of that touch callback. + + This is an issue for gestures with persistence, for example two touches. + So if we have a RelativeLayout we can't rely on the value in touch.pos . + So regardless of there being a RelativeLayout, we save each touch.pos + in self._persistent_pos[] and use that when the current value is + required. + + 2) A ModalView will inhibit touch events to this underlying Widget. + If this Widget saw an on_touch_down() and a ModalView inhibits the partner + on_touch_up() then this state machine will not reset. + For single touch events, not reset, the invalid state will be 'Long Pressed' + because this has the longest timer and this timer was not reset. + We recover on next on_touch down() with a test for this state. + ''' + + # touch down + ################## def on_touch_down(self, touch): if self.collide_point(touch.x, touch.y): if len(self._touches) == 1 and touch.id == self._touches[0].id: - # Filter noise from Kivy + # Filter noise from Kivy, one touch.id touches down twice pass + elif platform == 'ios' and 'mouse' in str(touch.id): + # Filter more noise from Kivy, extra mouse events + return super().on_touch_down(touch) else: + if len(self._touches) == 1 and\ + self._gesture_state in ['Long Pressed']: + # Case 2) Previous on_touch_up() was not seen, reset. + self._touches = [] + self._gesture_state = 'None' + self._single_tap_schedule = None + self._long_press_schedule = None self._touches.append(touch) + if touch.is_mouse_scrolling: self._gesture_state = 'Wheel' - scale = self._WHEEL_SENSITIVITY x, y = self._pos_to_widget(touch.x, touch.y) - - if touch.button == 'scrollleft': - self._gesture_state = 'PotentialPage' - self.cg_shift_wheel(touch,1/scale, x, y) - elif touch.button == 'scrollright': - self._gesture_state = 'PotentialPage' - self.cg_shift_wheel(touch,scale, x, y) - else: - self._gesture_state = 'Wheel' - if touch.button == 'scrollup': - scale = 1/scale - if self._CTRL: - self.cg_ctrl_wheel(touch,scale, x, y) - elif self._SHIFT: - self.cg_shift_wheel(touch,scale, x, y) + scale = self._WHEEL_SENSITIVITY + delta_scale = scale - 1 + if touch.button in ['scrollup', 'scrollleft']: + scale = 1/scale + delta_scale = -delta_scale + vertical = touch.button in ['scrollup', 'scrolldown'] + horizontal = touch.button in ['scrollleft', 'scrollright'] + + # Event filter + global PREVIOUS_PAGE_START + delta_t = touch.time_start - PREVIOUS_PAGE_START + PREVIOUS_PAGE_START = touch.time_start + if delta_t > self._TWO_FINGER_SWIPE_END: + # end with slow scroll, or other event + self._wheel_enabled = True + + # Page event + if self._wheel_enabled and\ + delta_t < self._TWO_FINGER_SWIPE_START: + # start with fast scroll + self._wheel_enabled = False + if horizontal: + self.cg_swipe_horizontal(touch, + touch.button == 'scrollright') + self.cgb_horizontal_page(touch, + touch.button == 'scrollright') else: - self.cg_wheel(touch,scale, x, y) + self.cg_swipe_vertical(touch, + touch.button == 'scrollup') + self.cgb_vertical_page(touch, + touch.button == 'scrollup') + + # Scroll events + if vertical: + vertical_scroll = True + if self._CTRL: + vertical_scroll = False + self.cg_ctrl_wheel(touch, scale, x, y) + self.cgb_zoom(touch, None, x, y, scale) + if self._SHIFT: + vertical_scroll = False + self.cg_shift_wheel(touch, scale, x, y) + distance = x * delta_scale + period = touch.time_update - self._previous_wheel_time + velocity = 0 + if period: + velocity = distance / (period * Metrics.dpi) + self.cgb_pan(touch, x, y, distance, velocity) + if self._ALT: + vertical_scroll = False + delta_angle = -5 + if touch.button == 'scrollup': + delta_angle = - delta_angle + self.cgb_rotate(touch, None, x, y, delta_angle) + if vertical_scroll: + self.cg_wheel(touch, scale, x, y) + distance = y * delta_scale + period = touch.time_update - self._previous_wheel_time + velocity = 0 + if period: + velocity = distance / (period * Metrics.dpi) + self.cgb_scroll(touch, x, y, distance, velocity) + elif horizontal: + self.cg_shift_wheel(touch, scale, x, y) + distance = x * delta_scale + period = touch.time_update - self._previous_wheel_time + velocity = 0 + if period: + velocity = distance / (period * Metrics.dpi) + self.cgb_pan(touch, x, y, distance, velocity) + self._previous_wheel_time = touch.time_update elif len(self._touches) == 1: + ox, oy = self._pos_to_widget(touch.ox, touch.oy) + self._last_x = ox + self._last_y = oy + self._wheel_enabled = True if 'button' in touch.profile and touch.button == 'right': # Two finger tap or right click - self._gesture_state = 'Right' + self._gesture_state = 'Right' else: - self._gesture_state = 'Dont Know' - # schedule a posssible long press - if not self._long_press_schedule: - self._long_press_schedule =\ - Clock.schedule_once(partial(self._long_press_event, - touch, touch.x, touch.y, - touch.ox, touch.oy), - self._LONG_PRESS) - # schedule a posssible tap + self._gesture_state = 'Left' + # schedule a posssible tap if not self._single_tap_schedule: self._single_tap_schedule =\ - Clock.schedule_once(partial(self._single_tap_event, - touch , + Clock.create_trigger(partial(self._single_tap_event, + touch, touch.x, touch.y), self._DOUBLE_TAP_TIME) + # schedule a posssible long press + if not self._long_press_schedule: + self._long_press_schedule = Clock.create_trigger( + partial(self._long_press_event, + touch, touch.x, touch.y, + touch.ox, touch.oy), + self._LONG_PRESS) + # Hopefully schedules both from the same timestep + if self._single_tap_schedule: + self._single_tap_schedule() + if self._long_press_schedule: + self._long_press_schedule() self._persistent_pos[0] = tuple(touch.pos) elif len(self._touches) == 2: - self._gesture_state = 'Scale' + self._wheel_enabled = True + self._gesture_state = 'Right' # scale, or rotate # If two fingers it cant be a long press, swipe or tap - self._not_long_press() + self._not_long_press() self._not_single_tap() self._persistent_pos[1] = tuple(touch.pos) x, y = self._scale_midpoint() - self.cg_scale_start(self._touches[0],self._touches[1], x, y) + self.cg_scale_start(self._touches[0], self._touches[1], x, y) + elif len(self._touches) == 3: + # Another bogus Kivy event + # Occurs on desktop pinch/spread when touchpad reports + # the touch points and not ctrl-scroll + td = None + for t in self._touches: + if 'mouse' in str(t.id): + td = t + if td: + self._remove_gesture(td) + self._persistent_pos[0] = tuple(self._touches[0].pos) + self._persistent_pos[1] = tuple(self._touches[1].pos) return super().on_touch_down(touch) - ### touch move ### + # touch move + ################# def on_touch_move(self, touch): if touch in self._touches and self.collide_point(touch.x, touch.y): # Old Android screens give noisy touch events # which can kill a long press. if (not self.mobile and (touch.dx or touch.dy)) or\ - (self.mobile and not self._long_press_schedule and\ + (self.mobile and not self._long_press_schedule and (touch.dx or touch.dy)) or\ - (self.mobile and (abs(touch.dx) > self._LONG_MOVE_THRESHOLD or\ - abs(touch.dy) > self._LONG_MOVE_THRESHOLD)): + (self.mobile and (abs(touch.dx) > self._LONG_MOVE_THRESHOLD or + abs(touch.dy) > self._LONG_MOVE_THRESHOLD)): # If moving it cant be a pending long press or tap self._not_long_press() - self._not_single_tap() + self._not_single_tap() # State changes if self._gesture_state == 'Long Pressed': self._gesture_state = 'Long Press Move' @@ -157,69 +270,108 @@ def on_touch_move(self, touch): self._velocity_start(touch) self.cg_long_press_move_start(touch, x, y) - elif self._gesture_state == 'Dont Know': + elif self._gesture_state == 'Left': + # Moving 'Left' is a drag, or a page self._gesture_state = 'Disambiguate' x, y = self._pos_to_widget(touch.ox, touch.oy) self._velocity_start(touch) self.cg_move_start(touch, x, y) - if self._gesture_state == 'Disambiguate': - if touch.time_update - touch.time_start < self._SWIPE_TIME: - if self._possible_swipe(touch): - # 'Swipe' but may not see a touch_up. - self._new_gesture() - else: - self._gesture_state = 'Move' + if self._gesture_state == 'Disambiguate' and\ + len(self._touches) == 1: + self._gesture_state = 'Move' + # schedule a posssible swipe + if not self._swipe_schedule: + self._swipe_schedule = Clock.schedule_once( + partial(self._possible_swipe, touch), + self._SWIPE_TIME) - if self._gesture_state == 'Scale': + if self._gesture_state in ['Right', 'Scale']: if len(self._touches) <= 2: indx = self._touches.index(touch) self._persistent_pos[indx] = tuple(touch.pos) if len(self._touches) > 1: + self._gesture_state = 'Scale' # and rotate finger_distance = self._scale_distance() + f = self._scale_angle() + if f >= 0: + finger_angle = f + else: # Div zero in angle calc + finger_angle = self._finger_angle if self._finger_distance: scale = finger_distance / self._finger_distance + x, y = self._scale_midpoint() if abs(scale) != 1: - x, y = self._scale_midpoint() self.cg_scale(self._touches[0], self._touches[1], scale, x, y) + self.cgb_zoom(self._touches[0], + self._touches[1], + x, y, scale) + delta_angle = self._finger_angle - finger_angle + # wrap around + if delta_angle < -170: + delta_angle += 180 + if delta_angle > 170: + delta_angle -= 180 + if delta_angle: + self.cgb_rotate(self._touches[0], + self._touches[1], + x, y, delta_angle) self._finger_distance = finger_distance + self._finger_angle = finger_angle - else: + else: x, y = self._pos_to_widget(touch.x, touch.y) - if self._gesture_state == 'Move': - self.cg_move_to(touch, x, y, self._velocity_now(touch)) - - elif self._gesture_state == 'Long Press Move': + delta_x = x - self._last_x + delta_y = y - self._last_y + if self._gesture_state == 'Move' and self.mobile: + v = self._velocity_now(touch) + self.cg_move_to(touch, x, y, v) + ox, oy = self._pos_to_widget(touch.ox, touch.oy) + if abs(x - ox) > abs(y - oy): + self.cgb_pan(touch, x, y, delta_x, v) + else: + self.cgb_scroll(touch, x, y, delta_y, v) + elif self._gesture_state == 'Long Press Move' or\ + (self._gesture_state == 'Move' and not self.mobile): self.cg_long_press_move_to(touch, x, y, self._velocity_now(touch)) - - return super().on_touch_move(touch) + self.cgb_drag(touch, x, y, delta_x, delta_y) + self._last_x = x + self._last_y = y + + return super().on_touch_move(touch) - ### touch up ### + # touch up + ############### def on_touch_up(self, touch): - if touch in self._touches: + if touch in self._touches: + self._not_long_press() x, y = self._pos_to_widget(touch.x, touch.y) - if self._gesture_state == 'Dont Know': + if self._gesture_state == 'Left': if touch.is_double_tap: self._not_single_tap() self.cg_double_tap(touch, x, y) + self.cgb_select(touch, x, y, False) self._new_gesture() else: self._remove_gesture(touch) elif self._gesture_state == 'Right': self.cg_two_finger_tap(touch, x, y) - + self.cgb_secondary(touch, x, y) + self._new_gesture() + elif self._gesture_state == 'Scale': self.cg_scale_end(self._touches[0], self._touches[1]) self._new_gesture() elif self._gesture_state == 'Long Press Move': self.cg_long_press_move_end(touch, x, y) + self.cgb_long_press_end(touch, x, y) self._new_gesture() elif self._gesture_state == 'Move': @@ -228,31 +380,28 @@ def on_touch_up(self, touch): elif self._gesture_state == 'Long Pressed': self.cg_long_press_end(touch, x, y) + self.cgb_long_press_end(touch, x, y) self._new_gesture() - elif self._gesture_state == 'PotentialPage': - self._potential_page(touch) - self._new_gesture() - - elif self._gesture_state == 'Wheel' or\ - self._gesture_state == 'Disambiguate' or\ - self._gesture_state == 'Swipe': + elif self._gesture_state in ['Wheel', 'Disambiguate', 'Swipe']: self._new_gesture() - return super().on_touch_up(touch) + return super().on_touch_up(touch) ############################################ # gesture utilities ############################################ - # - ### long press clock ### + # long press clock + ######################## + def _long_press_event(self, touch, x, y, ox, oy, dt): self._long_press_schedule = None distance_squared = (x - ox) ** 2 + (y - oy) ** 2 if distance_squared < self._DOUBLE_TAP_DISTANCE ** 2: x, y = self._pos_to_widget(x, y) self.cg_long_press(touch, x, y) + self.cgb_select(touch, x, y, True) self._gesture_state = 'Long Pressed' def _not_long_press(self): @@ -260,12 +409,14 @@ def _not_long_press(self): Clock.unschedule(self._long_press_schedule) self._long_press_schedule = None - ### single tap clock ### + # single tap clock + ####################### def _single_tap_event(self, touch, x, y, dt): - if self._gesture_state == 'Dont Know': + if self._gesture_state == 'Left': if not self._long_press_schedule: x, y = self._pos_to_widget(x, y) - self.cg_tap(touch,x,y) + self.cg_tap(touch, x, y) + self.cgb_primary(touch, x, y) self._new_gesture() def _not_single_tap(self): @@ -273,8 +424,11 @@ def _not_single_tap(self): Clock.unschedule(self._single_tap_schedule) self._single_tap_schedule = None - def _possible_swipe(self, touch): - x, y = touch.pos + # swipe clock + ####################### + def _possible_swipe(self, touch, dt): + self._swipe_schedule = None + x, y = touch.pos ox, oy = touch.opos period = touch.time_update - touch.time_start distance = sqrt((x - ox) ** 2 + (y - oy) ** 2) @@ -290,70 +444,48 @@ def _possible_swipe(self, touch): self.cg_move_end(touch, wox, woy) if self.touch_horizontal(touch): self.cg_swipe_horizontal(touch, x-ox > 0) + self.cgb_horizontal_page(touch, x-ox > 0) else: self.cg_swipe_vertical(touch, y-oy > 0) - return True - return False + self.cgb_vertical_page(touch, y-oy > 0) + self._new_gesture() def _velocity_start(self, touch): - self._velx , self._vely = touch.opos + self._velx, self._vely = touch.opos self._velt = touch.time_start - + def _velocity_now(self, touch): period = touch.time_update - self._velt x, y = touch.pos distance = sqrt((x - self._velx) ** 2 + (y - self._vely) ** 2) self._velt = touch.time_update - self._velx , self._vely = touch.pos + self._velx, self._vely = touch.pos if period: - return distance / (period * Metrics.dpi) + return distance / (period * Metrics.dpi) else: return 0 - ### potential page #### - def _potential_page(self,touch): - right = touch.button == 'scrollright' - if platform == 'win': - # https://github.com/kivy/kivy/issues/7707 - # Windows can generate an event storm in this case. - # Pick the first one and inhibit handling the - # following ones for the next 2 seconds - # - # A following event may pop out of the event queue after we have - # changed screens, in which case we get an event sent - # to the new screen, and an extra screen change, and sometimes more. - # - # Here the workaround is a global containing the filter state, - # this will be shared between screens. - # A better fix would be a Kivy event filter. - global ENABLE_HORIZONTAL_PAGE - if ENABLE_HORIZONTAL_PAGE: - ENABLE_HORIZONTAL_PAGE = False - Clock.schedule_once(self._re_enable_horizontal_page, - self._PAGE_FILTER) - self.cg_swipe_horizontal(touch, right) - else: - self.cg_swipe_horizontal(touch, right) - - def _re_enable_horizontal_page(self, dt): - global ENABLE_HORIZONTAL_PAGE - ENABLE_HORIZONTAL_PAGE = True - - ### touch direction ### - # direction is the same with or without RelativeLayout - + # Touch direction + ###################### def touch_horizontal(self, touch): return abs(touch.x-touch.ox) > abs(touch.y-touch.oy) - def touch_vertical(self, touch): + def touch_vertical(self, touch): return abs(touch.y-touch.oy) > abs(touch.x-touch.ox) - ### Two finger touch ### - + # Two finger touch + ###################### def _scale_distance(self): x0, y0 = self._persistent_pos[0] x1, y1 = self._persistent_pos[1] - return sqrt((x0 - x1) ** 2 + (y0 - y1) ** 2) + return sqrt((x0 - x1) ** 2 + (y0 - y1) ** 2) + + def _scale_angle(self): + x0, y0 = self._persistent_pos[0] + x1, y1 = self._persistent_pos[1] + if y0 == y1: + return -90 # NOP + return 90 + degrees(atan((x0 - x1) / (y0 - y1))) def _scale_midpoint(self): x0, y0 = self._persistent_pos[0] @@ -365,47 +497,110 @@ def _scale_midpoint(self): y = midy - self.y return x, y - ### Every result is in the self frame ### - + # Every result is in the self frame + ######################################### def _pos_to_widget(self, x, y): return (x - self.x, y - self.y) - ### gesture utilities ### - + # gesture utilities + ######################## def _remove_gesture(self, touch): if touch and len(self._touches): if touch in self._touches: self._touches.remove(touch) - + def _new_gesture(self): self._touches = [] self._long_press_schedule = None self._single_tap_schedule = None self._velocity_schedule = None + self._swipe_schedule = None self._gesture_state = 'None' self._finger_distance = 0 self._velocity = 0 - ### CTRL SHIFT key detect - def _ctrl_key_down(self, a, b, c, d, modifiers): + # Modiier key detect + def _modifier_key_down(self, a, b, c, d, modifiers): command_key = platform == 'macosx' and 'meta' in modifiers + self._linux_caps_key = platform == 'linux' and 'capslock' in modifiers if 'ctrl' in modifiers or command_key: self._CTRL = True - - def _shift_key_down(self, a, b, c, d, modifiers): - if 'shift' in modifiers: + elif 'shift' in modifiers: self._SHIFT = True - - def _key_up(self, *args): + elif 'alt' in modifiers or self._linux_caps_key: + self._ALT = True + + def _modifier_key_up(self,a, b, c): self._CTRL = False self._SHIFT = False + self._ALT = self._linux_caps_key ############################################ # User Events # define some subset in the derived class ############################################ - ############# Tap, Double Tap, and Long Press + # + # Common Gestures Behavioral API + # + # primary, secondary, select, drag, scroll, pan, page, zoom, rotate + # + # focus_x, focus_y are locations in widget coordinates, representing + # the location of a cursor, finger, or mid point between two fingers. + + # Click, tap, or deep press events + def cgb_primary(self, touch, focus_x, focus_y): + pass + + def cgb_secondary(self, touch, focus_x, focus_y): + pass + + def cgb_select(self, touch, focus_x, focus_y, long_press): + # If long_press == True + # Then on a mobile device set visual feedback. + pass + + def cgb_long_press_end(self, touch, focus_x, focus_y): + # Only called if cgb_select() long_press argument was True + # On mobile device reset visual feedback. + pass + + def cgb_drag(self, touch, focus_x, focus_y, delta_x, delta_y): + pass + + # Scroll + def cgb_scroll(self, touch, focus_x, focus_y, delta_y, velocity): + # do not use in combination with cgb_vertical_page() + pass + + def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): + # do not use in combination with cgb_horizontal_page() + pass + + # Page + def cgb_vertical_page(self, touch, bottom_to_top): + # do not use in combination with cgb_scroll() + pass + + def cgb_horizontal_page(self, touch, left_to_right): + # do not use in combination with cgb_pan() + pass + + # Zoom + def cgb_zoom(self, touch0, touch1, focus_x, focus_y, delta_scale): + # touch1 may be None + pass + + # Rotate + def cgb_rotate(self, touch0, touch1, focus_x, focus_y, delta_angle): + # touch1 may be None + pass + + # + # Classic Common Gestures API + # (will be depreciated) + + # Tap, Double Tap, and Long Press def cg_tap(self, touch, x, y): pass @@ -422,7 +617,7 @@ def cg_long_press(self, touch, x, y): def cg_long_press_end(self, touch, x, y): pass - ############## Move + # Move def cg_move_start(self, touch, x, y): pass @@ -434,7 +629,7 @@ def cg_move_to(self, touch, x, y, velocity): def cg_move_end(self, touch, x, y): pass - ############### Move preceded by a long press. + # Move preceded by a long press. # cg_long_press() called first, cg_long_press_end() is not called def cg_long_press_move_start(self, touch, x, y): pass @@ -447,14 +642,14 @@ def cg_long_press_move_to(self, touch, x, y, velocity): def cg_long_press_move_end(self, touch, x, y): pass - ############### a fast move + # a fast move def cg_swipe_horizontal(self, touch, left_to_right): pass def cg_swipe_vertical(self, touch, bottom_to_top): pass - ############### pinch/spread + # pinch/spread def cg_scale_start(self, touch0, touch1, x, y): pass @@ -464,16 +659,17 @@ def cg_scale(self, touch0, touch1, scale, x, y): def cg_scale_end(self, touch0, touch1): pass - ############# Mouse Wheel, or Windows touch pad two finger vertical move - - ############# a common shortcut for scroll + # Mouse Wheel, or Windows touch pad two finger vertical move + + # a common shortcut for scroll def cg_wheel(self, touch, scale, x, y): pass - ############# a common shortcut for pinch/spread + # a common shortcut for pinch/spread def cg_ctrl_wheel(self, touch, scale, x, y): pass - ############# a common shortcut for horizontal scroll + # a common shortcut for horizontal scroll def cg_shift_wheel(self, touch, scale, x, y): pass +