Ticket #3269: mousemodes-1.diff

File mousemodes-1.diff, 35.1 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..ecb1d0e68
    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, event):
     160        '''
     161        Supported API.
     162        Override this method to take action when a two-finger pinching motion is
     163        used on a multitouch touchpad. The scale parameter is available as
     164        event.two_finger_scale and is a float, where values larger than 1
     165        indicate fingers moving apart and values less than 1 indicate fingers
     166        moving together.
     167        '''
     168        pass
     169
     170    def touchpad_two_finger_twist(self, event):
     171        '''
     172        Supported API.
     173        Override this method to take action when a two-finger twisting motion
     174        is used on a multitouch touchpad. The angle parameter is available as
     175        event.two_finger_twist, and is a float representing the rotation
     176        angle in degrees.
     177        '''
     178        pass
     179
     180    def touchpad_two_finger_trans(self, event):
     181        '''
     182        Supported API.
     183        Override this method to take action when a two-finger swiping motion is
     184        used on a multitouch touchpad. This method should use either
     185        event.two_finger_trans (a tuple of two floats delta_x and delta_y) or
     186        event.wheel_value (an effective mouse wheel value synthesized from
     187        delta_y). delta_x and delta_y are distances expressed as fractions of
     188        the total width of the trackpad.
     189        '''
     190        pass
     191
     192    def touchpad_three_finger_trans(self, event):
     193        '''
     194        Supported API.
     195        Override this method to take action when a three-finger swiping motion
     196        is used on a multitouch touchpad. The move parameter is available as
     197        event.three_finger_trans and is a tuple of two floats: (delta_x,
     198        delta_y) representing the distance moved on the touchpad as a fraction
     199        of its width.
     200        '''
     201        pass
     202
     203    def touchpad_four_finger_trans(self, event):
     204        '''
     205        Supported API.
     206        Override this method to take action when a four-finger swiping motion
     207        is used on a multitouch touchpad. The move parameter is available as
     208        event.three_finger_trans and is a tuple of two floats: (delta_x,
     209        delta_y) representing the distance moved on the touchpad as a fraction
     210        of its width.
     211        '''
     212        pass
     213
     214
    159215    def pixel_size(self, center = None, min_scene_frac = 1e-5):
    160216        '''
    161217        Supported API.
    class MouseMode:  
    201257        cfile = inspect.getfile(cls)
    202258        p = path.join(path.dirname(cfile), file)
    203259        return p
    204    
     260
    205261class MouseBinding:
    206262    '''
    207263    Associates a mouse button ('left', 'middle', 'right', 'wheel', 'pause') and
    class MouseBinding:  
    227283        '''
    228284        return button == self.button and set(modifiers) == set(self.modifiers)
    229285
     286
     287
     288
     289
    230290class MouseModes:
    231291    '''
    232292    Keep the list of available mouse modes and also which mode is bound
    class MouseModes:  
    243303        self._available_modes = [mode(session) for mode in standard_mouse_mode_classes()]
    244304
    245305        self._bindings = []  # List of MouseBinding instances
     306        self._trackpad_bindings = [] # List of MultitouchBinding instances
    246307
    247308        from PyQt5.QtCore import Qt
    248309        # 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))
    252310
    253311        # Mouse pause parameters
    254312        self._last_mouse_time = None
    class MouseModes:  
    261319        self._last_mode = None                  # Remember mode at mouse down and stay with it until mouse up
    262320
    263321        from .trackpad import MultitouchTrackpad
    264         self.trackpad = MultitouchTrackpad(session)
     322        self.trackpad = MultitouchTrackpad(session, self)
    265323
    266     def bind_mouse_mode(self, button, modifiers, mode):
     324    def bind_mouse_mode(self, mouse_button=None, mouse_modifiers=[], mode=None,
     325            trackpad_action=None, trackpad_modifiers=[]):
     326        '''
     327        Bind a MouseMode to a mouse click and/or a multitouch trackpad action
     328        with optional modifier keys.
     329
     330        mouse_button is either None or one of ("left", "middle", "right", "wheel", or "pause").
     331
     332        trackpad_action is either None or one of ("pinch", "twist", "two finger swipe",
     333        "three finger swipe" or "four finger swipe").
     334
     335        mouse_modifiers and trackpad_modifiers are each a list of 0 or more of
     336        ("alt", "command", "control" or "shift").
     337
     338        mode is a MouseMode instance.
     339        '''
     340        if mouse_button is not None:
     341            self._bind_mouse_mode(mouse_button, mouse_modifiers, mode)
     342        if trackpad_action is not None:
     343            self._bind_trackpad_mode(trackpad_action, trackpad_modifiers, mode)
     344
     345    def _bind_mouse_mode(self, button, modifiers, mode):
    267346        '''
    268347        Button is "left", "middle", "right", "wheel", or "pause".
    269348        Modifiers is a list 0 or more of 'alt', 'command', 'control', 'shift'.
    class MouseModes:  
    279358        if button == "right" and not modifiers:
    280359            self.session.triggers.activate_trigger("set right mouse", mode)
    281360
     361    def _bind_trackpad_mode(self, action, modifiers, mode):
     362        '''
     363        Action is one of ("pinch", "twist", "two finger swipe",
     364        "three finger swipe" or "four finger swipe"). Modifiers is a list of
     365        0 or more of ("alt", "command", "control" or "shift"). Mode is a
     366        MouseMode instance.
     367        '''
     368        self.remove_binding(trackpad_action=action, trackpad_modifiers=modifiers)
     369        if mode is not None:
     370            from .std_modes import NullMouseMode
     371            if not isinstance(mode, NullMouseMode):
     372                from .trackpad import MultitouchBinding
     373                b = MultitouchBinding(action, modifiers, mode)
     374                self._trackpad_bindings.append(b)
     375                mode.enable()
     376
    282377    def bind_standard_mouse_modes(self, buttons = ('left', 'middle', 'right', 'wheel', 'pause')):
    283378        '''
    284379        Bind the standard mouse modes: left = rotate, ctrl-left = select, middle = translate,
    285380        right = zoom, wheel = zoom, pause = identify object.
    286381        '''
    287382        standard_modes = (
    288             ('left', [], 'rotate'),
    289             ('left', ['control'], 'select'),
    290             ('middle', [], 'translate'),
    291             ('right', [], 'translate'),
    292             ('wheel', [], 'zoom'),
    293             ('pause', [], 'identify object'),
     383            ('left', [], 'two finger swipe', [], 'rotate'),
     384            (None, [], 'twist', [], 'rotate'),
     385            ('left', ['control'], None, [], 'select'),
     386            ('middle', [], 'three finger swipe', [], 'translate'),
     387            ('right', [], None, [], 'translate'),
     388            ('wheel', [], 'pinch', [], 'zoom'),
     389            ('pause', [], None, [], 'identify object'),
     390            (None, [], 'four finger swipe', [], 'swipe as scroll')
    294391            )
    295392        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])
     393        for button, modifiers, trackpad_action, trackpad_modifiers, mode_name in standard_modes:
     394            self.bind_mouse_mode(button, modifiers, mmap[mode_name], trackpad_action, trackpad_modifiers)
    299395
    300396    def add_mode(self, mode):
    301397        '''Supported API. Add a MouseMode instance to the list of available modes.'''
    class MouseModes:  
    321417            m = None
    322418        return m
    323419
     420    def trackpad_mode(self, action, modifiers=[], exact=False):
     421        '''
     422        Return the MouseMode associated with a specific multitouch action and
     423        modifiers, or None if no mode is bound.
     424        '''
     425        if exact:
     426            mb = [b for b in self._trackpad_bindings if b.exact_match(action, modifiers)]
     427        else:
     428            mb = [b for b in self._trackpad_bindings if b.matches(action, modifiers)]
     429        if len(mb) == 1:
     430            m = mb[0].mode
     431        elif len(mb) > 1:
     432            m = max(mb, key = lambda b: len(b.modifiers)).mode
     433        else:
     434            m = None
     435        return m
     436
    324437    @property
    325438    def modes(self):
    326439        '''List of MouseMode instances.'''
    class MouseModes:  
    331444            if m.name == name:
    332445                return m
    333446        return None
    334    
     447
    335448    def mouse_pause_tracking(self):
    336449        '''
    337450        Called periodically to check for mouse pause and invoke pause mode.
    class MouseModes:  
    362475                self._mouse_pause()
    363476                self._paused = True
    364477
    365     def remove_binding(self, button, modifiers):
     478    def remove_binding(self, button=None, modifiers=[],
     479            trackpad_action=None, trackpad_modifiers=[]):
    366480        '''
    367481        Unbind the mouse button and modifier key combination.
    368482        No mode will be associated with this button and modifier.
    369483        '''
    370         self._bindings = [b for b in self.bindings if not b.exact_match(button, modifiers)]
     484        if button is not None:
     485            self._bindings = [b for b in self.bindings if not b.exact_match(button, modifiers)]
     486        if trackpad_action is not None:
     487            self._trackpad_bindings = [b for b in self._trackpad_bindings if not b.exact_match(trackpad_action, trackpad_modifiers)]
    371488
    372489    def remove_mode(self, mode):
    373490        '''Remove a MouseMode instance from the list of available modes.'''
    class MouseModes:  
    382499    def _mouse_buttons_down(self):
    383500        from PyQt5.QtCore import Qt
    384501        return self.session.ui.mouseButtons() != Qt.NoButton
    385        
     502
    386503    def _dispatch_mouse_event(self, event, action):
    387504        button, modifiers = self._event_type(event)
    388505        if button is None:
    class MouseModes:  
    404521            self._last_mode = None
    405522
    406523    def _event_type(self, event):
    407         modifiers = self._key_modifiers(event)
     524        modifiers = key_modifiers(event)
    408525
    409526        # button() gives press/release buttons; buttons() gives move buttons
    410527        from PyQt5.QtCore import Qt
    class MouseModes:  
    449566
    450567        return button, modifiers
    451568
     569    def _dispatch_touch_event(self, touch_event):
     570        te = touch_event
     571        from .trackpad import touch_action_to_property
     572        for action, prop in touch_action_to_property.items():
     573            data = getattr(te, prop)
     574            if getattr(touch_event, prop) is None:
     575                continue
     576            m = self.trackpad_mode(action, te.modifiers)
     577            if m is not None:
     578                f = getattr(m, 'touchpad_'+prop)
     579                f(te)
     580
     581
     582        # t_string = ('Registered touch event: \n'
     583        #     'modifer keys pressed: {}\n'
     584        #     'wheel_value: {}\n'
     585        #     'two_finger_trans: {}\n'
     586        #     'two_finger_scale: {}\n'
     587        #     'two_finger_twist: {}\n'
     588        #     'three_finger_trans: {}\n'
     589        #     'four_finger_trans: {}').format(
     590        #         ', '.join(te._modifiers),
     591        #         te.wheel_value,
     592        #         te.two_finger_trans,
     593        #         te.two_finger_scale,
     594        #         te.two_finger_twist,
     595        #         te.three_finger_trans,
     596        #         te.four_finger_trans
     597        #     )
     598        # print(t_string)
     599
     600
    452601    def _have_mode(self, button, modifier):
    453602        for b in self.bindings:
    454603            if b.exact_match(button, [modifier]):
    455604                return True
    456605        return False
    457606
    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 
    463607    def _mouse_pause(self):
    464608        m = self.mode('pause')
    465609        if m:
    class MouseModes:  
    482626    def _wheel_event(self, event):
    483627        if self.trackpad.discard_trackpad_wheel_event(event):
    484628            return      # Trackpad processing handled this event
    485         f = self.mode('wheel', self._key_modifiers(event))
     629        f = self.mode('wheel', key_modifiers(event))
    486630        if f:
    487631            f.wheel(MouseEvent(event))
    488632
    class MouseEvent:  
    498642                                        # for mouse button emulation.
    499643        self._position = position       # x,y in pixels, can be None
    500644        self._wheel_value = wheel_value # wheel clicks (usually 1 click equals 15 degrees rotation).
    501        
     645
    502646    def shift_down(self):
    503647        '''
    504648        Supported API.
    class MouseEvent:  
    556700                delta = min(deltas.x(), deltas.y())
    557701            return delta/120.0   # Usually one wheel click is delta of 120
    558702        return 0
    559        
     703
    560704def mod_key_info(key_function):
    561705    """Qt swaps control/meta on Mac, so centralize that knowledge here.
    562706    The possible "key_functions" are: alt, control, command, and shift
    def mod_key_info(key_function):  
    584728            return Qt.ControlModifier, "control"
    585729        return Qt.MetaModifier, command_name
    586730
     731_function_keys = ["alt", "control", "command", "shift"]
     732_modifier_bits = [(mod_key_info(fkey)[0], fkey) for fkey in _function_keys]
     733
     734
     735def key_modifiers(event):
     736    return decode_modifier_bits(event.modifiers())
     737
     738def decode_modifier_bits(mod):
     739    modifiers = [mod_name for bit, mod_name in _modifier_bits if bit & mod]
     740    return modifiers
     741
     742
    587743def keyboard_modifier_names(qt_keyboard_modifiers):
    588744    from PyQt5.QtCore import Qt
    589745    import sys
    def keyboard_modifier_names(qt_keyboard_modifiers):  
    601757    mnames = [mname for mflag, mname in modifiers if mflag & qt_keyboard_modifiers]
    602758    return mnames
    603759
     760
     761
     762
     763
    604764def unpickable(drawing):
    605765    return not getattr(drawing, 'pickable', True)
    606    
     766
    607767def picked_object(window_x, window_y, view, max_transparent_layers = 3, exclude = unpickable):
    608768    xyz1, xyz2 = view.clip_plane_points(window_x, window_y)
    609769    if xyz1 is None or xyz2 is None:
    def picked_object_on_segment(xyz1, xyz2, view, max_transparent_layers = 3, exclu  
    621781        else:
    622782            break
    623783    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..5b0dff48a 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, event):
     317        move = event.two_finger_trans
     318        if self.mouse_action=='rotate':
     319            tp = self.session.ui.mouse_modes.trackpad
     320            from math import sqrt
     321            dx, dy = move
     322            turns = sqrt(dx*dx + dy*dy)*tp.full_width_translation_distance/tp.full_rotation_distance
     323            angle = tp.trackpad_speed*360*turns
     324            self._rotate((dy, dx, 0), angle)
     325
     326    def touchpad_three_finger_trans(self, event):
     327        dx, dy = event.three_finger_trans
     328        if self.mouse_action=='translate':
     329            tp = self.session.ui.mouse_modes.trackpad
     330            ww = self.session.view.window_size[0] # window width in pixels
     331            s = tp.trackpad_speed*ww
     332            self._translate((s*dx, -s*dy, 0))
     333
     334    def touchpad_two_finger_twist(self, event):
     335        angle = event.two_finger_twist
     336        if self.mouse_action=='rotate':
     337            self._rotate((0,0,1), angle)
     338
     339
    315340    def _set_z_rotation(self, event):
    316341        x,y = event.position()
    317342        w,h = self.view.window_size
    class MoveMouseMode(MouseMode):  
    372397            self._move_atoms(translation(step))
    373398        else:
    374399            self.view.translate(step, self.models())
    375        
     400
    376401    def _translation(self, event):
    377402        '''Returned shift is in camera coordinates.'''
    378403        dx, dy = self.mouse_motion(event)
    class MoveMouseMode(MouseMode):  
    401426    @property
    402427    def _moving_atoms(self):
    403428        return self.move_atoms and self._atoms is not None and len(self._atoms) > 0
    404        
     429
    405430    def _move_atoms(self, transform):
    406431        atoms = self._atoms
    407432        atoms.scene_coords = transform * atoms.scene_coords
    class MoveMouseMode(MouseMode):  
    444469            from chimerax.atomic import selected_atoms
    445470            self._atoms = selected_atoms(self.session)
    446471        self._undo_start()
    447        
     472
    448473    def vr_motion(self, event):
    449474        # Virtual reality hand controller motion.
    450475        if self._moving_atoms:
    class MoveMouseMode(MouseMode):  
    452477        else:
    453478            self.view.move(event.motion, self.models())
    454479        self._moved = True
    455        
     480
    456481    def vr_release(self, event):
    457482        # Virtual reality hand controller button release.
    458483        self._undo_save()
    459        
     484
    460485class RotateMouseMode(MoveMouseMode):
    461486    '''
    462487    Mouse mode to rotate objects (actually the camera is moved) by dragging.
    class RotateSelectedAtomsMouseMode(RotateMouseMode):  
    560585    name = 'rotate selected atoms'
    561586    icon_file = 'icons/rotate_atoms.png'
    562587    move_atoms = True
    563        
     588
    564589class ZoomMouseMode(MouseMode):
    565590    '''
    566591    Mouse mode to move objects in z, actually the camera is moved
    class ZoomMouseMode(MouseMode):  
    572597        MouseMode.__init__(self, session)
    573598        self.speed = 1
    574599
    575     def mouse_drag(self, event):       
     600    def mouse_drag(self, event):
    576601
    577602        dx, dy = self.mouse_motion(event)
    578603        psize = self.pixel_size()
    class ZoomMouseMode(MouseMode):  
    585610        delta_z = 100*d*psize*self.speed
    586611        self.zoom(delta_z, stereo_scaling = not event.alt_down())
    587612
     613    def touchpad_two_finger_scale(self, event):
     614        scale = event.two_finger_scale
     615        v = self.session.view
     616        wpix = v.window_size[0]
     617        psize = v.pixel_size()
     618        d = (scale-1)*wpix*psize
     619        self.zoom(d)
     620
    588621    def zoom(self, delta_z, stereo_scaling = False):
    589622        v = self.view
    590623        c = v.camera
    class ZoomMouseMode(MouseMode):  
    597630        else:
    598631            shift = c.position.transform_vector((0, 0, delta_z))
    599632            v.translate(shift)
    600        
     633
    601634class ObjectIdMouseMode(MouseMode):
    602635    '''
    603636    Mouse mode to that shows the name of an object in a popup window
    class ObjectIdMouseMode(MouseMode):  
    607640    def __init__(self, session):
    608641        MouseMode.__init__(self, session)
    609642        session.triggers.add_trigger('mouse hover')
    610        
     643
    611644    def pause(self, position):
    612645        ui = self.session.ui
    613646        if ui.activeWindow() is None:
    class AtomCenterOfRotationMode(MouseMode):  
    677710            return
    678711        from chimerax.std_commands import cofr
    679712        cofr.cofr(self.session, pivot=xyz)
    680            
     713
    681714class NullMouseMode(MouseMode):
    682715    '''Used to assign no mode to a mouse button.'''
    683716    name = 'none'
    class ClipMouseMode(MouseMode):  
    720753        front_shift = 1 if shift or not alt else 0
    721754        back_shift = 0 if not (alt or shift) else (1 if alt and shift else -1)
    722755        return front_shift, back_shift
    723    
     756
    724757    def wheel(self, event):
    725758        d = event.wheel_value()
    726759        psize = self.pixel_size()
    class ClipMouseMode(MouseMode):  
    766799            use_scene_planes = (clip_settings.mouse_clip_plane_type == 'scene planes')
    767800        else:
    768801            use_scene_planes = (p.find_plane('front') or p.find_plane('back'))
    769                
     802
    770803        pfname, pbname = ('front','back') if use_scene_planes else ('near','far')
    771        
     804
    772805        pf, pb = p.find_plane(pfname), p.find_plane(pbname)
    773806        from chimerax.std_commands.clip import adjust_plane
    774807        c = v.camera
    class ClipRotateMouseMode(MouseMode):  
    897930            p.normal = move.transform_vector(p.normal)
    898931            p.plane_point = move * p.plane_point
    899932
     933class SwipeAsScrollMouseMode(MouseMode):
     934    '''
     935    Reinterprets the vertical component of a multi-touch swiping action as a
     936    mouse scroll, and passes it on to the currently-mapped mode.
     937    '''
     938    name = 'swipe as scroll'
     939    def _scrollwheel_mode(self, modifiers):
     940        return self.session.ui.mouse_modes.mode(button='wheel', modifiers=modifiers)
     941
     942    def _wheel_value(self, dy):
     943        tp = self.session.ui.mouse_modes.trackpad
     944        speed = tp.trackpad_speed
     945        wcp = tp.wheel_click_pixels
     946        fwd = tp.full_width_translation_distance
     947        delta = speed * dy * fwd / wcp
     948        return delta
     949
     950    def touchpad_two_finger_trans(self, event):
     951        self._pass_event(event, 'two_finger_trans')
     952
     953    def touchpad_three_finger_trans(self, event):
     954        self._pass_event(event, 'three_finger_trans')
     955
     956    def touchpad_four_finger_trans(self, event):
     957        self._pass_event(event, 'four_finger_trans')
     958
     959    def _pass_event(self, event, event_type):
     960        _, dy = getattr(event, event_type)
     961        swm = self._scrollwheel_mode(event.modifiers)
     962        if swm:
     963            wv = self._wheel_value(dy)
     964            from .mousemodes import MouseEvent
     965            swm.wheel(MouseEvent(position=event.position, wheel_value=wv, modifiers=event.modifiers))
     966
     967
     968
    900969def standard_mouse_mode_classes():
    901970    '''List of core MouseMode classes.'''
    902971    mode_classes = [
    def standard_mouse_mode_classes():  
    918987        ClipRotateMouseMode,
    919988        ObjectIdMouseMode,
    920989        AtomCenterOfRotationMode,
     990        SwipeAsScrollMouseMode,
    921991        NullMouseMode,
    922992    ]
    923993    return mode_classes
  • 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..c3e52f941
    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
     48    @property
     49    def wheel_click_pixels(self):
     50        return self._wheel_click_pixels
     51
    3752    def set_graphics_window(self, graphics_window):
    3853        graphics_window.touchEvent = self._touch_event
    3954        self._enable_touch_events(graphics_window)
    class MultitouchTrackpad:  
    5065            t.remove_handler(h)
    5166            h = None
    5267        self._touch_handler = h
    53    
     68
    5469    def _enable_touch_events(self, graphics_window):
    5570        from sys import platform
    5671        if platform == 'darwin':
    class MultitouchTrackpad:  
    6984        w.setAttribute(Qt.WA_AcceptTouchEvents)
    7085        print('graphics widget touch enabled', w.testAttribute(Qt.WA_AcceptTouchEvents))
    7186        '''
    72        
     87
    7388    # Appears that Qt has disabled touch events on Mac due to unresolved scrolling lag problems.
    7489    # Searching for qt setAcceptsTouchEvents shows they were disabled Oct 17, 2012.
    7590    # A patch that allows an environment variable QT_MAC_ENABLE_TOUCH_EVENTS to allow touch
    class MultitouchTrackpad:  
    8499
    85100        from PyQt5.QtCore import QEvent
    86101        t = event.type()
     102        # For some unfathomable reason the QTouchEvent.modifiers() method always
     103        # returns zero (QTBUG-60389, unresolved since 2017). So we need to do a
     104        # little hacky workaround
     105
     106        from .mousemodes import decode_modifier_bits
     107        # session.ui.keyboardModifiers() does *not* work here (always returns 0)
     108        mb = int(self._session.ui.queryKeyboardModifiers())
     109        self._modifier_keys = decode_modifier_bits(mb)
     110
     111
    87112        if t == QEvent.TouchUpdate:
    88             # On Mac touch events get backlogged in queue when the events cause 
     113            # On Mac touch events get backlogged in queue when the events cause
    89114            # time consuming computatation.  It appears Qt does not collapse the events.
    90115            # So event processing can get tens of seconds behind.  To reduce this problem
    91116            # we only handle the most recent touch update per redraw.
    class MultitouchTrackpad:  
    100125    def _collapse_touch_events(self):
    101126        touches = self._recent_touches
    102127        if touches:
    103             self._process_touches(touches)
     128            event = self._process_touches(touches)
    104129            self._recent_touches = []
     130            self._mouse_mode_mgr._dispatch_touch_event(event)
    105131
    106132    def _process_touches(self, touches):
     133        pinch = twist = scroll = None
     134        two_swipe = None
     135        three_swipe = None
     136        four_swipe = None
    107137        n = len(touches)
    108138        speed = self.trackpad_speed
     139        position = (sum(t.x for t in touches)/n, sum(t.y for t in touches)/n)
    109140        moves = [t.move(self._last_touch_locations) for t in touches]
     141        dx = sum(x for x,y in moves)/n
     142        dy = sum(y for x,y in moves)/n
     143
    110144        if n == 2:
    111145            (dx0,dy0),(dx1,dy1) = moves[0], moves[1]
    112146            from math import sqrt, exp, atan2, pi
    113147            l0,l1 = sqrt(dx0*dx0 + dy0*dy0),sqrt(dx1*dx1 + dy1*dy1)
    114148            d12 = dx0*dx1+dy0*dy1
    115149            if d12 < 0:
    116                 # Finger moving in opposite directions: pinch or twist
     150                # Finger moving in opposite directions: pinch/twist
    117151                (x0,y0),(x1,y1) = [(t.x,t.y) for t in touches[:2]]
    118152                sx,sy = x1-x0,y1-y0
    119153                sn = sqrt(sx*sx + sy*sy)
    120154                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)
     155                zf = 1 + speed * self._zoom_scaling * (l0+l1) / self._full_width_translation_distance
     156                if sd1 < 0:
     157                    zf = 1/zf
     158                pinch = zf
     159                rot = atan2(-sy*dx1+sx*dy1,sn*sn) + atan2(sy*dx0-sx*dy0,sn*sn)
     160                a = -speed * self._twist_scaling * rot * 180 / pi
     161                twist = a
     162            else:
     163                two_swipe = tuple([d/self._full_width_translation_distance for d in (dx, dy)])
     164                scroll = speed * dy / self._wheel_click_pixels
    141165        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))
     166            three_swipe = tuple([d/self._full_width_translation_distance for d in (dx, dy)])
    147167        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))
     168            four_swipe = tuple([d/self._full_width_translation_distance for d in (dx, dy)])
     169
     170        return MultitouchEvent(modifiers=self._modifier_keys,
     171            position=position, wheel_value=scroll, two_finger_trans=two_swipe, two_finger_scale=pinch,
     172            two_finger_twist=twist, three_finger_trans=three_swipe,
     173            four_finger_trans=four_swipe)
     174
     175        return pinch, twist, scroll, two_swipe, three_swipe, four_swipe
    158176
    159177    def _rotate(self, screen_axis, angle):
    160178        if angle == 0:
    class Touch:  
    230248        x,y = self.x, self.y
    231249        last_touch_locations[id] = (x,y)
    232250        return (x-lx, y-ly)
     251
     252touch_action_to_property = {
     253    'pinch':    'two_finger_scale',
     254    'twist':    'two_finger_twist',
     255    'two finger swipe': 'two_finger_trans',
     256    'three finger swipe':   'three_finger_trans',
     257    'four finger swipe':    'four_finger_trans',
     258}
     259
     260
     261class MultitouchBinding:
     262    '''
     263    Associates an action on a multitouch trackpad and a set of modifier keys
     264    ('alt', 'command', 'control', 'shift') with a MouseMode.
     265    '''
     266    valid_actions = list(touch_action_to_property.keys())
     267
     268    def __init__(self, action, modifiers, mode):
     269        if action not in self.valid_actions:
     270            from chimerax.core.errors import UserError
     271            raise UserError('Unrecognised touchpad action! Must be one of: {}'.format(
     272                ', '.join(valid_actions)
     273            ))
     274        self.action = action
     275        self.modifiers = modifiers
     276        self.mode = mode
     277    def matches(self, action, modifiers):
     278        '''
     279        Does this binding match the specified action and modifiers?
     280        A match requires all of the binding modifiers keys are among
     281        the specified modifiers (and possibly more).
     282        '''
     283        return (action==self.action and
     284            len([k for k in self.modifiers if not k in modifiers]) == 0
     285        )
     286    def exact_match(self, action, modifiers):
     287        '''
     288        Does this binding exactly match the specified action and modifiers?
     289        An exact match requires the binding modifiers keys are exactly the
     290        same set as the specified modifier keys.
     291        '''
     292        return action == self.action and set(modifiers) == set(self.modifiers)
     293
     294
     295from .mousemodes import MouseEvent
     296class MultitouchEvent(MouseEvent):
     297    '''
     298    Provides an interface to events fired by multi-touch trackpads and modifier
     299    keys so that mouse modes do not directly depend on details of the window
     300    toolkit or trackpad implementation.
     301    '''
     302    def __init__(self, modifiers = None, position=None, wheel_value = None,
     303            two_finger_trans=None, two_finger_scale=None, two_finger_twist=None,
     304            three_finger_trans=None, four_finger_trans=None):
     305        super().__init__(event=None, modifiers=modifiers, position=position, wheel_value=wheel_value)
     306        self._two_finger_trans = two_finger_trans
     307        self._two_finger_scale = two_finger_scale
     308        self._two_finger_twist = two_finger_twist
     309        self._three_finger_trans = three_finger_trans
     310        self._four_finger_trans = four_finger_trans
     311
     312    @property
     313    def modifiers(self):
     314        return self._modifiers
     315
     316    # @property
     317    # def event(self):
     318    #     '''
     319    #     The core QTouchEvent object
     320    #     '''
     321    #     return self._event
     322
     323    @property
     324    def wheel_value(self):
     325        '''
     326        Supported API.
     327        Effective mouse wheel value if two-finger vertical swipe is to be
     328        interpreted as a scrolling action.
     329        '''
     330        return self._wheel_value
     331
     332    @property
     333    def two_finger_trans(self):
     334        '''
     335        Supported API.
     336        Returns a tuple (delta_x, delta_y) in screen coordinates representing
     337        the movement when a two-finger swipe is interpreted as a translation
     338        action.
     339        '''
     340        return self._two_finger_trans
     341
     342    @property
     343    def two_finger_scale(self):
     344        '''
     345        Supported API
     346        Returns a float representing the change in a two-finger pinching action.
     347        '''
     348        return self._two_finger_scale
     349
     350    @property
     351    def two_finger_twist(self):
     352        '''
     353        Supported API
     354        Returns the rotation in degrees defined by a two-finger twisting action.
     355        '''
     356        return self._two_finger_twist
     357
     358    @property
     359    def three_finger_trans(self):
     360        '''
     361        Supported API
     362        Returns a tuple (delta_x, delta_y) in screen coordinates representing
     363        the translation in a 3-fingered swipe.
     364        '''
     365        return self._three_finger_trans
     366
     367    @property
     368    def four_finger_trans(self):
     369        '''
     370        Supported API
     371        Returns a tuple (delta_x, delta_y) in screen coordinates representing
     372        the translation in a 3-fingered swipe.
     373        '''
     374        return self._four_finger_trans