diff options
Diffstat (limited to 'silx/gui/plot/PlotInteraction.py')
-rw-r--r-- | silx/gui/plot/PlotInteraction.py | 396 |
1 files changed, 224 insertions, 172 deletions
diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py index abfcf79..d182a49 100644 --- a/silx/gui/plot/PlotInteraction.py +++ b/silx/gui/plot/PlotInteraction.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2019 European Synchrotron Radiation Facility +# 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 @@ -37,7 +37,7 @@ import weakref from .. import colors from .. import qt from . import items -from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN, +from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN, MIDDLE_BTN, State, StateMachine) from .PlotEvents import (prepareCurveSignal, prepareDrawingSignal, prepareHoverSignal, prepareImageSignal, @@ -102,11 +102,11 @@ class _PlotInteraction(object): else: color2 = "black" - self.plot.addItem(points[:, 0], points[:, 1], legend=legend, - replace=False, - shape=shape, fill=fill, - color=color, linebgcolor=color2, linestyle="--", - overlay=True) + self.plot.addShape(points[:, 0], points[:, 1], legend=legend, + replace=False, + shape=shape, fill=fill, + color=color, linebgcolor=color2, linestyle="--", + overlay=True) self._selectionAreas.add(legend) @@ -127,7 +127,7 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction): _DOUBLE_CLICK_TIMEOUT = 0.4 - class ZoomIdle(ClickOrDrag.Idle): + class Idle(ClickOrDrag.Idle): def onWheel(self, x, y, angle): scaleF = 1.1 if angle > 0 else 1. / 1.1 applyZoomToPlot(self.machine.plot, scaleF, (x, y)) @@ -170,23 +170,16 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction): x, y) self.plot.notify(**eventDict) - def __init__(self, plot): + def __init__(self, plot, **kwargs): """Init. :param plot: The plot to apply modifications to. """ - _PlotInteraction.__init__(self, plot) - - states = { - 'idle': _ZoomOnWheel.ZoomIdle, - 'rightClick': ClickOrDrag.RightClick, - 'clickOrDrag': ClickOrDrag.ClickOrDrag, - 'drag': ClickOrDrag.Drag - } - StateMachine.__init__(self, states, 'idle') - self._lastClick = 0., None + _PlotInteraction.__init__(self, plot) + ClickOrDrag.__init__(self, **kwargs) + # Pan ######################################################################### @@ -198,10 +191,10 @@ class Pan(_ZoomOnWheel): _, y2Data = self.plot.pixelToData(x, y, axis='right') return xData, yData, y2Data - def beginDrag(self, x, y): + def beginDrag(self, x, y, btn): self._previousDataPos = self._pixelToData(x, y) - def drag(self, x, y): + def drag(self, x, y, btn): xData, yData, y2Data = self._pixelToData(x, y) lastX, lastY, lastY2 = self._previousDataPos @@ -266,7 +259,7 @@ class Pan(_ZoomOnWheel): self._previousDataPos = self._pixelToData(x, y) - def endDrag(self, startPos, endPos): + def endDrag(self, startPos, endPos, btn): del self._previousDataPos def cancel(self): @@ -315,12 +308,12 @@ class Zoom(_ZoomOnWheel): return areaX0, areaY0, areaX1, areaY1 - def beginDrag(self, x, y): + def beginDrag(self, x, y, btn): dataPos = self.plot.pixelToData(x, y) assert dataPos is not None self.x0, self.y0 = x, y - def drag(self, x1, y1): + def drag(self, x1, y1, btn): if self.color is None: return # Do not draw zoom area @@ -388,7 +381,7 @@ class Zoom(_ZoomOnWheel): self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) - def endDrag(self, startPos, endPos): + def endDrag(self, startPos, endPos, btn): x0, y0 = startPos x1, y1 = endPos @@ -475,6 +468,24 @@ class SelectPolygon(Select): self.machine.parameters) self.machine.plot.notify(**eventDict) + def validate(self): + if len(self.points) > 2: + self.closePolygon() + else: + # It would be nice to have a cancel event. + # The plot is not aware that the interaction was cancelled + self.machine.cancel() + + def closePolygon(self): + self.machine.resetSelectionArea() + self.points[-1] = self.points[0] + eventDict = prepareDrawingSignal('drawingFinished', + 'polygon', + self.points, + self.machine.parameters) + self.machine.plot.notify(**eventDict) + self.goto('idle') + def onWheel(self, x, y, angle): self.machine.onWheel(x, y, angle) self.updateFirstPoint() @@ -491,16 +502,7 @@ class SelectPolygon(Select): # Only allow to close polygon after first point if len(self.points) > 2 and dx <= threshold and dy <= threshold: - self.machine.resetSelectionArea() - - self.points[-1] = self.points[0] - - eventDict = prepareDrawingSignal('drawingFinished', - 'polygon', - self.points, - self.machine.parameters) - self.machine.plot.notify(**eventDict) - self.goto('idle') + self.closePolygon() return False # Update polygon last point not too close to previous one @@ -1023,13 +1025,13 @@ class SelectFreeLine(ClickOrDrag, _PlotInteraction): if btn == LEFT_BTN: self._processEvent(x, y, isLast=True) - def beginDrag(self, x, y): + def beginDrag(self, x, y, btn): self._processEvent(x, y, isLast=False) - def drag(self, x, y): + def drag(self, x, y, btn): self._processEvent(x, y, isLast=False) - def endDrag(self, startPos, endPos): + def endDrag(self, startPos, endPos, btn): x, y = endPos self._processEvent(x, y, isLast=True) @@ -1079,15 +1081,13 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): applyZoomToPlot(self.machine.plot, scaleF, (x, y)) def onMove(self, x, y): - result = self.machine.plot._pickTopMost( - x, y, lambda item: isinstance(item, items.MarkerBase)) - marker = result.getItem() if result is not None else None + marker = self.machine.plot._getMarkerAt(x, y) if marker is not None: dataPos = self.machine.plot.pixelToData(x, y) assert dataPos is not None eventDict = prepareHoverSignal( - marker.getLegend(), 'marker', + marker.getName(), 'marker', dataPos, (x, y), marker.isDraggable(), marker.isSelectable()) @@ -1109,19 +1109,18 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): elif marker.isSelectable(): self.machine.plot.setGraphCursorShape(CURSOR_POINTING) + else: + self.machine.plot.setGraphCursorShape() return True def __init__(self, plot): - _PlotInteraction.__init__(self, plot) + self._pan = Pan(plot) - states = { - 'idle': ItemsInteraction.Idle, - 'rightClick': ClickOrDrag.RightClick, - 'clickOrDrag': ClickOrDrag.ClickOrDrag, - 'drag': ClickOrDrag.Drag - } - StateMachine.__init__(self, states, 'idle') + _PlotInteraction.__init__(self, plot) + ClickOrDrag.__init__(self, + clickButtons=(LEFT_BTN, RIGHT_BTN), + dragButtons=(LEFT_BTN, MIDDLE_BTN)) def click(self, x, y, btn): """Handle mouse click @@ -1169,7 +1168,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): eventDict = prepareMarkerSignal('markerClicked', 'left', - item.getLegend(), + item.getName(), 'marker', item.isDraggable(), item.isSelectable(), @@ -1186,7 +1185,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): indices = result.getIndices(copy=False) eventDict = prepareCurveSignal('left', - item.getLegend(), + item.getName(), 'curve', xData[indices], yData[indices], @@ -1201,7 +1200,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): indices = result.getIndices(copy=False) row, column = indices[0][0], indices[1][0] eventDict = prepareImageSignal('left', - item.getLegend(), + item.getName(), 'image', column, row, dataPos[0], dataPos[1], @@ -1224,7 +1223,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): eventDict = prepareMarkerSignal(eventType, 'left', - marker.getLegend(), + marker.getName(), 'marker', marker.isDraggable(), marker.isSelectable(), @@ -1242,65 +1241,79 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): self.plot.setGraphCursorShape() self.draggedItemRef = None - def beginDrag(self, x, y): + def beginDrag(self, x, y, btn): """Handle begining of drag interaction :param x: X position of the mouse in pixels :param y: Y position of the mouse in pixels + :param str btn: The mouse button for which a drag is starting. :return: True if drag is catched by an item, False otherwise """ - self._lastPos = self.plot.pixelToData(x, y) - assert self._lastPos is not None + if btn == LEFT_BTN: + self._lastPos = self.plot.pixelToData(x, y) + assert self._lastPos is not None - result = self.plot._pickTopMost(x, y, self.__isDraggableItem) - item = result.getItem() if result is not None else None + result = self.plot._pickTopMost(x, y, self.__isDraggableItem) + item = result.getItem() if result is not None else None - self.draggedItemRef = None if item is None else weakref.ref(item) + self.draggedItemRef = None if item is None else weakref.ref(item) - if item is None: - self.__terminateDrag() - return False + if item is None: + self.__terminateDrag() + return False - if isinstance(item, items.MarkerBase): - self._signalMarkerMovingEvent('markerMoving', item, x, y) + if isinstance(item, items.MarkerBase): + self._signalMarkerMovingEvent('markerMoving', item, x, y) + item._startDrag() - return True + return True + elif btn == MIDDLE_BTN: + self._pan.beginDrag(x, y, btn) + return True - def drag(self, x, y): - dataPos = self.plot.pixelToData(x, y) - assert dataPos is not None + def drag(self, x, y, btn): + if btn == LEFT_BTN: + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None - item = None if self.draggedItemRef is None else self.draggedItemRef() - if item is not None: - item.drag(self._lastPos, dataPos) + item = None if self.draggedItemRef is None else self.draggedItemRef() + if item is not None: + item.drag(self._lastPos, dataPos) - if isinstance(item, items.MarkerBase): - self._signalMarkerMovingEvent('markerMoving', item, x, y) + if isinstance(item, items.MarkerBase): + self._signalMarkerMovingEvent('markerMoving', item, x, y) - self._lastPos = dataPos - - def endDrag(self, startPos, endPos): - item = None if self.draggedItemRef is None else self.draggedItemRef() - if item is not None and isinstance(item, items.MarkerBase): - posData = list(item.getPosition()) - if posData[0] is None: - posData[0] = 1. - if posData[1] is None: - posData[1] = 1. - - eventDict = prepareMarkerSignal( - 'markerMoved', - 'left', - item.getLegend(), - 'marker', - item.isDraggable(), - item.isSelectable(), - posData) - self.plot.notify(**eventDict) + self._lastPos = dataPos + elif btn == MIDDLE_BTN: + self._pan.drag(x, y, btn) - self.__terminateDrag() + def endDrag(self, startPos, endPos, btn): + if btn == LEFT_BTN: + item = None if self.draggedItemRef is None else self.draggedItemRef() + if isinstance(item, items.MarkerBase): + posData = list(item.getPosition()) + if posData[0] is None: + posData[0] = 1. + if posData[1] is None: + posData[1] = 1. + + eventDict = prepareMarkerSignal( + 'markerMoved', + 'left', + item.getLegend(), + 'marker', + item.isDraggable(), + item.isSelectable(), + posData) + self.plot.notify(**eventDict) + item._endDrag() + + self.__terminateDrag() + elif btn == MIDDLE_BTN: + self._pan.endDrag(startPos, endPos, btn) def cancel(self): + self._pan.cancel() self.__terminateDrag() @@ -1319,25 +1332,12 @@ class ItemsInteractionForCombo(ItemsInteraction): result = self.machine.plot._pickTopMost( x, y, self.__isItemSelectableOrDraggable) if result is not None: # Request focus and handle interaction - self.goto('clickOrDrag', x, y) + self.goto('clickOrDrag', x, y, btn) return True else: # Do not request focus return False - - elif btn == RIGHT_BTN: - self.goto('rightClick', x, y) - return True - - def __init__(self, plot): - _PlotInteraction.__init__(self, plot) - - states = { - 'idle': ItemsInteractionForCombo.Idle, - 'rightClick': ClickOrDrag.RightClick, - 'clickOrDrag': ClickOrDrag.ClickOrDrag, - 'drag': ClickOrDrag.Drag - } - StateMachine.__init__(self, states, 'idle') + else: + return super().onPress(x, y, btn) # FocusManager ################################################################ @@ -1350,11 +1350,12 @@ class FocusManager(StateMachine): """ class Idle(State): def onPress(self, x, y, btn): - for eventHandler in self.machine.eventHandlers: - requestFocus = eventHandler.handleEvent('press', x, y, btn) - if requestFocus: - self.goto('focus', eventHandler, btn) - break + if btn == LEFT_BTN: + for eventHandler in self.machine.eventHandlers: + requestFocus = eventHandler.handleEvent('press', x, y, btn) + if requestFocus: + self.goto('focus', eventHandler, btn) + break def _processEvent(self, *args): for eventHandler in self.machine.eventHandlers: @@ -1366,7 +1367,8 @@ class FocusManager(StateMachine): self._processEvent('move', x, y) def onRelease(self, x, y, btn): - self._processEvent('release', x, y, btn) + if btn == LEFT_BTN: + self._processEvent('release', x, y, btn) def onWheel(self, x, y, angle): self._processEvent('wheel', x, y, angle) @@ -1376,18 +1378,24 @@ class FocusManager(StateMachine): self.eventHandler = eventHandler self.focusBtns = {btn} + def validate(self): + self.eventHandler.validate() + self.goto('idle') + def onPress(self, x, y, btn): - self.focusBtns.add(btn) - self.eventHandler.handleEvent('press', x, y, btn) + if btn == LEFT_BTN: + self.focusBtns.add(btn) + self.eventHandler.handleEvent('press', x, y, btn) def onMove(self, x, y): self.eventHandler.handleEvent('move', x, y) def onRelease(self, x, y, btn): - self.focusBtns.discard(btn) - requestFocus = self.eventHandler.handleEvent('release', x, y, btn) - if len(self.focusBtns) == 0 and not requestFocus: - self.goto('idle') + if btn == LEFT_BTN: + self.focusBtns.discard(btn) + requestFocus = self.eventHandler.handleEvent('release', x, y, btn) + if len(self.focusBtns) == 0 and not requestFocus: + self.goto('idle') def onWheel(self, x, y, angleInDegrees): self.eventHandler.handleEvent('wheel', x, y, angleInDegrees) @@ -1447,37 +1455,40 @@ class ZoomAndSelect(ItemsInteraction): else: self._zoom.click(x, y, btn) - def beginDrag(self, x, y): + def beginDrag(self, x, y, btn): """Handle start drag and switching between zoom and item drag. :param x: X position in pixels :param y: Y position in pixels + :param str btn: The mouse button for which a drag is starting. """ - self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y) + self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y, btn) if self._doZoom: - self._zoom.beginDrag(x, y) + self._zoom.beginDrag(x, y, btn) - def drag(self, x, y): + def drag(self, x, y, btn): """Handle drag, eventually forwarding to zoom. :param x: X position in pixels :param y: Y position in pixels + :param str btn: The mouse button for which a drag is in progress. """ if self._doZoom: - return self._zoom.drag(x, y) + return self._zoom.drag(x, y, btn) else: - return super(ZoomAndSelect, self).drag(x, y) + return super(ZoomAndSelect, self).drag(x, y, btn) - def endDrag(self, startPos, endPos): + def endDrag(self, startPos, endPos, btn): """Handle end of drag, eventually forwarding to zoom. :param startPos: (x, y) position at the beginning of the drag :param endPos: (x, y) position at the end of the drag + :param str btn: The mouse button for which a drag is done. """ if self._doZoom: - return self._zoom.endDrag(startPos, endPos) + return self._zoom.endDrag(startPos, endPos, btn) else: - return super(ZoomAndSelect, self).endDrag(startPos, endPos) + return super(ZoomAndSelect, self).endDrag(startPos, endPos, btn) class PanAndSelect(ItemsInteraction): @@ -1515,41 +1526,101 @@ class PanAndSelect(ItemsInteraction): else: self._pan.click(x, y, btn) - def beginDrag(self, x, y): + def beginDrag(self, x, y, btn): """Handle start drag and switching between zoom and item drag. :param x: X position in pixels :param y: Y position in pixels + :param str btn: The mouse button for which a drag is starting. """ - self._doPan = not super(PanAndSelect, self).beginDrag(x, y) + self._doPan = not super(PanAndSelect, self).beginDrag(x, y, btn) if self._doPan: - self._pan.beginDrag(x, y) + self._pan.beginDrag(x, y, btn) - def drag(self, x, y): + def drag(self, x, y, btn): """Handle drag, eventually forwarding to zoom. :param x: X position in pixels :param y: Y position in pixels + :param str btn: The mouse button for which a drag is in progress. """ if self._doPan: - return self._pan.drag(x, y) + return self._pan.drag(x, y, btn) else: - return super(PanAndSelect, self).drag(x, y) + return super(PanAndSelect, self).drag(x, y, btn) - def endDrag(self, startPos, endPos): + def endDrag(self, startPos, endPos, btn): """Handle end of drag, eventually forwarding to zoom. :param startPos: (x, y) position at the beginning of the drag :param endPos: (x, y) position at the end of the drag + :param str btn: The mouse button for which a drag is done. """ if self._doPan: - return self._pan.endDrag(startPos, endPos) + return self._pan.endDrag(startPos, endPos, btn) else: - return super(PanAndSelect, self).endDrag(startPos, endPos) + return super(PanAndSelect, self).endDrag(startPos, endPos, btn) # Interaction mode control #################################################### +# Mapping of draw modes: event handler +_DRAW_MODES = { + 'polygon': SelectPolygon, + 'rectangle': SelectRectangle, + 'ellipse': SelectEllipse, + 'line': SelectLine, + 'vline': SelectVLine, + 'hline': SelectHLine, + 'polylines': SelectFreeLine, + 'pencil': DrawFreeHand, + } + + +class DrawMode(FocusManager): + """Interactive mode for draw and select""" + + def __init__(self, plot, shape, label, color, width): + eventHandlerClass = _DRAW_MODES[shape] + parameters = { + 'shape': shape, + 'label': label, + 'color': color, + 'width': width, + } + super().__init__(( + Pan(plot, clickButtons=(), dragButtons=(MIDDLE_BTN,)), + eventHandlerClass(plot, parameters))) + + def getDescription(self): + """Returns the dict describing this interactive mode""" + params = self.eventHandlers[1].parameters.copy() + params['mode'] = 'draw' + return params + + +class DrawSelectMode(FocusManager): + """Interactive mode for draw and select""" + + def __init__(self, plot, shape, label, color, width): + eventHandlerClass = _DRAW_MODES[shape] + parameters = { + 'shape': shape, + 'label': label, + 'color': color, + 'width': width, + } + super().__init__(( + ItemsInteractionForCombo(plot), + eventHandlerClass(plot, parameters))) + + def getDescription(self): + """Returns the dict describing this interactive mode""" + params = self.eventHandlers[1].parameters.copy() + params['mode'] = 'select-draw' + return params + + class PlotInteraction(object): """Proxy to currently use state machine for interaction. @@ -1582,26 +1653,15 @@ class PlotInteraction(object): """Returns the current interactive mode as a dict. The returned dict contains at least the key 'mode'. - Mode can be: 'draw', 'pan', 'select', 'zoom'. + Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom'. It can also contains extra keys (e.g., 'color') specific to a mode as provided to :meth:`setInteractiveMode`. """ if isinstance(self._eventHandler, ZoomAndSelect): return {'mode': 'zoom', 'color': self._eventHandler.color} - elif isinstance(self._eventHandler, FocusManager): - drawHandler = self._eventHandler.eventHandlers[1] - if not isinstance(drawHandler, Select): - raise RuntimeError('Unknown interactive mode') - - result = drawHandler.parameters.copy() - result['mode'] = 'draw' - return result - - elif isinstance(self._eventHandler, Select): - result = self._eventHandler.parameters.copy() - result['mode'] = 'draw' - return result + elif isinstance(self._eventHandler, (DrawMode, DrawSelectMode)): + return self._eventHandler.getDescription() elif isinstance(self._eventHandler, PanAndSelect): return {'mode': 'pan'} @@ -1609,6 +1669,13 @@ class PlotInteraction(object): else: return {'mode': 'select'} + def validate(self): + """Validate the current interaction if possible + + If was designed to close the polygon interaction. + """ + self._eventHandler.validate() + def setInteractiveMode(self, mode, color='black', shape='polygon', label=None, width=None): """Switch the interactive mode. @@ -1636,24 +1703,9 @@ class PlotInteraction(object): color = colors.rgba(color) if mode in ('draw', 'select-draw'): - assert shape in self._DRAW_MODES - eventHandlerClass = self._DRAW_MODES[shape] - parameters = { - 'shape': shape, - 'label': label, - 'color': color, - 'width': width, - } - eventHandler = eventHandlerClass(plot, parameters) - self._eventHandler.cancel() - - if mode == 'draw': - self._eventHandler = eventHandler - - else: # mode == 'select-draw' - self._eventHandler = FocusManager( - (ItemsInteractionForCombo(plot), eventHandler)) + handlerClass = DrawMode if mode == 'draw' else DrawSelectMode + self._eventHandler = handlerClass(plot, shape, label, color, width) elif mode == 'pan': # Ignores color, shape and label |