1import itertools
2
3import numpy as np
4
5from AnyQt.QtCore import (
6    QRectF, QLineF, QObject, QEvent, Qt, pyqtSignal as Signal
7)
8from AnyQt.QtGui import QTransform, QFontMetrics, QStaticText, QBrush, QPen, \
9    QFont
10from AnyQt.QtWidgets import (
11    QGraphicsLineItem, QGraphicsSceneMouseEvent, QPinchGesture,
12    QGraphicsItemGroup, QWidget)
13
14import pyqtgraph as pg
15import pyqtgraph.functions as fn
16from pyqtgraph.graphicsItems.LegendItem import ItemSample
17from pyqtgraph.graphicsItems.ScatterPlotItem import drawSymbol
18
19from Orange.widgets.utils.plot import SELECT, PANNING, ZOOMING
20
21
22class TextItem(pg.TextItem):
23    if not hasattr(pg.TextItem, "setAnchor"):
24        # Compatibility with pyqtgraph <= 0.9.10; in (as of yet unreleased)
25        # 0.9.11 the TextItem has a `setAnchor`, but not `updateText`
26        def setAnchor(self, anchor):
27            self.anchor = pg.Point(anchor)
28            self.updateText()
29
30    def get_xy(self):
31        point = self.pos()
32        return point.x(), point.y()
33
34
35class AnchorItem(pg.GraphicsObject):
36    def __init__(self, parent=None, line=QLineF(), text="", **kwargs):
37        super().__init__(parent, **kwargs)
38        self._text = text
39        self.setFlag(pg.GraphicsObject.ItemHasNoContents)
40
41        self._spine = QGraphicsLineItem(line, self)
42        angle = line.angle()
43
44        self._arrow = pg.ArrowItem(parent=self, angle=0)
45        self._arrow.setPos(self._spine.line().p2())
46        self._arrow.setRotation(angle)
47        self._arrow.setStyle(headLen=10)
48
49        self._label = TextItem(text=text, color=(10, 10, 10))
50        self._label.setParentItem(self)
51        self._label.setPos(*self.get_xy())
52
53        if parent is not None:
54            self.setParentItem(parent)
55
56    def get_xy(self):
57        point = self._spine.line().p2()
58        return point.x(), point.y()
59
60    def setFont(self, font):
61        self._label.setFont(font)
62
63    def setText(self, text):
64        if text != self._text:
65            self._text = text
66            self._label.setText(text)
67            self._label.setVisible(bool(text))
68
69    def text(self):
70        return self._text
71
72    def setLine(self, *line):
73        line = QLineF(*line)
74        if line != self._spine.line():
75            self._spine.setLine(line)
76            self.__updateLayout()
77
78    def line(self):
79        return self._spine.line()
80
81    def setPen(self, pen):
82        self._spine.setPen(pen)
83
84    def setArrowVisible(self, visible):
85        self._arrow.setVisible(visible)
86
87    def paint(self, painter, option, widget):
88        pass
89
90    def boundingRect(self):
91        return QRectF()
92
93    def viewTransformChanged(self):
94        self.__updateLayout()
95
96    def __updateLayout(self):
97        T = self.sceneTransform()
98        if T is None:
99            T = QTransform()
100
101        # map the axis spine to scene coord. system.
102        viewbox_line = T.map(self._spine.line())
103        angle = viewbox_line.angle()
104        assert not np.isnan(angle)
105        # note in Qt the y axis is inverted (90 degree angle 'points' down)
106        left_quad = 270 < angle <= 360 or -0.0 <= angle < 90
107
108        # position the text label along the viewbox_line
109        label_pos = self._spine.line().pointAt(0.90)
110
111        if left_quad:
112            # Anchor the text under the axis spine
113            anchor = (0.5, -0.1)
114        else:
115            # Anchor the text over the axis spine
116            anchor = (0.5, 1.1)
117
118        self._label.setPos(label_pos)
119        self._label.setAnchor(pg.Point(*anchor))
120        self._label.setRotation(-angle if left_quad else 180 - angle)
121
122        self._arrow.setPos(self._spine.line().p2())
123        self._arrow.setRotation(180 - angle)
124
125
126class HelpEventDelegate(QObject):
127    def __init__(self, delegate, parent=None):
128        super().__init__(parent)
129        self.delegate = delegate
130
131    def eventFilter(self, _, event):
132        if event.type() == QEvent.GraphicsSceneHelp:
133            return self.delegate(event)
134        else:
135            return False
136
137
138class MouseEventDelegate(HelpEventDelegate):
139    def __init__(self, delegate, delegate2, parent=None):
140        self.delegate2 = delegate2
141        super().__init__(delegate, parent=parent)
142
143    def eventFilter(self, obj, event):
144        if isinstance(event, QGraphicsSceneMouseEvent):
145            self.delegate2(event)
146        return super().eventFilter(obj, event)
147
148
149class InteractiveViewBox(pg.ViewBox):
150    def __init__(self, graph, enable_menu=False):
151        self.init_history()
152        pg.ViewBox.__init__(self, enableMenu=enable_menu)
153        self.graph = graph
154        self.setMouseMode(self.PanMode)
155        self.grabGesture(Qt.PinchGesture)
156
157    @staticmethod
158    def _dragtip_pos():
159        return 10, 10
160
161    def setDragTooltip(self, tooltip):
162        scene = self.scene()
163        scene.addItem(tooltip)
164        tooltip.setPos(*self._dragtip_pos())
165        tooltip.hide()
166        scene.drag_tooltip = tooltip
167
168    def updateScaleBox(self, p1, p2):
169        """
170        Overload to use ViewBox.mapToView instead of mapRectFromParent
171        mapRectFromParent (from Qt) uses QTransform.invert() which has
172        floating-point issues and can't invert the matrix with large
173        coefficients. ViewBox.mapToView uses invertQTransform from pyqtgraph.
174
175        This code, except for first three lines, are copied from the overloaded
176        method.
177        """
178        p1 = self.mapToView(p1)
179        p2 = self.mapToView(p2)
180        r = QRectF(p1, p2)
181        self.rbScaleBox.setPos(r.topLeft())
182        tr = QTransform.fromScale(r.width(), r.height())
183        self.rbScaleBox.setTransform(tr)
184        self.rbScaleBox.show()
185
186    def safe_update_scale_box(self, buttonDownPos, currentPos):
187        x, y = currentPos
188        if buttonDownPos[0] == x:
189            x += 1
190        if buttonDownPos[1] == y:
191            y += 1
192        self.updateScaleBox(buttonDownPos, pg.Point(x, y))
193
194    def _updateDragtipShown(self, enabled):
195        scene = self.scene()
196        dragtip = scene.drag_tooltip
197        if enabled != dragtip.isVisible():
198            dragtip.setVisible(enabled)
199
200    # noinspection PyPep8Naming,PyMethodOverriding
201    def mouseDragEvent(self, ev, axis=None):
202        def get_mapped_rect():
203            p1, p2 = ev.buttonDownPos(ev.button()), ev.pos()
204            p1 = self.mapToView(p1)
205            p2 = self.mapToView(p2)
206            return QRectF(p1, p2)
207
208        def select():
209            ev.accept()
210            if ev.button() == Qt.LeftButton:
211                self.safe_update_scale_box(ev.buttonDownPos(), ev.pos())
212                if ev.isFinish():
213                    self._updateDragtipShown(False)
214                    self.graph.unsuspend_jittering()
215                    self.rbScaleBox.hide()
216                    value_rect = get_mapped_rect()
217                    self.graph.select_by_rectangle(value_rect)
218                else:
219                    self._updateDragtipShown(True)
220                    self.graph.suspend_jittering()
221                    self.safe_update_scale_box(ev.buttonDownPos(), ev.pos())
222
223        def zoom():
224            # A fixed version of the code from the inherited mouseDragEvent
225            # Use mapToView instead of mapRectFromParent
226            ev.accept()
227            self.rbScaleBox.hide()
228            ax = get_mapped_rect()
229            self.showAxRect(ax)
230            self.axHistoryPointer += 1
231            self.axHistory = self.axHistory[:self.axHistoryPointer] + [ax]
232
233        if self.graph.state == SELECT and axis is None:
234            select()
235        elif self.graph.state == ZOOMING or self.graph.state == PANNING:
236            # Inherited mouseDragEvent doesn't work for large zooms because it
237            # uses mapRectFromParent. We don't want to copy the parts of the
238            # method that work, hence we only use our code under the following
239            # conditions.
240            if ev.button() & (Qt.LeftButton | Qt.MidButton) \
241                    and self.state['mouseMode'] == pg.ViewBox.RectMode \
242                    and ev.isFinish():
243                zoom()
244            else:
245                super().mouseDragEvent(ev, axis=axis)
246        else:
247            ev.ignore()
248
249    def updateAutoRange(self):
250        # indirectly called by the autorange button on the graph
251        super().updateAutoRange()
252        self.tag_history()
253
254    def tag_history(self):
255        #add current view to history if it differs from the last view
256        if self.axHistory:
257            currentview = self.viewRect()
258            lastview = self.axHistory[self.axHistoryPointer]
259            inters = currentview & lastview
260            united = currentview.united(lastview)
261            if inters.width()*inters.height()/(united.width()*united.height()) > 0.95:
262                return
263        self.axHistoryPointer += 1
264        self.axHistory = self.axHistory[:self.axHistoryPointer] + \
265                         [self.viewRect()]
266
267    def init_history(self):
268        self.axHistory = []
269        self.axHistoryPointer = -1
270
271    def autoRange(self, padding=None, items=None, item=None):
272        super().autoRange(padding=padding, items=items, item=item)
273        self.tag_history()
274
275    def suggestPadding(self, _):  # no padding so that undo works correcty
276        return 0.
277
278    def scaleHistory(self, d):
279        self.tag_history()
280        super().scaleHistory(d)
281
282    def mouseClickEvent(self, ev):
283        if ev.button() == Qt.RightButton:  # undo zoom
284            self.scaleHistory(-1)
285        elif ev.modifiers() == Qt.NoModifier:
286            ev.accept()
287            self.graph.unselect_all()
288
289    def sceneEvent(self, event):
290        if event.type() == QEvent.Gesture:
291            return self.gestureEvent(event)
292        return super().sceneEvent(event)
293
294    def gestureEvent(self, event):
295        gesture = event.gesture(Qt.PinchGesture)
296        if gesture.state() == Qt.GestureStarted:
297            event.accept(gesture)
298        elif gesture.changeFlags() & QPinchGesture.ScaleFactorChanged:
299            center = self.mapSceneToView(gesture.centerPoint())
300            scale_prev = gesture.lastScaleFactor()
301            scale = gesture.scaleFactor()
302            if scale_prev != 0:
303                scale = scale / scale_prev
304            if scale > 0:
305                self.scaleBy((1 / scale, 1 / scale), center)
306        elif gesture.state() == Qt.GestureFinished:
307            self.tag_history()
308
309        return True
310
311
312class DraggableItemsViewBox(InteractiveViewBox):
313    """
314    A viewbox with draggable items
315
316    Graph that uses it must provide two methods:
317    - `closest_draggable_item(pos)` returns an int representing the id of the
318      draggable item that is closest (and close enough) to `QPoint` pos, or
319      `None`;
320    - `show_indicator(item_id)` shows or updates an indicator for moving
321      the item with the given `item_id`.
322
323    Viewbox emits three signals:
324    - `started = Signal(item_id)`
325    - `moved = Signal(item_id, x, y)`
326    - `finished = Signal(item_id, x, y)`
327    """
328    started = Signal(int)
329    moved = Signal(int, float, float)
330    finished = Signal(int, float, float)
331
332    def __init__(self, graph, enable_menu=False):
333        self.mouse_state = 0
334        self.item_id = None
335        super().__init__(graph, enable_menu)
336
337    def mousePressEvent(self, ev):
338        super().mousePressEvent(ev)
339        pos = self.childGroup.mapFromParent(ev.pos())
340        if self.graph.closest_draggable_item(pos) is not None:
341            self.setCursor(Qt.ClosedHandCursor)
342
343    def mouseDragEvent(self, ev, axis=None):
344        pos = self.childGroup.mapFromParent(ev.pos())
345        item_id = self.graph.closest_draggable_item(pos)
346        if ev.button() != Qt.LeftButton or (ev.start and item_id is None):
347            self.mouse_state = 2
348        if self.mouse_state == 2:
349            if ev.finish:
350                self.mouse_state = 0
351            super().mouseDragEvent(ev, axis)
352            return
353
354        ev.accept()
355        if ev.start:
356            self.setCursor(Qt.ClosedHandCursor)
357            self.mouse_state = 1
358            self.item_id = item_id
359            self.started.emit(self.item_id)
360
361        if self.mouse_state == 1:
362            if ev.finish:
363                self.mouse_state = 0
364                self.finished.emit(self.item_id, pos.x(), pos.y())
365                if self.graph.closest_draggable_item(pos) is not None:
366                    self.setCursor(Qt.OpenHandCursor)
367                else:
368                    self.setCursor(Qt.ArrowCursor)
369                    self.item_id = None
370            else:
371                self.moved.emit(self.item_id, pos.x(), pos.y())
372            self.graph.show_indicator(self.item_id)
373
374
375def wrap_legend_items(items, max_width, hspacing, vspacing):
376    def line_width(line):
377        return sum(item.boundingRect().width() for item in line) \
378            + hspacing * (len(line) - 1)
379
380    def create_line(line, yi, fixed_width=None):
381        x = 0
382        for item in line:
383            item.setPos(x, yi * vspacing)
384            paragraph.addToGroup(item)
385            if fixed_width:
386                x += fixed_width
387            else:
388                x += item.boundingRect().width() + hspacing
389
390    max_item = max(item.boundingRect().width() + hspacing for item in items)
391    in_line = int(max_width // max_item)
392    if line_width(items) < max_width:  # single line
393        lines = [items]
394        fixed_width = None
395    elif in_line < 2:
396        lines = [[]]
397        for i, item in enumerate(items):  # just a single column - free wrap
398            lines[-1].append(item)
399            if line_width(lines[-1]) > max_width and len(lines[-1]) > 1:
400                lines.append([lines[-1].pop()])
401        fixed_width = None
402    else:  # arrange into grid
403        lines = [items[i:i + in_line]
404                 for i in range(0, len(items) + in_line - 1, in_line)]
405        fixed_width = max_item
406
407    paragraph = QGraphicsItemGroup()
408    for yi, line in enumerate(lines):
409        create_line(line, yi, fixed_width=fixed_width)
410    return paragraph
411
412
413class ElidedLabelsAxis(pg.AxisItem):
414    """
415    Horizontal axis that elides long text labels
416
417    The class assumes that ticks with labels are distributed equally, and that
418    standard `QWidget.font()` is used for printing them.
419    """
420    def generateDrawSpecs(self, p):
421        axis_spec, tick_specs, text_specs = super().generateDrawSpecs(p)
422        bounds = self.mapRectFromParent(self.geometry())
423        max_width = 0.9 * bounds.width() / (len(text_specs) or 1)
424        elide = QFontMetrics(QWidget().font()).elidedText
425        text_specs = [(rect, flags, elide(text, Qt.ElideRight, max_width))
426                      for rect, flags, text in text_specs]
427        return axis_spec, tick_specs, text_specs
428
429
430class PaletteItemSample(ItemSample):
431    """A color strip to insert into legends for discretized continuous values"""
432
433    def __init__(self, palette, scale, label_formatter=None):
434        """
435        :param palette: palette used for showing continuous values
436        :type palette: BinnedContinuousPalette
437        :param scale: an instance of DiscretizedScale that defines the
438                      conversion of values into bins
439        :type scale: DiscretizedScale
440        """
441        super().__init__(None)
442        self.palette = palette
443        self.scale = scale
444        if label_formatter is None:
445            label_formatter = "{{:.{}f}}".format(scale.decimals).format
446        cuts = [label_formatter(scale.offset + i * scale.width)
447                for i in range(scale.bins + 1)]
448        self.labels = [QStaticText("{} - {}".format(fr, to))
449                       for fr, to in zip(cuts, cuts[1:])]
450        self.font = self.font()
451        self.font.setPointSize(11)
452
453    @property
454    def bin_height(self):
455        return self.font.pointSize() + 4
456
457    @property
458    def text_width(self):
459        for label in self.labels:
460            label.prepare(font=self.font)
461        return max(label.size().width() for label in self.labels)
462
463    def set_font(self, font: QFont):
464        self.font = font
465        self.update()
466
467    def boundingRect(self):
468        return QRectF(0, 0,
469                      25 + self.text_width + self.bin_height,
470                      20 + self.scale.bins * self.bin_height)
471
472    def paint(self, p, *args):
473        p.setRenderHint(p.Antialiasing)
474        p.translate(5, 5)
475        p.setFont(self.font)
476        colors = self.palette.qcolors
477        h = self.bin_height
478        for i, color, label in zip(itertools.count(), colors, self.labels):
479            p.setPen(Qt.NoPen)
480            p.setBrush(QBrush(color))
481            p.drawRect(0, i * h, h, h)
482            p.setPen(QPen(Qt.black))
483            p.drawStaticText(h + 5, i * h + 1, label)
484
485
486class SymbolItemSample(ItemSample):
487    """Adjust position for symbols"""
488    def __init__(self, pen, brush, size, symbol):
489        super().__init__(None)
490        self.__pen = fn.mkPen(pen)
491        self.__brush = fn.mkBrush(brush)
492        self.__size = size
493        self.__symbol = symbol
494
495    def paint(self, p, *args):
496        p.translate(8, 12)
497        drawSymbol(p, self.__symbol, self.__size, self.__pen, self.__brush)
498
499
500class AxisItem(pg.AxisItem):
501    def __init__(self, orientation, rotate_ticks=False, **kwargs):
502        super().__init__(orientation, **kwargs)
503        self.style["rotateTicks"] = rotate_ticks
504
505    def setRotateTicks(self, rotate):
506        self.style["rotateTicks"] = rotate
507        self.picture = None  # pylint: disable=attribute-defined-outside-init
508        self.prepareGeometryChange()
509        self.update()
510
511    def drawPicture(self, p, axisSpec, tickSpecs, textSpecs):
512        if self.orientation in ["bottom", "top"] and self.style["rotateTicks"]:
513            p.setRenderHint(p.Antialiasing, False)
514            p.setRenderHint(p.TextAntialiasing, True)
515
516            # draw long line along axis
517            pen, p1, p2 = axisSpec
518            p.setPen(pen)
519            p.drawLine(p1, p2)
520            p.translate(0.5, 0)  # resolves some damn pixel ambiguity
521
522            # draw ticks
523            for pen, p1, p2 in tickSpecs:
524                p.setPen(pen)
525                p.drawLine(p1, p2)
526
527            # draw all text
528            if self.style['tickFont'] is not None:
529                p.setFont(self.style['tickFont'])
530            p.setPen(self.pen())
531
532            offset = self.style["tickTextOffset"][0]
533            max_text_size = 0
534            for rect, flags, text in textSpecs:
535                p.save()
536                p.translate(rect.x() + rect.width() / 2
537                            - rect.y() - rect.height() / 2,
538                            rect.x() + rect.width() + offset)
539                p.rotate(-90)
540                p.drawText(rect, flags, text)
541                p.restore()
542                max_text_size = max(max_text_size, rect.width())
543            self._updateMaxTextSize(max_text_size + offset)
544        else:
545            super().drawPicture(p, axisSpec, tickSpecs, textSpecs)
546