From 3bfc9258f86b4a737ac74355cbc43f05bbf9ee3d Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Fri, 31 Dec 2021 15:54:41 -1000 Subject: [PATCH 01/13] Update setup.cfg --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0d9815a..464ad4b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.0.4 +version = 0.0.5 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md From b11816e0a41f11fab79d1c2152ae8b7e009f6270 Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Fri, 31 Dec 2021 15:55:34 -1000 Subject: [PATCH 02/13] 0.0.6 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 464ad4b..318dde5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.0.5 +version = 0.0.6 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md From c2ffe2b72963e70e3694fa3d0f30b809d99fbced Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Sun, 3 Apr 2022 13:57:17 -1000 Subject: [PATCH 03/13] 0.0.7 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 318dde5..ad53853 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.0.6 +version = 0.0.7 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md From 2374e7c4e70d30e1ebe8e35f277fada83c85675c Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Sun, 3 Apr 2022 13:57:31 -1000 Subject: [PATCH 04/13] Add iOS --- README.md | 8 +++++--- src/gestures4kivy/commongestures.py | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9a57898..0e86590 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,15 @@ from gestures4kivy import CommonGestures This is required at the top of the app's main.py to disable a Kivy feature: ``` -Config.set('input', 'mouse', 'mouse, disable_multitouch') +if platform not in ['android', 'ios]: + 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`. -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. +Originally designed for use on Android, the gestures can be used on any Kivy supported platform and input device. 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. @@ -180,7 +181,8 @@ Force Click (deep press) is reported as a long press, this is a happy coincidenc See [https://github.com/kivy/kivy/issues/7708](https://github.com/kivy/kivy/issues/7708). ### iOS -Not tested + +All screen gestures work. ### Linux diff --git a/src/gestures4kivy/commongestures.py b/src/gestures4kivy/commongestures.py index 9541d30..58a7cc9 100644 --- a/src/gestures4kivy/commongestures.py +++ b/src/gestures4kivy/commongestures.py @@ -53,7 +53,7 @@ def __init__(self, **kwargs): self._LONG_PRESS = 0.4 # sec, convention self._MOVE_VELOCITY_SAMPLE = 0.2 # sec self._SWIPE_TIME = 0.3 # sec - self._SWIPE_VELOCITY = 7 # inches/sec, heuristic + self._SWIPE_VELOCITY = 6 # inches/sec, heuristic if platform == 'android': # Old Android devices have insensitive screens from android import api_version @@ -85,8 +85,11 @@ def __init__(self, **kwargs): 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: self._touches.append(touch) if touch.is_mouse_scrolling: From a140993216a51006f25687efa664ff9dbbf9f087 Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Tue, 30 Aug 2022 16:09:50 -1000 Subject: [PATCH 05/13] page filter and pep8 Improved filter of event storms when using swipe as page gesture on Win and Linux touchpads. --- src/gestures4kivy/commongestures.py | 255 ++++++++++++++-------------- 1 file changed, 132 insertions(+), 123 deletions(-) diff --git a/src/gestures4kivy/commongestures.py b/src/gestures4kivy/commongestures.py index 58a7cc9..febdf8b 100644 --- a/src/gestures4kivy/commongestures.py +++ b/src/gestures4kivy/commongestures.py @@ -4,7 +4,7 @@ # # 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`. +# A `long press move` is initiated with a `long press`. # On the desktop it also detects `mouse wheel` and the touchpad equivalent # `two finger move`. # @@ -29,9 +29,9 @@ from time import time from math import sqrt -# This is a workaround for a Kivy issue described below. -ENABLE_HORIZONTAL_PAGE = True -SCHEDULE_HORIZONTAL_PAGE = None +# This is a workaround for a SDL2 issue described below. +PREVIOUS_PAGE_START = 0 + class CommonGestures(Widget): @@ -45,43 +45,45 @@ def __init__(self, **kwargs): self._CTRL = False self._SHIFT = False self._new_gesture() - #### Sensitivity - self._DOUBLE_TAP_TIME = Config.getint('postproc', - 'double_tap_time') / 1000 + # Sensitivity + self._DOUBLE_TAP_TIME = Config.getint('postproc', + 'double_tap_time') / 1000 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 = 6 # inches/sec, heuristic + self._LONG_PRESS = 0.4 # sec, convention + self._MOVE_VELOCITY_SAMPLE = 0.2 # sec + self._SWIPE_TIME = 0.3 # sec + self._SWIPE_VELOCITY = 6 # inches/sec, heuristic if platform == 'android': # Old Android devices have insensitive screens from android import api_version if api_version < 28: - 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._SWIPE_VELOCITY = 5 # inches/sec, heuristic + self._WHEEL_SENSITIVITY = 1.1 # heuristic + self._PAGE_FILTER = 2.0 # Hz, heuristic + self._persistent_pos = [(0, 0), (0, 0)] + self._LONG_MOVE_THRESHOLD = self._DOUBLE_TAP_DISTANCE / 2 + ''' ##################### # 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 ### + 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 + ################## 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: @@ -98,39 +100,39 @@ def on_touch_down(self, touch): 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) + 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.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) + self.cg_ctrl_wheel(touch, scale, x, y) elif self._SHIFT: - self.cg_shift_wheel(touch,scale, x, y) + self.cg_shift_wheel(touch, scale, x, y) else: - self.cg_wheel(touch,scale, x, y) + self.cg_wheel(touch, scale, x, y) elif len(self._touches) == 1: 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' + 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._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 if not self._single_tap_schedule: self._single_tap_schedule =\ Clock.schedule_once(partial(self._single_tap_event, - touch , + touch, touch.x, touch.y), self._DOUBLE_TAP_TIME) @@ -138,27 +140,28 @@ def on_touch_down(self, touch): elif len(self._touches) == 2: self._gesture_state = 'Scale' # 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) 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' @@ -178,7 +181,7 @@ def on_touch_move(self, touch): # 'Swipe' but may not see a touch_up. self._new_gesture() else: - self._gesture_state = 'Move' + self._gesture_state = 'Move' if self._gesture_state == 'Scale': if len(self._touches) <= 2: @@ -195,18 +198,19 @@ def on_touch_move(self, touch): scale, x, y) self._finger_distance = finger_distance - 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': self.cg_long_press_move_to(touch, x, y, self._velocity_now(touch)) - - return super().on_touch_move(touch) - ### touch up ### + return super().on_touch_move(touch) + + # touch up + ############### def on_touch_up(self, touch): if touch in self._touches: @@ -223,7 +227,7 @@ def on_touch_up(self, touch): elif self._gesture_state == 'Right': self.cg_two_finger_tap(touch, x, y) - + elif self._gesture_state == 'Scale': self.cg_scale_end(self._touches[0], self._touches[1]) self._new_gesture() @@ -243,20 +247,21 @@ def on_touch_up(self, touch): 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': + self._gesture_state == 'Disambiguate' or\ + self._gesture_state == '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 @@ -270,12 +275,13 @@ 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 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._new_gesture() def _not_single_tap(self): @@ -284,7 +290,7 @@ def _not_single_tap(self): self._single_tap_schedule = None def _possible_swipe(self, touch): - x, y = touch.pos + x, y = touch.pos ox, oy = touch.opos period = touch.time_update - touch.time_start distance = sqrt((x - ox) ** 2 + (y - oy) ** 2) @@ -306,69 +312,72 @@ def _possible_swipe(self, touch): return False 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): + # 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 till 2 seconds after the 'last' one. + if platform in ['win', 'linux']: + # Windows/Linux touchpads can generate an event storm + # in the case of a two finger horizonal swipe - a page gesture. + # SDL2 passes this gesture as many horizonal scroll wheel events, + # with no begining or end to the sequence. # - # 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. + # To inhibit multiple page events, we implement low pass filter. + # This filter defaults to 2 Hz. (self._PAGE_FILTER) # - # 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 - global SCHEDULE_HORIZONTAL_PAGE - if ENABLE_HORIZONTAL_PAGE: - ENABLE_HORIZONTAL_PAGE = False - self.cg_swipe_horizontal(touch, right) - if SCHEDULE_HORIZONTAL_PAGE: - Clock.unschedule(SCHEDULE_HORIZONTAL_PAGE) - SCHEDULE_HORIZONTAL_PAGE = None - SCHEDULE_HORIZONTAL_PAGE = Clock.schedule_once( - self._re_enable_horizontal_page, - self._PAGE_FILTER) - else: - self.cg_swipe_horizontal(touch, right) - + # We use a global because if, for example, the page event is used + # to switch between Screens, the following storm events will be + # processed by a different instance of CommonGestures. So we need + # a global state variable for the filter. + # + # All touchpads generate event storms to some extent. Some older + # touchpads, I'm looking at you the otherwise amazing T650, can + # generate long event storms. A side effect of the filter is to + # inhibit a new swipe gesture until the previous storm has passed. + # If this is an issue, use slower or shorter swipes. + # + # Tested with Logitech T650, Dell notebook, and Magic Trackpad + global PREVIOUS_PAGE_START + event_storm =\ + touch.time_start - PREVIOUS_PAGE_START < 1 / self._PAGE_FILTER + PREVIOUS_PAGE_START = touch.time_start + if event_storm: + return + 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 + # direction is the same with or without RelativeLayout + ###################### 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_midpoint(self): x0, y0 = self._persistent_pos[0] @@ -380,18 +389,18 @@ 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 @@ -401,7 +410,7 @@ def _new_gesture(self): self._finger_distance = 0 self._velocity = 0 - ### CTRL SHIFT key detect + # CTRL SHIFT key detect def _ctrl_key_down(self, a, b, c, d, modifiers): command_key = platform == 'macosx' and 'meta' in modifiers if 'ctrl' in modifiers or command_key: @@ -410,7 +419,7 @@ def _ctrl_key_down(self, a, b, c, d, modifiers): def _shift_key_down(self, a, b, c, d, modifiers): if 'shift' in modifiers: self._SHIFT = True - + def _key_up(self, *args): self._CTRL = False self._SHIFT = False @@ -420,7 +429,7 @@ def _key_up(self, *args): # define some subset in the derived class ############################################ - ############# Tap, Double Tap, and Long Press + # Tap, Double Tap, and Long Press def cg_tap(self, touch, x, y): pass @@ -437,7 +446,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 @@ -449,7 +458,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 @@ -462,14 +471,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 @@ -479,16 +488,16 @@ 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 From 63ba38b138336e0ff52064f1efd556485de8f6b9 Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Wed, 21 Sep 2022 13:58:27 -1000 Subject: [PATCH 06/13] behavior api --- README.md | 232 +++++++++--------- setup.cfg | 2 +- src/gestures4kivy/commongestures.py | 367 ++++++++++++++++++++-------- 3 files changed, 380 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index 0e86590..2539240 100644 --- a/README.md +++ b/README.md @@ -3,198 +3,200 @@ Gestures for Kivy *Detect common touch gestures in Kivy apps* +**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: ``` -if platform not in ['android', 'ios]: - Config.set('input', 'mouse', 'mouse, disable_multitouch') +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. -Originally designed for use on Android, the gestures can be used on any Kivy supported platform and input device. - -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 -`CommonGestures` implements these gesture callbacks, a child class may use any subset: - -``` - ############################################ - # User Events - # define some subset in the derived class - ############################################ - - ############# Tap and Long Press - def cg_tap(self, touch, x, y): - pass - - def cg_two_finger_tap(self, touch, x, y): - # also a mouse right click, desktop only - 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. - def cg_double_tap(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_long_press(self, touch, x, y): - 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_long_press_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 - def cg_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_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_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 - ############### Move preceded by a long press - def cg_long_press_move_start(self, touch, x, y): + 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_long_press_move_to(self, touch, x, y, velocity): - # velocity is average of the last 0.2 sec, in inches/sec :) - 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. - def cg_long_press_move_end(self, touch, 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 - ############### fast horizontal movement - def cg_swipe_horizontal(self, touch, left_to_right): +### 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_swipe_vertical(self, touch, bottom_to_top): - 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. - ############### pinch/spread - def cg_scale_start(self, touch0, touch1, 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 - def cg_scale(self, touch0, touch1, 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. - def cg_scale_end(self, touch0, touch1): +### 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 - ############# Mouse Wheel, or Windows touch pad two finger vertical move - - ############# a common shortcut for scroll - def cg_wheel(self, touch, scale, x, y): - pass +On a Mac, the Command key is the convention for zoom, either Command or Ctrl can be used. - ############# a common shortcut for pinch/spread - def cg_ctrl_wheel(self, touch, scale, x, y): - pass +The touch1 parameter may be `None`. - ############# a common shortcut for horizontal scroll - def cg_shift_wheel(self, touch, scale, x, y): +### 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 -## Hardware Considerations - -#### Mouse - -As usual, `Move`, `Long Press Move`, `Swipe`, and `Long Press` are initiated with press the left mouse button, and end when the press ends. +On a Mac, Alt is the key labeled Option -The right mouse button generates a `cg_two_finger_tap()` callback. +On Linux, Alt is not available as a modifier, use the sequence CapsLock,Scroll,CapsLock. -Mouse wheel movement generates t `cg_wheel()`, `cg_shift_wheel()`, and `cg_ctrl_wheel()` callbacks. +The touch1 parameter may be `None`. -#### Touch Pad +### 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 -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. +See [Pan](#pan) for possible interactions. -A `Swipe` callback is also generated by a two finger horizonal move. A two finger vertical move initaiates a scroll callback. +### 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 -A two finger tap generates a `cg_two_finger_tap()` callback. +See [Scroll](#scroll) for possible interactions. -Two finger pinch/spread uses the cursor location as focus. Note that the cursor may move significantly during a pinch/spread. -## OS Considerations +## Known Issues: -### Android +### Kivy Multitouch -Pinch/spread focus is the mid point between two fingers. The mouse wheel callbacks are not generated. +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. -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') +```python +Config.set('input', 'mouse', 'mouse, disable_multitouch') ``` -### Windows - -On some touchpads pinch/spread will not be detected the if 'mouse, disable_multitouch' feature is not disabled. - -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). - ### Mac -Two finger pinch/spread is not available. Use `Command` and `vertical scroll`. - -Force Click (deep press) is reported as a long press, this is a happy coincidence and not by design. - -See [https://github.com/kivy/kivy/issues/7708](https://github.com/kivy/kivy/issues/7708). - -### iOS - -All screen gestures work. +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 ad53853..09e6112 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.0.7 +version = 0.1.0 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 febdf8b..894b3aa 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` 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,9 +24,10 @@ from kivy.utils import platform from functools import partial from time import time -from math import sqrt +from math import sqrt, atan, degrees -# This is a workaround for a SDL2 issue described below. +# 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 @@ -39,31 +37,43 @@ 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 + + # 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_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 - self._SWIPE_VELOCITY = 6 # inches/sec, heuristic - if platform == 'android': - # Old Android devices have insensitive screens - from android import api_version - if api_version < 28: - self._SWIPE_VELOCITY = 5 # inches/sec, heuristic + 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 - self._PAGE_FILTER = 2.0 # Hz, heuristic - self._persistent_pos = [(0, 0), (0, 0)] - self._LONG_MOVE_THRESHOLD = self._DOUBLE_TAP_DISTANCE / 2 ''' ##################### @@ -94,33 +104,92 @@ def on_touch_down(self, touch): return super().on_touch_down(touch) else: 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 + 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_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) - elif self._SHIFT: + self.cgb_zoom(touch, None, x, y, scale) + if self._SHIFT: + vertical_scroll = False self.cg_shift_wheel(touch, scale, x, y) - else: + 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' else: - self._gesture_state = 'Dont Know' + self._gesture_state = 'Left' # schedule a posssible long press if not self._long_press_schedule: self._long_press_schedule = Clock.schedule_once( @@ -138,13 +207,26 @@ def on_touch_down(self, touch): 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_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) + 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) @@ -169,43 +251,77 @@ 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 == 'Scale': + 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 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: x, y = self._pos_to_widget(touch.x, touch.y) + delta_x = x - self._last_x + delta_y = y - self._last_y if self._gesture_state == 'Move': - self.cg_move_to(touch, x, y, self._velocity_now(touch)) - + v = self._velocity_now(touch) + self.cg_move_to(touch, x, y, v) + if self.mobile: + 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) + else: + self.cgb_drag(touch, x, y, delta_x, delta_y) elif self._gesture_state == 'Long Press Move': self.cg_long_press_move_to(touch, x, y, self._velocity_now(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) @@ -217,16 +333,19 @@ def on_touch_up(self, touch): 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]) @@ -234,6 +353,7 @@ def on_touch_up(self, touch): 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': @@ -242,15 +362,10 @@ 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) @@ -268,6 +383,7 @@ def _long_press_event(self, touch, x, y, ox, oy, dt): 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): @@ -278,10 +394,11 @@ def _not_long_press(self): # 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.cgb_primary(touch, x, y) self._new_gesture() def _not_single_tap(self): @@ -289,7 +406,10 @@ def _not_single_tap(self): Clock.unschedule(self._single_tap_schedule) self._single_tap_schedule = None - def _possible_swipe(self, touch): + # 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 @@ -304,12 +424,16 @@ def _possible_swipe(self, touch): wox, woy = self._pos_to_widget(ox, oy) self.cg_move_to(touch, wox, woy, self._velocity) self.cg_move_end(touch, wox, woy) + if not self.mobile: + # reset drag + self.cgb_drag(touch, wox, woy, ox - x, oy - y) 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 @@ -326,45 +450,7 @@ def _velocity_now(self, touch): else: return 0 - # potential page - ##################### - def _potential_page(self, touch): - right = touch.button == 'scrollright' - if platform in ['win', 'linux']: - # Windows/Linux touchpads can generate an event storm - # in the case of a two finger horizonal swipe - a page gesture. - # SDL2 passes this gesture as many horizonal scroll wheel events, - # with no begining or end to the sequence. - # - # To inhibit multiple page events, we implement low pass filter. - # This filter defaults to 2 Hz. (self._PAGE_FILTER) - # - # We use a global because if, for example, the page event is used - # to switch between Screens, the following storm events will be - # processed by a different instance of CommonGestures. So we need - # a global state variable for the filter. - # - # All touchpads generate event storms to some extent. Some older - # touchpads, I'm looking at you the otherwise amazing T650, can - # generate long event storms. A side effect of the filter is to - # inhibit a new swipe gesture until the previous storm has passed. - # If this is an issue, use slower or shorter swipes. - # - # Tested with Logitech T650, Dell notebook, and Magic Trackpad - global PREVIOUS_PAGE_START - event_storm =\ - touch.time_start - PREVIOUS_PAGE_START < 1 / self._PAGE_FILTER - PREVIOUS_PAGE_START = touch.time_start - if event_storm: - return - 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) @@ -379,6 +465,13 @@ def _scale_distance(self): x1, y1 = self._persistent_pos[1] 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] x1, y1 = self._persistent_pos[1] @@ -406,29 +499,92 @@ def _new_gesture(self): 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 + elif 'alt' in modifiers or self._linux_caps_key: + self._ALT = True - def _key_up(self, *args): + 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 ############################################ + # + # 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 @@ -501,3 +657,4 @@ def cg_ctrl_wheel(self, touch, scale, x, y): # a common shortcut for horizontal scroll def cg_shift_wheel(self, touch, scale, x, y): pass + From 51401e8343e542ad63daa17a973042667ff1846f Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Tue, 20 Dec 2022 14:36:26 -1000 Subject: [PATCH 07/13] remove false positives --- src/gestures4kivy/commongestures.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/gestures4kivy/commongestures.py b/src/gestures4kivy/commongestures.py index 894b3aa..4eea553 100644 --- a/src/gestures4kivy/commongestures.py +++ b/src/gestures4kivy/commongestures.py @@ -258,7 +258,8 @@ def on_touch_move(self, touch): self._velocity_start(touch) self.cg_move_start(touch, x, y) - if self._gesture_state == 'Disambiguate': + if self._gesture_state == 'Disambiguate' and\ + self.mobile and len(self._touches) == 1: self._gesture_state = 'Move' # schedule a posssible swipe if not self._swipe_schedule: @@ -308,14 +309,11 @@ def on_touch_move(self, touch): if self._gesture_state == 'Move': v = self._velocity_now(touch) self.cg_move_to(touch, x, y, v) - if self.mobile: - 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) + 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_drag(touch, x, y, delta_x, delta_y) + self.cgb_scroll(touch, x, y, delta_y, v) elif self._gesture_state == 'Long Press Move': self.cg_long_press_move_to(touch, x, y, self._velocity_now(touch)) @@ -424,9 +422,6 @@ def _possible_swipe(self, touch, dt): wox, woy = self._pos_to_widget(ox, oy) self.cg_move_to(touch, wox, woy, self._velocity) self.cg_move_end(touch, wox, woy) - if not self.mobile: - # reset drag - self.cgb_drag(touch, wox, woy, ox - x, oy - y) if self.touch_horizontal(touch): self.cg_swipe_horizontal(touch, x-ox > 0) self.cgb_horizontal_page(touch, x-ox > 0) From 1438f0a42e264faed3a89d89a428ccf33ceebb5d Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Tue, 20 Dec 2022 14:36:42 -1000 Subject: [PATCH 08/13] 0.1.1 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 09e6112..7b654ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.1.0 +version = 0.1.1 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md From 9e2f625e3d5b45cc2348a416932def1e24fd21f8 Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:22:48 -1000 Subject: [PATCH 09/13] false negative --- setup.cfg | 2 +- src/gestures4kivy/commongestures.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7b654ad..a991666 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.1.1 +version = 0.1.2 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 4eea553..a5bf843 100644 --- a/src/gestures4kivy/commongestures.py +++ b/src/gestures4kivy/commongestures.py @@ -259,7 +259,7 @@ def on_touch_move(self, touch): self.cg_move_start(touch, x, y) if self._gesture_state == 'Disambiguate' and\ - self.mobile and len(self._touches) == 1: + len(self._touches) == 1: self._gesture_state = 'Move' # schedule a posssible swipe if not self._swipe_schedule: From b5f83a427687c6632b872d6bf0d2cf04702e3147 Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Tue, 21 Feb 2023 16:07:46 -1000 Subject: [PATCH 10/13] raspberry --- setup.cfg | 2 +- src/gestures4kivy/commongestures.py | 42 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/setup.cfg b/setup.cfg index a991666..6430fe8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.1.2 +version = 0.1.3.dev0 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 a5bf843..a06a4ee 100644 --- a/src/gestures4kivy/commongestures.py +++ b/src/gestures4kivy/commongestures.py @@ -79,7 +79,7 @@ def __init__(self, **kwargs): ##################### # Kivy Touch Events ##################### - In the case of a RelativeLayout, the touch.pos value is not persistent. + 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 @@ -90,6 +90,13 @@ def __init__(self, **kwargs): 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 @@ -103,6 +110,13 @@ def on_touch_down(self, touch): # 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: @@ -190,20 +204,25 @@ def on_touch_down(self, touch): self._gesture_state = 'Right' else: self._gesture_state = 'Left' - # 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 if not self._single_tap_schedule: self._single_tap_schedule =\ - Clock.schedule_once(partial(self._single_tap_event, + 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: @@ -306,7 +325,7 @@ def on_touch_move(self, touch): x, y = self._pos_to_widget(touch.x, touch.y) delta_x = x - self._last_x delta_y = y - self._last_y - if self._gesture_state == 'Move': + 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) @@ -314,7 +333,8 @@ def on_touch_move(self, touch): 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': + 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)) self.cgb_drag(touch, x, y, delta_x, delta_y) From ed8dbdd044629a0eba89ac226af5df6d5002ff84 Mon Sep 17 00:00:00 2001 From: RobertFlatt <34464649+RobertFlatt@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:26:11 -1000 Subject: [PATCH 11/13] Update setup.cfg --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 6430fe8..53926d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.1.3.dev0 +version = 0.1.3 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md From d475c38d60542048354b86790cb26ef978765675 Mon Sep 17 00:00:00 2001 From: RobertFlatt <85974252+Android-for-Python@users.noreply.github.com> Date: Wed, 29 Mar 2023 20:49:57 -1000 Subject: [PATCH 12/13] Update README.md --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 2539240..1654a16 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,28 @@ Where the `swipe_screen()` method configures the screen manager. This is fully i `CommonGestures` callback methods detect gestures; they do not implement behaviors. +## 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. + +```python +class HScrollView(ScrollView, CommonGestures): + + def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): + print('pan') + # this is never called +``` + +If this is not the required behavior, change the module resolution order. CommonGestures and ScrollView events will be called. + +```python +class HScrollView(CommonGestures, ScrollView): + + def cgb_pan(self, touch, focus_x, focus_y, delta_x, velocity): + print('pan') + # this is always called +``` + ## API `CommonGestures` implements the following gesture callbacks, a child class may use any subset. The callbacks are initiated by input device events as described below. From 3123f140f44c9e7d86bf9b70bb7e26502a997ac6 Mon Sep 17 00:00:00 2001 From: RobertFlatt <85974252+Android-for-Python@users.noreply.github.com> Date: Mon, 13 Nov 2023 15:02:30 -1000 Subject: [PATCH 13/13] archive --- README.md | 2 ++ setup.cfg | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1654a16..6d15344 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ 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 diff --git a/setup.cfg b/setup.cfg index 53926d9..7addbd2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = gestures4kivy -version = 0.1.3 +version = 0.1.4 author = Robert Flatt description = Detect common touch gestures in Kivy apps long_description = file: README.md