Ticket #3374: mousemodes.diff

File mousemodes.diff, 32.5 KB (added by Tristan Croll, 5 years ago)

Added by email2trac

  • src/bundles/mouse_modes/src/mousemodes.py

    diff --git a/src/bundles/mouse_modes/src/mousemodes.py b/src/bundles/mouse_modes/src/mousemodes.py
    old mode 100644
    new mode 100755
    index eea9b8496..8cd52f68c
    a b class MouseMode:  
    6464
    6565    def enable(self):
    6666        '''
    67         Supported API. 
     67        Supported API.
    6868        Called when mouse mode is enabled.
    6969        Override if mode wants to know that it has been bound to a mouse button.
    7070        '''
    class MouseMode:  
    139139    def uses_wheel(self):
    140140        '''Return True if derived class implements the wheel() method.'''
    141141        return getattr(self, 'wheel') != MouseMode.wheel
    142    
     142
    143143    def pause(self, position):
    144144        '''
    145145        Supported API.
    class MouseMode:  
    156156        '''
    157157        pass
    158158
     159    def touchpad_two_finger_scale(self, scale):
     160        '''
     161        Supported API.
     162        Override this method to take action when a two-finger pinching motion
     163        is used on a multitouch touchpad. The scale parameter is a float, where
     164        values larger than 1 indicate fingers moving apart and values less than
     165        1 indicate fingers moving together.
     166        '''
     167        pass
     168
     169    def touchpad_two_finger_twist(self, angle):
     170        '''
     171        Supported API.
     172        Override this method to take action when a two-finger twisting motion
     173        is used on a multitouch touchpad. The angle parameter is the rotation
     174        angle in degrees.
     175        '''
     176        pass
     177
     178    def touchpad_two_finger_trans(self, move):
     179        '''
     180        Supported API.
     181        Override this method to take action when a two-finger swiping motion  is
     182        used on a multitouch touchpad. The move parameter is a tuple of two
     183        floats: (delta_x, delta_y), where delta_x and delta_y are
     184        distances expressed as fractions of the total width of the trackpad
     185        '''
     186        pass
     187
     188    def touchpad_three_finger_trans(self, move):
     189        '''
     190        Supported API.
     191        Override this method to take action when a three-finger swiping motion
     192        is used on a multitouch touchpad. The move parameter is a tuple of two
     193        floats: (delta_x, delta_y) representing the distance moved on the
     194        touchpad as a fraction of its width.
     195        '''
     196        pass
     197
     198    def touchpad_four_finger_trans(self, move):
     199        '''
     200        Supported API.
     201        Override this method to take action when a four-finger swiping motion
     202        is used on a multitouch touchpad. The move parameter is a tuple of two
     203        floats: (delta_x, delta_y) representing the distance moved on the
     204        touchpad as a fraction of its width.
     205        '''
     206        pass
     207
     208
    159209    def pixel_size(self, center = None, min_scene_frac = 1e-5):
    160210        '''
    161211        Supported API.
    class MouseMode:  
    201251        cfile = inspect.getfile(cls)
    202252        p = path.join(path.dirname(cfile), file)
    203253        return p
    204    
     254
    205255class MouseBinding:
    206256    '''
    207257    Associates a mouse button ('left', 'middle', 'right', 'wheel', 'pause') and
    class MouseBinding:  
    227277        '''
    228278        return button == self.button and set(modifiers) == set(self.modifiers)
    229279
     280
     281
     282
     283
    230284class MouseModes:
    231285    '''
    232286    Keep the list of available mouse modes and also which mode is bound
    class MouseModes:  
    243297        self._available_modes = [mode(session) for mode in standard_mouse_mode_classes()]
    244298
    245299        self._bindings = []  # List of MouseBinding instances
     300        self._trackpad_bindings = [] # List of MultitouchBinding instances
    246301
    247302        from PyQt5.QtCore import Qt
    248303        # Qt maps control to meta on Mac...
    249         self._modifier_bits = []
    250         for keyfunc in ["alt", "control", "command", "shift"]:
    251             self._modifier_bits.append((mod_key_info(keyfunc)[0], keyfunc))
    252304
    253305        # Mouse pause parameters
    254306        self._last_mouse_time = None
    class MouseModes:  
    261313        self._last_mode = None                  # Remember mode at mouse down and stay with it until mouse up
    262314
    263315        from .trackpad import MultitouchTrackpad
    264         self.trackpad = MultitouchTrackpad(session)
     316        self.trackpad = MultitouchTrackpad(session, self)
    265317
    266     def bind_mouse_mode(self, button, modifiers, mode):
     318    def bind_mouse_mode(self, mouse_button=None, mouse_modifiers=[], mode=None,
     319            trackpad_action=None, trackpad_modifiers=[]):
     320        '''
     321        Bind a MouseMode to a mouse click and/or a multitouch trackpad action
     322        with optional modifier keys.
     323
     324        mouse_button is either None or one of ("left", "middle", "right", "wheel", or "pause").
     325
     326        trackpad_action is either None or one of ("pinch", "twist", "two finger swipe",
     327        "three finger swipe" or "four finger swipe").
     328
     329        mouse_modifiers and trackpad_modifiers are each a list of 0 or more of
     330        ("alt", "command", "control" or "shift").
     331
     332        mode is a MouseMode instance.
     333        '''
     334        if mouse_button is not None:
     335            self._bind_mouse_mode(mouse_button, mouse_modifiers, mode)
     336        if trackpad_action is not None:
     337            self._bind_trackpad_mode(trackpad_action, trackpad_modifiers, mode)
     338
     339    def _bind_mouse_mode(self, button, modifiers, mode):
    267340        '''
    268341        Button is "left", "middle", "right", "wheel", or "pause".
    269342        Modifiers is a list 0 or more of 'alt', 'command', 'control', 'shift'.
    class MouseModes:  
    279352        if button == "right" and not modifiers:
    280353            self.session.triggers.activate_trigger("set right mouse", mode)
    281354
     355    def _bind_trackpad_mode(self, action, modifiers, mode):
     356        '''
     357        Action is one of ("pinch", "twist", "two finger swipe",
     358        "three finger swipe" or "four finger swipe"). Modifiers is a list of
     359        0 or more of ("alt", "command", "control" or "shift"). Mode is a
     360        MouseMode instance.
     361        '''
     362        self.remove_binding(trackpad_action=action, trackpad_modifiers=modifiers)
     363        if mode is not None:
     364            from .std_modes import NullMouseMode
     365            if not isinstance(mode, NullMouseMode):
     366                from .trackpad import MultitouchBinding
     367                b = MultitouchBinding(action, modifiers, mode)
     368                self._trackpad_bindings.append(b)
     369                mode.enable()
     370
    282371    def bind_standard_mouse_modes(self, buttons = ('left', 'middle', 'right', 'wheel', 'pause')):
    283372        '''
    284373        Bind the standard mouse modes: left = rotate, ctrl-left = select, middle = translate,
    285374        right = zoom, wheel = zoom, pause = identify object.
    286375        '''
    287376        standard_modes = (
    288             ('left', [], 'rotate'),
    289             ('left', ['control'], 'select'),
    290             ('middle', [], 'translate'),
    291             ('right', [], 'translate'),
    292             ('wheel', [], 'zoom'),
    293             ('pause', [], 'identify object'),
     377            ('left', [], 'two finger swipe', [], 'rotate'),
     378            (None, [], 'twist', [], 'rotate'),
     379            ('left', ['control'], None, [], 'select'),
     380            ('middle', [], 'three finger swipe', [], 'translate'),
     381            ('right', [], None, [], 'translate'),
     382            ('wheel', [], 'pinch', [], 'zoom'),
     383            ('pause', [], None, [], 'identify object'),
    294384            )
    295385        mmap = {m.name:m for m in self.modes}
    296         for button, modifiers, mode_name in standard_modes:
    297             if button in buttons:
    298                 self.bind_mouse_mode(button, modifiers, mmap[mode_name])
     386        for button, modifiers, trackpad_action, trackpad_modifiers, mode_name in standard_modes:
     387            self.bind_mouse_mode(button, modifiers, mmap[mode_name], trackpad_action, trackpad_modifiers)
    299388
    300389    def add_mode(self, mode):
    301390        '''Supported API. Add a MouseMode instance to the list of available modes.'''
    class MouseModes:  
    321410            m = None
    322411        return m
    323412
     413    def trackpad_mode(self, action, modifiers=[], exact=False):
     414        '''
     415        Return the MouseMode associated with a specific multitouch action and
     416        modifiers, or None if no mode is bound.
     417        '''
     418        if exact:
     419            mb = [b for b in self._trackpad_bindings if b.exact_match(action, modifiers)]
     420        else:
     421            mb = [b for b in self._trackpad_bindings if b.matches(action, modifiers)]
     422        if len(mb) == 1:
     423            m = mb[0].mode
     424        elif len(mb) > 1:
     425            m = max(mb, key = lambda b: len(b.modifiers)).mode
     426        else:
     427            m = None
     428        return m
     429
    324430    @property
    325431    def modes(self):
    326432        '''List of MouseMode instances.'''
    class MouseModes:  
    331437            if m.name == name:
    332438                return m
    333439        return None
    334    
     440
    335441    def mouse_pause_tracking(self):
    336442        '''
    337443        Called periodically to check for mouse pause and invoke pause mode.
    class MouseModes:  
    362468                self._mouse_pause()
    363469                self._paused = True
    364470
    365     def remove_binding(self, button, modifiers):
     471    def remove_binding(self, button=None, modifiers=[],
     472            trackpad_action=None, trackpad_modifiers=[]):
    366473        '''
    367474        Unbind the mouse button and modifier key combination.
    368475        No mode will be associated with this button and modifier.
    369476        '''
    370         self._bindings = [b for b in self.bindings if not b.exact_match(button, modifiers)]
     477        if button is not None:
     478            self._bindings = [b for b in self.bindings if not b.exact_match(button, modifiers)]
     479        if trackpad_action is not None:
     480            self._trackpad_bindings = [b for b in self._trackpad_bindings if not b.exact_match(trackpad_action, trackpad_modifiers)]
    371481
    372482    def remove_mode(self, mode):
    373483        '''Remove a MouseMode instance from the list of available modes.'''
    class MouseModes:  
    382492    def _mouse_buttons_down(self):
    383493        from PyQt5.QtCore import Qt
    384494        return self.session.ui.mouseButtons() != Qt.NoButton
    385        
     495
    386496    def _dispatch_mouse_event(self, event, action):
    387497        button, modifiers = self._event_type(event)
    388498        if button is None:
    class MouseModes:  
    404514            self._last_mode = None
    405515
    406516    def _event_type(self, event):
    407         modifiers = self._key_modifiers(event)
     517        modifiers = key_modifiers(event)
    408518
    409519        # button() gives press/release buttons; buttons() gives move buttons
    410520        from PyQt5.QtCore import Qt
    class MouseModes:  
    449559
    450560        return button, modifiers
    451561
     562    def _dispatch_touch_event(self, touch_event):
     563        te = touch_event
     564        from .trackpad import touch_action_to_property
     565        for action, prop in touch_action_to_property.items():
     566            data = getattr(touch_event, prop)
     567            if getattr(touch_event, prop) is None:
     568                continue
     569            m = self.trackpad_mode(action, te.modifiers)
     570            if m is not None:
     571                f = getattr(m, 'touchpad_'+prop)
     572                f(data)
     573
     574
     575        # t_string = ('Registered touch event: \n'
     576        #     'modifer keys pressed: {}\n'
     577        #     'wheel_value: {}\n'
     578        #     'two_finger_trans: {}\n'
     579        #     'two_finger_scale: {}\n'
     580        #     'two_finger_twist: {}\n'
     581        #     'three_finger_trans: {}\n'
     582        #     'four_finger_trans: {}').format(
     583        #         ', '.join(te._modifiers),
     584        #         te.wheel_value,
     585        #         te.two_finger_trans,
     586        #         te.two_finger_scale,
     587        #         te.two_finger_twist,
     588        #         te.three_finger_trans,
     589        #         te.four_finger_trans
     590        #     )
     591        # print(t_string)
     592
     593
    452594    def _have_mode(self, button, modifier):
    453595        for b in self.bindings:
    454596            if b.exact_match(button, [modifier]):
    455597                return True
    456598        return False
    457599
    458     def _key_modifiers(self, event):
    459         mod = event.modifiers()
    460         modifiers = [mod_name for bit, mod_name in self._modifier_bits if bit & mod]
    461         return modifiers
    462 
    463600    def _mouse_pause(self):
    464601        m = self.mode('pause')
    465602        if m:
    class MouseModes:  
    482619    def _wheel_event(self, event):
    483620        if self.trackpad.discard_trackpad_wheel_event(event):
    484621            return      # Trackpad processing handled this event
    485         f = self.mode('wheel', self._key_modifiers(event))
     622        f = self.mode('wheel', key_modifiers(event))
    486623        if f:
    487624            f.wheel(MouseEvent(event))
    488625
    class MouseEvent:  
    498635                                        # for mouse button emulation.
    499636        self._position = position       # x,y in pixels, can be None
    500637        self._wheel_value = wheel_value # wheel clicks (usually 1 click equals 15 degrees rotation).
    501        
     638
    502639    def shift_down(self):
    503640        '''
    504641        Supported API.
    class MouseEvent:  
    556693                delta = min(deltas.x(), deltas.y())
    557694            return delta/120.0   # Usually one wheel click is delta of 120
    558695        return 0
    559        
     696
    560697def mod_key_info(key_function):
    561698    """Qt swaps control/meta on Mac, so centralize that knowledge here.
    562699    The possible "key_functions" are: alt, control, command, and shift
    def mod_key_info(key_function):  
    584721            return Qt.ControlModifier, "control"
    585722        return Qt.MetaModifier, command_name
    586723
     724_function_keys = ["alt", "control", "command", "shift"]
     725_modifier_bits = [(mod_key_info(fkey)[0], fkey) for fkey in _function_keys]
     726
     727
     728def key_modifiers(event):
     729    return decode_modifier_bits(event.modifiers())
     730
     731def decode_modifier_bits(mod):
     732    modifiers = [mod_name for bit, mod_name in _modifier_bits if bit & mod]
     733    return modifiers
     734
     735
    587736def keyboard_modifier_names(qt_keyboard_modifiers):
    588737    from PyQt5.QtCore import Qt
    589738    import sys
    def keyboard_modifier_names(qt_keyboard_modifiers):  
    601750    mnames = [mname for mflag, mname in modifiers if mflag & qt_keyboard_modifiers]
    602751    return mnames
    603752
     753
     754
     755
     756
    604757def unpickable(drawing):
    605758    return not getattr(drawing, 'pickable', True)
    606    
     759
    607760def picked_object(window_x, window_y, view, max_transparent_layers = 3, exclude = unpickable):
    608761    xyz1, xyz2 = view.clip_plane_points(window_x, window_y)
    609762    if xyz1 is None or xyz2 is None:
    def picked_object_on_segment(xyz1, xyz2, view, max_transparent_layers = 3, exclu  
    621774        else:
    622775            break
    623776    return p2 if p2 else p
    624 
  • src/bundles/mouse_modes/src/std_modes.py

    diff --git a/src/bundles/mouse_modes/src/std_modes.py b/src/bundles/mouse_modes/src/std_modes.py
    index c68bd96f6..9826f6072 100644
    a b class SelectSubtractMouseMode(SelectMouseMode):  
    172172    '''Mouse mode to subtract objects from selection by clicking on them.'''
    173173    name = 'select subtract'
    174174    icon_file = None
    175    
     175
    176176class SelectToggleMouseMode(SelectMouseMode):
    177177    '''Mouse mode to toggle selected objects by clicking on them.'''
    178178    name = 'select toggle'
    class MoveMouseMode(MouseMode):  
    264264        # Undo
    265265        self._starting_atom_scene_coords = None
    266266        self._starting_model_positions = None
    267        
     267
    268268    def mouse_down(self, event):
    269269        MouseMode.mouse_down(self, event)
    270270        if self.action(event) == 'rotate':
    class MoveMouseMode(MouseMode):  
    283283            self._translate(shift)
    284284        self._moved = True
    285285
     286
    286287    def mouse_up(self, event):
    287288        if self.click_to_select:
    288289            if event.position() == self.mouse_down_position:
    class MoveMouseMode(MouseMode):  
    294295
    295296        if self.move_atoms:
    296297            self._atoms = None
    297        
     298
    298299    def wheel(self, event):
    299300        d = event.wheel_value()
    300301        if self.move_atoms:
    class MoveMouseMode(MouseMode):  
    311312            # Holding shift key switches between rotation and translation
    312313            a = 'translate' if a == 'rotate' else 'rotate'
    313314        return a
    314    
     315
     316    def touchpad_two_finger_trans(self, move):
     317        if self.mouse_action=='rotate':
     318            tp = self.session.ui.mouse_modes.trackpad
     319            from math import sqrt
     320            dx, dy = move
     321            turns = sqrt(dx*dx + dy*dy)*tp.full_width_translation_distance/tp.full_rotation_distance
     322            angle = tp.trackpad_speed*360*turns
     323            self._rotate((dy, dx, 0), angle)
     324
     325    def touchpad_three_finger_trans(self, move):
     326        dx, dy = move
     327        if self.mouse_action=='translate':
     328            tp = self.session.ui.mouse_modes.trackpad
     329            ww = self.session.view.window_size[0] # window width in pixels
     330            s = tp.trackpad_speed*ww
     331            self._translate((s*dx, -s*dy, 0))
     332
     333    def touchpad_two_finger_twist(self, angle):
     334        if self.mouse_action=='rotate':
     335            self._rotate((0,0,1), angle)
     336
     337
    315338    def _set_z_rotation(self, event):
    316339        x,y = event.position()
    317340        w,h = self.view.window_size
    class MoveMouseMode(MouseMode):  
    372395            self._move_atoms(translation(step))
    373396        else:
    374397            self.view.translate(step, self.models())
    375        
     398
    376399    def _translation(self, event):
    377400        '''Returned shift is in camera coordinates.'''
    378401        dx, dy = self.mouse_motion(event)
    class MoveMouseMode(MouseMode):  
    401424    @property
    402425    def _moving_atoms(self):
    403426        return self.move_atoms and self._atoms is not None and len(self._atoms) > 0
    404        
     427
    405428    def _move_atoms(self, transform):
    406429        atoms = self._atoms
    407430        atoms.scene_coords = transform * atoms.scene_coords
    class MoveMouseMode(MouseMode):  
    444467            from chimerax.atomic import selected_atoms
    445468            self._atoms = selected_atoms(self.session)
    446469        self._undo_start()
    447        
     470
    448471    def vr_motion(self, event):
    449472        # Virtual reality hand controller motion.
    450473        if self._moving_atoms:
    class MoveMouseMode(MouseMode):  
    452475        else:
    453476            self.view.move(event.motion, self.models())
    454477        self._moved = True
    455        
     478
    456479    def vr_release(self, event):
    457480        # Virtual reality hand controller button release.
    458481        self._undo_save()
    459        
     482
    460483class RotateMouseMode(MoveMouseMode):
    461484    '''
    462485    Mouse mode to rotate objects (actually the camera is moved) by dragging.
    class RotateSelectedAtomsMouseMode(RotateMouseMode):  
    560583    name = 'rotate selected atoms'
    561584    icon_file = 'icons/rotate_atoms.png'
    562585    move_atoms = True
    563        
     586
    564587class ZoomMouseMode(MouseMode):
    565588    '''
    566589    Mouse mode to move objects in z, actually the camera is moved
    class ZoomMouseMode(MouseMode):  
    572595        MouseMode.__init__(self, session)
    573596        self.speed = 1
    574597
    575     def mouse_drag(self, event):       
     598    def mouse_drag(self, event):
    576599
    577600        dx, dy = self.mouse_motion(event)
    578601        psize = self.pixel_size()
    class ZoomMouseMode(MouseMode):  
    585608        delta_z = 100*d*psize*self.speed
    586609        self.zoom(delta_z, stereo_scaling = not event.alt_down())
    587610
     611    def touchpad_two_finger_scale(self, scale):
     612        v = self.session.view
     613        wpix = v.window_size[0]
     614        psize = v.pixel_size()
     615        d = (scale-1)*wpix*psize
     616        self.zoom(d)
     617
    588618    def zoom(self, delta_z, stereo_scaling = False):
    589619        v = self.view
    590620        c = v.camera
    class ZoomMouseMode(MouseMode):  
    597627        else:
    598628            shift = c.position.transform_vector((0, 0, delta_z))
    599629            v.translate(shift)
    600        
     630
    601631class ObjectIdMouseMode(MouseMode):
    602632    '''
    603633    Mouse mode to that shows the name of an object in a popup window
    class ObjectIdMouseMode(MouseMode):  
    607637    def __init__(self, session):
    608638        MouseMode.__init__(self, session)
    609639        session.triggers.add_trigger('mouse hover')
    610        
     640
    611641    def pause(self, position):
    612642        ui = self.session.ui
    613643        if ui.activeWindow() is None:
    class AtomCenterOfRotationMode(MouseMode):  
    677707            return
    678708        from chimerax.std_commands import cofr
    679709        cofr.cofr(self.session, pivot=xyz)
    680            
     710
    681711class NullMouseMode(MouseMode):
    682712    '''Used to assign no mode to a mouse button.'''
    683713    name = 'none'
    class ClipMouseMode(MouseMode):  
    720750        front_shift = 1 if shift or not alt else 0
    721751        back_shift = 0 if not (alt or shift) else (1 if alt and shift else -1)
    722752        return front_shift, back_shift
    723    
     753
    724754    def wheel(self, event):
    725755        d = event.wheel_value()
    726756        psize = self.pixel_size()
    class ClipMouseMode(MouseMode):  
    766796            use_scene_planes = (clip_settings.mouse_clip_plane_type == 'scene planes')
    767797        else:
    768798            use_scene_planes = (p.find_plane('front') or p.find_plane('back'))
    769                
     799
    770800        pfname, pbname = ('front','back') if use_scene_planes else ('near','far')
    771        
     801
    772802        pf, pb = p.find_plane(pfname), p.find_plane(pbname)
    773803        from chimerax.std_commands.clip import adjust_plane
    774804        c = v.camera
  • src/bundles/mouse_modes/src/trackpad.py

    diff --git a/src/bundles/mouse_modes/src/trackpad.py b/src/bundles/mouse_modes/src/trackpad.py
    old mode 100644
    new mode 100755
    index d0e2109d3..c85d90fb3
    a b class MultitouchTrackpad:  
    1717    and three finger drag translate scene,
    1818    and two finger pinch zoom scene.
    1919    '''
    20     def __init__(self, session):
     20
     21    def __init__(self, session, mouse_mode_mgr):
    2122        self._session = session
     23        self._mouse_mode_mgr = mouse_mode_mgr
    2224        self._view = session.main_view
    2325        self._recent_touches = []       # List of Touch instances
     26        self._modifier_keys = []
    2427        self._last_touch_locations = {} # Map touch id -> (x,y)
    2528        from .settings import settings
    2629        self.trackpad_speed = settings.trackpad_sensitivity     # Trackpad position sensitivity
    class MultitouchTrackpad:  
    3437        self._touch_handler = None
    3538        self._received_touch_event = False
    3639
     40    @property
     41    def full_width_translation_distance(self):
     42        return self._full_width_translation_distance
     43
     44    @property
     45    def full_rotation_distance(self):
     46        return self._full_rotation_distance
     47
    3748    def set_graphics_window(self, graphics_window):
    3849        graphics_window.touchEvent = self._touch_event
    3950        self._enable_touch_events(graphics_window)
    class MultitouchTrackpad:  
    5061            t.remove_handler(h)
    5162            h = None
    5263        self._touch_handler = h
    53    
     64
    5465    def _enable_touch_events(self, graphics_window):
    5566        from sys import platform
    5667        if platform == 'darwin':
    class MultitouchTrackpad:  
    6980        w.setAttribute(Qt.WA_AcceptTouchEvents)
    7081        print('graphics widget touch enabled', w.testAttribute(Qt.WA_AcceptTouchEvents))
    7182        '''
    72        
     83
    7384    # Appears that Qt has disabled touch events on Mac due to unresolved scrolling lag problems.
    7485    # Searching for qt setAcceptsTouchEvents shows they were disabled Oct 17, 2012.
    7586    # A patch that allows an environment variable QT_MAC_ENABLE_TOUCH_EVENTS to allow touch
    class MultitouchTrackpad:  
    8495
    8596        from PyQt5.QtCore import QEvent
    8697        t = event.type()
     98        # For some unfathomable reason the QTouchEvent.modifiers() method always
     99        # returns zero (QTBUG-60389, unresolved since 2017). So we need to do a
     100        # little hacky workaround
     101
     102        from .mousemodes import decode_modifier_bits
     103        # session.ui.keyboardModifiers() does *not* work here (always returns 0)
     104        mb = int(self._session.ui.queryKeyboardModifiers())
     105        self._modifier_keys = decode_modifier_bits(mb)
     106
     107
    87108        if t == QEvent.TouchUpdate:
    88             # On Mac touch events get backlogged in queue when the events cause 
     109            # On Mac touch events get backlogged in queue when the events cause
    89110            # time consuming computatation.  It appears Qt does not collapse the events.
    90111            # So event processing can get tens of seconds behind.  To reduce this problem
    91112            # we only handle the most recent touch update per redraw.
    class MultitouchTrackpad:  
    100121    def _collapse_touch_events(self):
    101122        touches = self._recent_touches
    102123        if touches:
    103             self._process_touches(touches)
     124            event = self._process_touches(touches)
    104125            self._recent_touches = []
     126            self._mouse_mode_mgr._dispatch_touch_event(event)
    105127
    106128    def _process_touches(self, touches):
     129        pinch = twist = scroll = None
     130        two_swipe = None
     131        three_swipe = None
     132        four_swipe = None
    107133        n = len(touches)
    108134        speed = self.trackpad_speed
    109135        moves = [t.move(self._last_touch_locations) for t in touches]
     136        dx = sum(x for x,y in moves)/n
     137        dy = sum(y for x,y in moves)/n
     138
    110139        if n == 2:
    111140            (dx0,dy0),(dx1,dy1) = moves[0], moves[1]
    112141            from math import sqrt, exp, atan2, pi
    113142            l0,l1 = sqrt(dx0*dx0 + dy0*dy0),sqrt(dx1*dx1 + dy1*dy1)
    114143            d12 = dx0*dx1+dy0*dy1
    115144            if d12 < 0:
    116                 # Finger moving in opposite directions: pinch or twist
     145                # Finger moving in opposite directions: pinch/twist
    117146                (x0,y0),(x1,y1) = [(t.x,t.y) for t in touches[:2]]
    118147                sx,sy = x1-x0,y1-y0
    119148                sn = sqrt(sx*sx + sy*sy)
    120149                sd0,sd1 = sx*dx0 + sy*dy0, sx*dx1 + sy*dy1
    121                 if abs(sd0) > 0.5*sn*l0 and abs(sd1) > 0.5*sn*l1:
    122                     # Fingers move along line between them: pinch to zoom
    123                     zf = 1 + speed * self._zoom_scaling * (l0+l1) / self._full_width_translation_distance
    124                     if sd1 < 0:
    125                         zf = 1/zf
    126                     self._zoom(zf)
    127                 else:
    128                     # Fingers move perpendicular to line between them: twist
    129                     rot = atan2(-sy*dx1+sx*dy1,sn*sn) + atan2(sy*dx0-sx*dy0,sn*sn)
    130                     a = -speed * self._twist_scaling * rot * 180 / pi
    131                     zaxis = (0,0,1)
    132                     self._rotate(zaxis, a)
    133                 return
    134             # Fingers moving in same direction: rotation
    135             dx = sum(x for x,y in moves)/n
    136             dy = sum(y for x,y in moves)/n
    137             from math import sqrt
    138             turns = sqrt(dx*dx + dy*dy)/self._full_rotation_distance
    139             angle = speed*360*turns
    140             self._rotate((dy, dx, 0), angle)
     150                zf = 1 + speed * self._zoom_scaling * (l0+l1) / self._full_width_translation_distance
     151                if sd1 < 0:
     152                    zf = 1/zf
     153                pinch = zf
     154                rot = atan2(-sy*dx1+sx*dy1,sn*sn) + atan2(sy*dx0-sx*dy0,sn*sn)
     155                a = -speed * self._twist_scaling * rot * 180 / pi
     156                twist = a
     157            else:
     158                two_swipe = tuple([d/self._full_width_translation_distance for d in (dx, dy)])
     159                scroll = speed * dy / self._wheel_click_pixels
    141160        elif n == 3:
    142             dx = sum(x for x,y in moves)/n
    143             dy = sum(y for x,y in moves)/n
    144             ww = self._view.window_size[0]      # Window width in pixels
    145             s = speed * ww / self._full_width_translation_distance
    146             self._translate((s*dx, -s*dy, 0))
     161            three_swipe = tuple([d/self._full_width_translation_distance for d in (dx, dy)])
    147162        elif n == 4:
    148             # Use scrollwheel mouse mode
    149             ses = self._session
    150             from .mousemodes import keyboard_modifier_names, MouseEvent
    151             modifiers = keyboard_modifier_names(ses.ui.queryKeyboardModifiers())
    152             scrollwheel_mode = ses.ui.mouse_modes.mode(button = 'wheel', modifiers = modifiers)
    153             if scrollwheel_mode:
    154                 xy = (sum(t.x for t in touches)/n, sum(t.y for t in touches)/n)
    155                 dy = sum(y for x,y in moves)/n                  # pixels
    156                 delta = speed * dy / self._wheel_click_pixels   # wheel clicks
    157                 scrollwheel_mode.wheel(MouseEvent(position = xy, wheel_value = delta, modifiers = modifiers))
     163            four_swipe = tuple([d/self._full_width_translation_distance for d in (dx, dy)])
     164
     165        return MultitouchEvent(modifiers=self._modifier_keys,
     166            wheel_value=scroll, two_finger_trans=two_swipe, two_finger_scale=pinch,
     167            two_finger_twist=twist, three_finger_trans=three_swipe,
     168            four_finger_trans=four_swipe)
     169
     170        return pinch, twist, scroll, two_swipe, three_swipe, four_swipe
    158171
    159172    def _rotate(self, screen_axis, angle):
    160173        if angle == 0:
    class Touch:  
    230243        x,y = self.x, self.y
    231244        last_touch_locations[id] = (x,y)
    232245        return (x-lx, y-ly)
     246
     247touch_action_to_property = {
     248    'pinch':    'two_finger_scale',
     249    'twist':    'two_finger_twist',
     250    'two finger swipe': 'two_finger_trans',
     251    'three finger swipe':   'three_finger_trans',
     252    'four finger swipe':    'four_finger_trans',
     253}
     254
     255
     256class MultitouchBinding:
     257    '''
     258    Associates an action on a multitouch trackpad and a set of modifier keys
     259    ('alt', 'command', 'control', 'shift') with a MouseMode.
     260    '''
     261    valid_actions = list(touch_action_to_property.keys())
     262
     263    def __init__(self, action, modifiers, mode):
     264        if action not in self.valid_actions:
     265            from chimerax.core.errors import UserError
     266            raise UserError('Unrecognised touchpad action! Must be one of: {}'.format(
     267                ', '.join(valid_actions)
     268            ))
     269        self.action = action
     270        self.modifiers = modifiers
     271        self.mode = mode
     272    def matches(self, action, modifiers):
     273        '''
     274        Does this binding match the specified action and modifiers?
     275        A match requires all of the binding modifiers keys are among
     276        the specified modifiers (and possibly more).
     277        '''
     278        return (action==self.action and
     279            len([k for k in self.modifiers if not k in modifiers]) == 0
     280        )
     281    def exact_match(self, action, modifiers):
     282        '''
     283        Does this binding exactly match the specified action and modifiers?
     284        An exact match requires the binding modifiers keys are exactly the
     285        same set as the specified modifier keys.
     286        '''
     287        return action == self.action and set(modifiers) == set(self.modifiers)
     288
     289
     290from .mousemodes import MouseEvent
     291class MultitouchEvent(MouseEvent):
     292    '''
     293    Provides an interface to events fired by multi-touch trackpads and modifier
     294    keys so that mouse modes do not directly depend on details of the window
     295    toolkit or trackpad implementation.
     296    '''
     297    def __init__(self, modifiers = None,  wheel_value = None,
     298            two_finger_trans=None, two_finger_scale=None, two_finger_twist=None,
     299            three_finger_trans=None, four_finger_trans=None):
     300        super().__init__(event=None, modifiers=modifiers, position=None, wheel_value=wheel_value)
     301        self._two_finger_trans = two_finger_trans
     302        self._two_finger_scale = two_finger_scale
     303        self._two_finger_twist = two_finger_twist
     304        self._three_finger_trans = three_finger_trans
     305        self._four_finger_trans = four_finger_trans
     306
     307    @property
     308    def modifiers(self):
     309        return self._modifiers
     310
     311    # @property
     312    # def event(self):
     313    #     '''
     314    #     The core QTouchEvent object
     315    #     '''
     316    #     return self._event
     317
     318    @property
     319    def wheel_value(self):
     320        '''
     321        Supported API.
     322        Effective mouse wheel value if two-finger vertical swipe is to be
     323        interpreted as a scrolling action.
     324        '''
     325        return self._wheel_value
     326
     327    @property
     328    def two_finger_trans(self):
     329        '''
     330        Supported API.
     331        Returns a tuple (delta_x, delta_y) in screen coordinates representing
     332        the movement when a two-finger swipe is interpreted as a translation
     333        action.
     334        '''
     335        return self._two_finger_trans
     336
     337    @property
     338    def two_finger_scale(self):
     339        '''
     340        Supported API
     341        Returns a float representing the change in a two-finger pinching action.
     342        '''
     343        return self._two_finger_scale
     344
     345    @property
     346    def two_finger_twist(self):
     347        '''
     348        Supported API
     349        Returns the rotation in degrees defined by a two-finger twisting action.
     350        '''
     351        return self._two_finger_twist
     352
     353    @property
     354    def three_finger_trans(self):
     355        '''
     356        Supported API
     357        Returns a tuple (delta_x, delta_y) in screen coordinates representing
     358        the translation in a 3-fingered swipe.
     359        '''
     360        return self._three_finger_trans
     361
     362    @property
     363    def four_finger_trans(self):
     364        '''
     365        Supported API
     366        Returns a tuple (delta_x, delta_y) in screen coordinates representing
     367        the translation in a 3-fingered swipe.
     368        '''
     369        return self._four_finger_trans