summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/Interaction.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/Interaction.py')
-rw-r--r--src/silx/gui/plot/Interaction.py352
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