diff options
Diffstat (limited to 'src/silx/gui/plot/Interaction.py')
-rw-r--r-- | src/silx/gui/plot/Interaction.py | 352 |
1 files changed, 352 insertions, 0 deletions
diff --git a/src/silx/gui/plot/Interaction.py b/src/silx/gui/plot/Interaction.py new file mode 100644 index 0000000..2d8bf63 --- /dev/null +++ b/src/silx/gui/plot/Interaction.py @@ -0,0 +1,352 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides an implementation of state machines for interaction. + +Sample code of a state machine with two states ('idle' and 'active') +with transitions on left button press/release: + +.. code-block:: python + + from silx.gui.plot.Interaction import * + + class SampleStateMachine(StateMachine): + + class Idle(State): + def onPress(self, x, y, btn): + if btn == LEFT_BTN: + self.goto('active') + + class Active(State): + def enterState(self): + print('Enabled') # Handle enter active state here + + def leaveState(self): + print('Disabled') # Handle leave active state here + + def onRelease(self, x, y, btn): + if btn == LEFT_BTN: + self.goto('idle') + + def __init__(self): + # State machine has 2 states + states = { + 'idle': SampleStateMachine.Idle, + 'active': SampleStateMachine.Active + } + super(TwoStates, self).__init__(states, 'idle') + # idle is the initial state + + stateMachine = SampleStateMachine() + + # Triggers a transition to the Active state: + stateMachine.handleEvent('press', 0, 0, LEFT_BTN) + + # Triggers a transition to the Idle state: + stateMachine.handleEvent('release', 0, 0, LEFT_BTN) + +See :class:`ClickOrDrag` for another example of a state machine. + +See `Renaud Blanch, Michel Beaudouin-Lafon. +Programming Rich Interactions using the Hierarchical State Machine Toolkit. +In Proceedings of AVI 2006. p 51-58. +<http://iihm.imag.fr/en/publication/BB06a/>`_ +for a discussion of using (hierarchical) state machines for interaction. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "18/02/2016" + + +import weakref + + +# state machine ############################################################### + + +class State(object): + """Base class for the states of a state machine. + + This class is meant to be subclassed. + """ + + def __init__(self, machine): + """State instances should be created by the :class:`StateMachine`. + + They are not intended to be used outside this context. + + :param machine: The state machine instance this state belongs to. + :type machine: StateMachine + """ + self._machineRef = weakref.ref(machine) # Prevent cyclic reference + + @property + def machine(self): + """The state machine this state belongs to. + + Useful to access data or methods that are shared across states. + """ + machine = self._machineRef() + if machine is not None: + return machine + else: + raise RuntimeError("Associated StateMachine is not valid") + + def goto(self, state, *args, **kwargs): + """Performs a transition to a new state. + + Extra arguments are passed to the :meth:`enterState` method of the + new state. + + :param str state: The name of the state to go to. + """ + self.machine._goto(state, *args, **kwargs) + + def enterState(self, *args, **kwargs): + """Called when the state machine enters this state. + + Arguments are those provided to the :meth:`goto` method that + triggered the transition to this state. + """ + pass + + def leaveState(self): + """Called when the state machine leaves this state + (i.e., when :meth:`goto` is called). + """ + pass + + def validate(self): + """Called externally to validate the current interaction in case of a + creation. + """ + pass + + +class StateMachine(object): + """State machine controller. + + This is the entry point of a state machine. + It is in charge of dispatching received event and handling the + current active state. + """ + + def __init__(self, states, initState, *args, **kwargs): + """Create a state machine controller with an initial state. + + Extra arguments are passed to the :meth:`enterState` method + of the initState. + + :param states: All states of the state machine + :type states: dict of: {str name: State subclass} + :param str initState: Key of the initial state in states + """ + self.states = states + + self.state = self.states[initState](self) + self.state.enterState(*args, **kwargs) + + def _goto(self, state, *args, **kwargs): + self.state.leaveState() + self.state = self.states[state](self) + self.state.enterState(*args, **kwargs) + + def handleEvent(self, eventName, *args, **kwargs): + """Process an event with the state machine. + + This method looks up for an event handler in the current state + and then in the :class:`StateMachine` instance. + Handler are looked up as 'onEventName' method. + If a handler is found, it is called with the provided extra + arguments, and this method returns the return value of the + handler. + If no handler is found, this method returns None. + + :param str eventName: Name of the event to handle + :returns: The return value of the handler or None + """ + handlerName = "on" + eventName[0].upper() + eventName[1:] + try: + handler = getattr(self.state, handlerName) + except AttributeError: + try: + handler = getattr(self, handlerName) + except AttributeError: + handler = None + if handler is not None: + return handler(*args, **kwargs) + + def validate(self): + """Called externally to validate the current interaction in case of a + creation. + """ + self.state.validate() + + +# clickOrDrag ################################################################# + +LEFT_BTN = "left" +"""Left mouse button.""" + +RIGHT_BTN = "right" +"""Right mouse button.""" + +MIDDLE_BTN = "middle" +"""Middle mouse button.""" + + +class ClickOrDrag(StateMachine): + """State machine for left and right click and left drag interaction. + + It is intended to be used through subclassing by overriding + :meth:`click`, :meth:`beginDrag`, :meth:`drag` and :meth:`endDrag`. + + :param Set[str] clickButtons: Set of buttons that provides click interaction + :param Set[str] dragButtons: Set of buttons that provides drag interaction + """ + + DRAG_THRESHOLD_SQUARE_DIST = 5**2 + + class Idle(State): + def onPress(self, x, y, btn): + if btn in self.machine.dragButtons: + self.goto("clickOrDrag", x, y, btn) + return True + elif btn in self.machine.clickButtons: + self.goto("click", x, y, btn) + return True + + class Click(State): + def enterState(self, x, y, btn): + self.initPos = x, y + self.button = btn + + def onMove(self, x, y): + dx2 = (x - self.initPos[0]) ** 2 + dy2 = (y - self.initPos[1]) ** 2 + if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST: + self.goto("idle") + + def onRelease(self, x, y, btn): + if btn == self.button: + self.machine.click(x, y, btn) + self.goto("idle") + + class ClickOrDrag(State): + def enterState(self, x, y, btn): + self.initPos = x, y + self.button = btn + + def onMove(self, x, y): + dx2 = (x - self.initPos[0]) ** 2 + dy2 = (y - self.initPos[1]) ** 2 + if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST: + self.goto("drag", self.initPos, (x, y), self.button) + + def onRelease(self, x, y, btn): + if btn == self.button: + if btn in self.machine.clickButtons: + self.machine.click(x, y, btn) + self.goto("idle") + + class Drag(State): + def enterState(self, initPos, curPos, btn): + self.initPos = initPos + self.button = btn + self.machine.beginDrag(*initPos, btn) + self.machine.drag(*curPos, btn) + + def onMove(self, x, y): + self.machine.drag(x, y, self.button) + + def onRelease(self, x, y, btn): + if btn == self.button: + self.machine.endDrag(self.initPos, (x, y), btn) + self.goto("idle") + + def __init__(self, clickButtons=(LEFT_BTN, RIGHT_BTN), dragButtons=(LEFT_BTN,)): + states = { + "idle": self.Idle, + "click": self.Click, + "clickOrDrag": self.ClickOrDrag, + "drag": self.Drag, + } + self.__clickButtons = set(clickButtons) + self.__dragButtons = set(dragButtons) + super(ClickOrDrag, self).__init__(states, "idle") + + clickButtons = property( + lambda self: self.__clickButtons, + doc="Buttons with click interaction (Set[int])", + ) + + dragButtons = property( + lambda self: self.__dragButtons, doc="Buttons with drag interaction (Set[int])" + ) + + def click(self, x, y, btn): + """Called upon a button supporting click. + + Override in subclass. + + :param int x: X mouse position in pixels. + :param int y: Y mouse position in pixels. + :param str btn: The mouse button which was clicked. + """ + pass + + def beginDrag(self, x, y, btn): + """Called at the beginning of a drag gesture with mouse button pressed. + + Override in subclass. + + :param int x: X mouse position in pixels. + :param int y: Y mouse position in pixels. + :param str btn: The mouse button for which a drag is starting. + """ + pass + + def drag(self, x, y, btn): + """Called on mouse moved during a drag gesture. + + Override in subclass. + + :param int x: X mouse position in pixels. + :param int y: Y mouse position in pixels. + :param str btn: The mouse button for which a drag is in progress. + """ + pass + + def endDrag(self, startPoint, endPoint, btn): + """Called at the end of a drag gesture when the mouse button is released. + + Override in subclass. + + :param List[int] startPoint: + (x, y) mouse position in pixels at the beginning of the drag. + :param List[int] endPoint: + (x, y) mouse position in pixels at the end of the drag. + :param str btn: The mouse button for which a drag is done. + """ + pass |