GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.

Orange.canvas.document.NewLinkAction   F
last analyzed

Complexity

Total Complexity 84

Size/Duplication

Total Lines 443
Duplicated Lines 0 %
Metric Value
dl 0
loc 443
rs 1.5789
wmc 84

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __init__() 0 18 1
A remove_tmp_anchor() 0 9 2
A create_tmp_anchor() 0 9 3
A cancel() 0 3 1
F mouseReleaseEvent() 0 44 10
F edit_links() 0 37 9
F connect_nodes() 0 103 20
A target_node_item_at() 0 18 3
A filter() 0 6 2
F create_new() 0 43 10
A can_connect() 0 13 2
A is_compatible() 0 4 3
A end() 0 7 1
B mousePressEvent() 0 32 4
D mouseMoveEvent() 0 53 10
B cleanup() 0 20 6
A set_link_target_anchor() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like Orange.canvas.document.NewLinkAction often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
"""
2
=========================
3
User Interaction Handlers
4
=========================
5
6
User interaction handlers for a :class:`~.SchemeEditWidget`.
7
8
User interactions encapsulate the logic of user interactions with the
9
scheme document.
10
11
All interactions are subclasses of :class:`UserInteraction`.
12
13
14
"""
15
16
import logging
17
18
from PyQt4.QtGui import (
0 ignored issues
show
Configuration introduced by
The import PyQt4.QtGui could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
19
    QApplication, QGraphicsRectItem, QPen, QBrush, QColor, QFontMetrics,
20
    QUndoCommand
21
)
22
23
from PyQt4.QtCore import (
0 ignored issues
show
Configuration introduced by
The import PyQt4.QtCore could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
24
    Qt, QObject, QCoreApplication, QSizeF, QPointF, QRect, QRectF, QLineF
25
)
26
27
from PyQt4.QtCore import pyqtSignal as Signal
0 ignored issues
show
Configuration introduced by
The import PyQt4.QtCore could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
28
29
from ..registry.description import WidgetDescription
30
from ..registry.qt import QtWidgetRegistry
31
from .. import scheme
32
from ..canvas import items
33
from ..canvas.items import controlpoints
34
from ..gui.quickhelp import QuickHelpTipEvent
35
from . import commands
36
from .editlinksdialog import EditLinksDialog
37
from functools import reduce
38
39
log = logging.getLogger(__name__)
40
41
42
class UserInteraction(QObject):
43
    """
44
    Base class for user interaction handlers.
45
46
    Parameters
47
    ----------
48
    document : :class:`~.SchemeEditWidget`
49
        An scheme editor instance with which the user is interacting.
50
    parent : :class:`QObject`, optional
51
        A parent QObject
52
    deleteOnEnd : bool, optional
53
        Should the UserInteraction be deleted when it finishes (``True``
54
        by default).
55
56
    """
57
    # Cancel reason flags
58
59
    #: No specified reason
60
    NoReason = 0
61
    #: User canceled the operation (e.g. pressing ESC)
62
    UserCancelReason = 1
63
    #: Another interaction was set
64
    InteractionOverrideReason = 3
65
    #: An internal error occurred
66
    ErrorReason = 4
67
    #: Other (unspecified) reason
68
    OtherReason = 5
69
70
    #: Emitted when the interaction is set on the scene.
71
    started = Signal()
72
73
    #: Emitted when the interaction finishes successfully.
74
    finished = Signal()
75
76
    #: Emitted when the interaction ends (canceled or finished)
77
    ended = Signal()
78
79
    #: Emitted when the interaction is canceled.
80
    canceled = Signal([], [int])
81
82
    def __init__(self, document, parent=None, deleteOnEnd=True):
83
        QObject.__init__(self, parent)
84
        self.document = document
85
        self.scene = document.scene()
86
        self.scheme = document.scheme()
87
        self.deleteOnEnd = deleteOnEnd
88
89
        self.cancelOnEsc = False
90
91
        self.__finished = False
92
        self.__canceled = False
93
        self.__cancelReason = self.NoReason
94
95
    def start(self):
96
        """
97
        Start the interaction. This is called by the :class:`CanvasScene` when
98
        the interaction is installed.
99
100
        .. note:: Must be called from subclass implementations.
101
102
        """
103
        self.started.emit()
104
105
    def end(self):
106
        """
107
        Finish the interaction. Restore any leftover state in this method.
108
109
        .. note:: This gets called from the default :func:`cancel`
110
                  implementation.
111
112
        """
113
        self.__finished = True
114
115
        if self.scene.user_interaction_handler is self:
116
            self.scene.set_user_interaction_handler(None)
117
118
        if self.__canceled:
119
            self.canceled.emit()
120
            self.canceled[int].emit(self.__cancelReason)
121
        else:
122
            self.finished.emit()
123
124
        self.ended.emit()
125
126
        if self.deleteOnEnd:
127
            self.deleteLater()
128
129
    def cancel(self, reason=OtherReason):
130
        """
131
        Cancel the interaction with `reason`.
132
        """
133
134
        self.__canceled = True
135
        self.__cancelReason = reason
136
137
        self.end()
138
139
    def isFinished(self):
140
        """
141
        Is the interaction finished.
142
        """
143
        return self.__finished
144
145
    def isCanceled(self):
146
        """
147
        Was the interaction canceled.
148
        """
149
        return self.__canceled
150
151
    def cancelReason(self):
152
        """
153
        Return the reason the interaction was canceled.
154
        """
155
        return self.__cancelReason
156
157
    def mousePressEvent(self, event):
0 ignored issues
show
Unused Code introduced by
The argument event seems to be unused.
Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
158
        """
159
        Handle a `QGraphicsScene.mousePressEvent`.
160
        """
161
        return False
162
163
    def mouseMoveEvent(self, event):
0 ignored issues
show
Unused Code introduced by
The argument event seems to be unused.
Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
164
        """
165
        Handle a `GraphicsScene.mouseMoveEvent`.
166
        """
167
        return False
168
169
    def mouseReleaseEvent(self, event):
0 ignored issues
show
Unused Code introduced by
The argument event seems to be unused.
Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
170
        """
171
        Handle a `QGraphicsScene.mouseReleaseEvent`.
172
        """
173
        return False
174
175
    def mouseDoubleClickEvent(self, event):
0 ignored issues
show
Unused Code introduced by
The argument event seems to be unused.
Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
176
        """
177
        Handle a `QGraphicsScene.mouseDoubleClickEvent`.
178
        """
179
        return False
180
181
    def keyPressEvent(self, event):
182
        """
183
        Handle a `QGraphicsScene.keyPressEvent`
184
        """
185
        if self.cancelOnEsc and event.key() == Qt.Key_Escape:
186
            self.cancel(self.UserCancelReason)
187
        return False
188
189
    def keyReleaseEvent(self, event):
0 ignored issues
show
Unused Code introduced by
The argument event seems to be unused.
Loading history...
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
190
        """
191
        Handle a `QGraphicsScene.keyPressEvent`
192
        """
193
        return False
194
195
196
class NoPossibleLinksError(ValueError):
197
    pass
198
199
200
class UserCanceledError(ValueError):
201
    pass
202
203
204
def reversed_arguments(func):
205
    """
206
    Return a function with reversed argument order.
207
    """
208
    def wrapped(*args):
209
        return func(*reversed(args))
210
    return wrapped
211
212
213
class NewLinkAction(UserInteraction):
214
    """
215
    User drags a new link from an existing `NodeAnchorItem` to create
216
    a connection between two existing nodes or to a new node if the release
217
    is over an empty area, in which case a quick menu for new node selection
218
    is presented to the user.
219
220
    """
221
    # direction of the drag
222
    FROM_SOURCE = 1
223
    FROM_SINK = 2
224
225
    def __init__(self, document, *args, **kwargs):
226
        UserInteraction.__init__(self, document, *args, **kwargs)
227
        self.source_item = None
228
        self.sink_item = None
229
        self.from_item = None
230
        self.direction = None
231
232
        # An `NodeItem` currently under the mouse as a possible
233
        # link drop target.
234
        self.current_target_item = None
235
        # A temporary `LinkItem` used while dragging.
236
        self.tmp_link_item = None
237
        # An temporary `AnchorPoint` inserted into `current_target_item`
238
        self.tmp_anchor_point = None
239
        # An `AnchorPoint` following the mouse cursor
240
        self.cursor_anchor_point = None
241
        # An QUndoCommand
242
        self.macro = None
243
244
    def remove_tmp_anchor(self):
245
        """
246
        Remove a temporary anchor point from the current target item.
247
        """
248
        if self.direction == self.FROM_SOURCE:
249
            self.current_target_item.removeInputAnchor(self.tmp_anchor_point)
250
        else:
251
            self.current_target_item.removeOutputAnchor(self.tmp_anchor_point)
252
        self.tmp_anchor_point = None
253
254
    def create_tmp_anchor(self, item):
255
        """
256
        Create a new tmp anchor at the `item` (:class:`NodeItem`).
257
        """
258
        assert(self.tmp_anchor_point is None)
0 ignored issues
show
Unused Code Coding Style introduced by
There is an unnecessary parenthesis after assert.
Loading history...
259
        if self.direction == self.FROM_SOURCE:
260
            self.tmp_anchor_point = item.newInputAnchor()
261
        else:
262
            self.tmp_anchor_point = item.newOutputAnchor()
263
264
    def can_connect(self, target_item):
265
        """
266
        Is the connection between `self.from_item` (item where the drag
267
        started) and `target_item` possible.
268
269
        """
270
        node1 = self.scene.node_for_item(self.from_item)
271
        node2 = self.scene.node_for_item(target_item)
272
273
        if self.direction == self.FROM_SOURCE:
274
            return bool(self.scheme.propose_links(node1, node2))
275
        else:
276
            return bool(self.scheme.propose_links(node2, node1))
277
278
    def set_link_target_anchor(self, anchor):
279
        """
280
        Set the temp line target anchor.
281
        """
282
        if self.direction == self.FROM_SOURCE:
283
            self.tmp_link_item.setSinkItem(None, anchor)
284
        else:
285
            self.tmp_link_item.setSourceItem(None, anchor)
286
287
    def target_node_item_at(self, pos):
288
        """
289
        Return a suitable :class:`NodeItem` at position `pos` on which
290
        a link can be dropped.
291
292
        """
293
        # Test for a suitable `NodeAnchorItem` or `NodeItem` at pos.
294
        if self.direction == self.FROM_SOURCE:
295
            anchor_type = items.SinkAnchorItem
296
        else:
297
            anchor_type = items.SourceAnchorItem
298
299
        item = self.scene.item_at(pos, (anchor_type, items.NodeItem))
300
301
        if isinstance(item, anchor_type):
302
            item = item.parentNodeItem()
303
304
        return item
305
306
    def mousePressEvent(self, event):
307
        anchor_item = self.scene.item_at(event.scenePos(),
308
                                         items.NodeAnchorItem,
309
                                         buttons=Qt.LeftButton)
310
        if anchor_item and event.button() == Qt.LeftButton:
311
            # Start a new link starting at item
312
            self.from_item = anchor_item.parentNodeItem()
313
            if isinstance(anchor_item, items.SourceAnchorItem):
314
                self.direction = NewLinkAction.FROM_SOURCE
315
                self.source_item = self.from_item
316
            else:
317
                self.direction = NewLinkAction.FROM_SINK
318
                self.sink_item = self.from_item
319
320
            event.accept()
321
322
            helpevent = QuickHelpTipEvent(
323
                self.tr("Create a new link"),
324
                self.tr('<h3>Create new link</h3>'
325
                        '<p>Drag a link to an existing node or release on '
326
                        'an empty spot to create a new node.</p>'
327
#                        '<a href="help://orange-canvas/create-new-links">'
328
#                        'More ...</a>'
329
                        )
330
            )
331
            QCoreApplication.postEvent(self.document, helpevent)
332
333
            return True
334
        else:
335
            # Whoever put us in charge did not know what he was doing.
336
            self.cancel(self.ErrorReason)
337
            return False
338
339
    def mouseMoveEvent(self, event):
340
        if not self.tmp_link_item:
341
            # On first mouse move event create the temp link item and
342
            # initialize it to follow the `cursor_anchor_point`.
343
            self.tmp_link_item = items.LinkItem()
344
            # An anchor under the cursor for the duration of this action.
345
            self.cursor_anchor_point = items.AnchorPoint()
346
            self.cursor_anchor_point.setPos(event.scenePos())
347
348
            # Set the `fixed` end of the temp link (where the drag started).
349
            if self.direction == self.FROM_SOURCE:
350
                self.tmp_link_item.setSourceItem(self.source_item)
351
            else:
352
                self.tmp_link_item.setSinkItem(self.sink_item)
353
354
            self.set_link_target_anchor(self.cursor_anchor_point)
355
            self.scene.addItem(self.tmp_link_item)
356
357
        # `NodeItem` at the cursor position
358
        item = self.target_node_item_at(event.scenePos())
359
360
        if self.current_target_item is not None and \
361
                (item is None or item is not self.current_target_item):
362
            # `current_target_item` is no longer under the mouse cursor
363
            # (was replaced by another item or the the cursor was moved over
364
            # an empty scene spot.
365
            log.info("%r is no longer the target.", self.current_target_item)
366
            self.remove_tmp_anchor()
367
            self.current_target_item = None
368
369
        if item is not None and item is not self.from_item:
370
            # The mouse is over an node item (different from the starting node)
371
            if self.current_target_item is item:
372
                # Avoid reseting the points
373
                pass
374
            elif self.can_connect(item):
375
                # Grab a new anchor
376
                log.info("%r is the new target.", item)
377
                self.create_tmp_anchor(item)
378
                self.set_link_target_anchor(self.tmp_anchor_point)
379
                self.current_target_item = item
380
            else:
381
                log.info("%r does not have compatible channels", item)
382
                self.set_link_target_anchor(self.cursor_anchor_point)
383
                # TODO: How to indicate that the connection is not possible?
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
384
                #       The node's anchor could be drawn with a 'disabled'
385
                #       palette
386
        else:
387
            self.set_link_target_anchor(self.cursor_anchor_point)
388
389
        self.cursor_anchor_point.setPos(event.scenePos())
390
391
        return True
392
393
    def mouseReleaseEvent(self, event):
394
        if self.tmp_link_item:
395
            item = self.target_node_item_at(event.scenePos())
396
            node = None
397
            stack = self.document.undoStack()
398
399
            self.macro = QUndoCommand(self.tr("Add link"))
400
401
            if item:
402
                # If the release was over a node item then connect them
403
                node = self.scene.node_for_item(item)
404
            else:
405
                # Release on an empty canvas part
406
                # Show a quick menu popup for a new widget creation.
407
                try:
408
                    node = self.create_new(event)
409
                except Exception:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
410
                    log.error("Failed to create a new node, ending.",
411
                              exc_info=True)
412
                    node = None
413
414
                if node is not None:
415
                    commands.AddNodeCommand(self.scheme, node,
416
                                            parent=self.macro)
417
418
            if node is not None:
419
                if self.direction == self.FROM_SOURCE:
420
                    source_node = self.scene.node_for_item(self.source_item)
421
                    sink_node = node
422
                else:
423
                    source_node = node
424
                    sink_node = self.scene.node_for_item(self.sink_item)
425
                self.connect_nodes(source_node, sink_node)
426
427
                if not self.isCanceled() or not self.isFinished() and \
428
                        self.macro is not None:
429
                    # Push (commit) the add link/node action on the stack.
430
                    stack.push(self.macro)
431
432
            self.end()
433
434
        else:
435
            self.end()
436
            return False
437
438
    def create_new(self, event):
439
        """
440
        Create and return a new node with a `QuickMenu`.
441
        """
442
        pos = event.screenPos()
443
        menu = self.document.quickMenu()
444
        node = self.scene.node_for_item(self.from_item)
445
        from_desc = node.description
446
447
        def is_compatible(source, sink):
448
            return any(scheme.compatible_channels(output, input) \
449
                       for output in source.outputs \
450
                       for input in sink.inputs)
451
452
        if self.direction == self.FROM_SINK:
453
            # Reverse the argument order.
454
            is_compatible = reversed_arguments(is_compatible)
455
456
        def filter(index):
0 ignored issues
show
Bug Best Practice introduced by
This seems to re-define the built-in filter.

It is generally discouraged to redefine built-ins as this makes code very hard to read.

Loading history...
457
            desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
458
            if isinstance(desc, WidgetDescription):
459
                return is_compatible(from_desc, desc)
460
            else:
461
                return False
462
463
        menu.setFilterFunc(filter)
464
        try:
465
            action = menu.exec_(pos)
466
        finally:
467
            menu.setFilterFunc(None)
468
469
        if action:
470
            item = action.property("item")
471
            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
472
            pos = event.scenePos()
473
            # a new widget should be placed so that the connection
474
            # stays as it was
475
            offset = 31 * (-1 if self.direction == self.FROM_SINK else
476
                           1 if self.direction == self.FROM_SOURCE else 0)
477
            node = self.document.newNodeHelper(desc,
478
                                               position=(pos.x() + offset,
479
                                                         pos.y()))
480
            return node
481
482
    def connect_nodes(self, source_node, sink_node):
483
        """
484
        Connect `source_node` to `sink_node`. If there are more then one
485
        equally weighted and non conflicting links possible present a
486
        detailed dialog for link editing.
487
488
        """
489
        try:
490
            possible = self.scheme.propose_links(source_node, sink_node)
491
492
            log.debug("proposed (weighted) links: %r",
493
                      [(s1.name, s2.name, w) for s1, s2, w in possible])
494
495
            if not possible:
496
                raise NoPossibleLinksError
497
498
            source, sink, w = possible[0]
499
500
            # just a list of signal tuples for now, will be converted
501
            # to SchemeLinks later
502
            links_to_add = [(source, sink)]
503
            links_to_remove = []
504
            show_link_dialog = False
505
506
            # Ambiguous new link request.
507
            if len(possible) >= 2:
508
                # Check for possible ties in the proposed link weights
509
                _, _, w2 = possible[1]
510
                if w == w2:
511
                    show_link_dialog = True
512
513
                # Check for destructive action (i.e. would the new link
514
                # replace a previous link)
515
                if sink.single and self.scheme.find_links(sink_node=sink_node,
516
                                                          sink_channel=sink):
517
                    show_link_dialog = True
518
519
            if show_link_dialog:
520
                existing = self.scheme.find_links(source_node=source_node,
521
                                                  sink_node=sink_node)
522
523
                if existing:
524
                    # edit_links will populate the view with existing links
525
                    initial_links = None
526
                else:
527
                    initial_links = [(source, sink)]
528
529
                try:
530
                    rstatus, links_to_add, links_to_remove = self.edit_links(
531
                        source_node, sink_node, initial_links
532
                    )
533
                except Exception:
534
                    log.error("Failed to edit the links",
535
                              exc_info=True)
536
                    raise
537
                if rstatus == EditLinksDialog.Rejected:
538
                    raise UserCanceledError
539
            else:
540
                # links_to_add now needs to be a list of actual SchemeLinks
541
                links_to_add = [scheme.SchemeLink(
542
                                    source_node, source_channel,
543
                                    sink_node, sink_channel)
544
                                for source_channel, sink_channel
545
                                in links_to_add]
546
547
                links_to_add, links_to_remove = \
548
                    add_links_plan(self.scheme, links_to_add)
549
550
            # Remove temp items before creating any new links
551
            self.cleanup()
552
553
            for link in links_to_remove:
554
                commands.RemoveLinkCommand(self.scheme, link,
555
                                           parent=self.macro)
556
557
            for link in links_to_add:
558
                # Check if the new requested link is a duplicate of an
559
                # existing link
560
                duplicate = self.scheme.find_links(
561
                    link.source_node, link.source_channel,
562
                    link.sink_node, link.sink_channel
563
                )
564
565
                if not duplicate:
566
                    commands.AddLinkCommand(self.scheme, link,
567
                                            parent=self.macro)
568
569
        except scheme.IncompatibleChannelTypeError:
570
            log.info("Cannot connect: invalid channel types.")
571
            self.cancel()
572
        except scheme.SchemeTopologyError:
573
            log.info("Cannot connect: connection creates a cycle.")
574
            self.cancel()
575
        except NoPossibleLinksError:
576
            log.info("Cannot connect: no possible links.")
577
            self.cancel()
578
        except UserCanceledError:
579
            log.info("User canceled a new link action.")
580
            self.cancel(UserInteraction.UserCancelReason)
581
        except Exception:
0 ignored issues
show
Best Practice introduced by
Catching very general exceptions such as Exception is usually not recommended.

Generally, you would want to handle very specific errors in the exception handler. This ensure that you do not hide other types of errors which should be fixed.

So, unless you specifically plan to handle any error, consider adding a more specific exception.

Loading history...
582
            log.error("An error occurred during the creation of a new link.",
583
                      exc_info=True)
584
            self.cancel()
585
586
    def edit_links(self, source_node, sink_node, initial_links=None):
587
        """
588
        Show and execute the `EditLinksDialog`.
589
        Optional `initial_links` list can provide a list of initial
590
        `(source, sink)` channel tuples to show in the view, otherwise
591
        the dialog is populated with existing links in the scheme (passing
592
        an empty list will disable all initial links).
593
594
        """
595
        status, links_to_add, links_to_remove = \
596
            edit_links(
597
                self.scheme, source_node, sink_node, initial_links,
598
                parent=self.document
599
            )
600
601
        if status == EditLinksDialog.Accepted:
602
            links_to_add = [scheme.SchemeLink(
603
                                source_node, source_channel,
604
                                sink_node, sink_channel)
605
                            for source_channel, sink_channel in links_to_add]
606
607
            links_to_remove = [self.scheme.find_links(
608
                                   source_node, source_channel,
609
                                   sink_node, sink_channel)
610
                               for source_channel, sink_channel
611
                               in links_to_remove]
612
613
            links_to_remove = reduce(list.__add__, links_to_remove, [])
614
            conflicting = [_f for _f in [conflicting_single_link(self.scheme, link)
615
                                  for link in links_to_add] if _f]
616
            for link in conflicting:
617
                if link not in links_to_remove:
618
                    links_to_remove.append(link)
619
620
            return status, links_to_add, links_to_remove
621
        else:
622
            return status, [], []
623
624
    def end(self):
625
        self.cleanup()
626
        # Remove the help tip set in mousePressEvent
627
        self.macro = None
628
        helpevent = QuickHelpTipEvent("", "")
629
        QCoreApplication.postEvent(self.document, helpevent)
630
        UserInteraction.end(self)
631
632
    def cancel(self, reason=UserInteraction.OtherReason):
633
        self.cleanup()
634
        UserInteraction.cancel(self, reason)
635
636
    def cleanup(self):
637
        """
638
        Cleanup all temporary items in the scene that are left.
639
        """
640
        if self.tmp_link_item:
641
            self.tmp_link_item.setSinkItem(None)
642
            self.tmp_link_item.setSourceItem(None)
643
644
            if self.tmp_link_item.scene():
645
                self.scene.removeItem(self.tmp_link_item)
646
647
            self.tmp_link_item = None
648
649
        if self.current_target_item:
650
            self.remove_tmp_anchor()
651
            self.current_target_item = None
652
653
        if self.cursor_anchor_point and self.cursor_anchor_point.scene():
654
            self.scene.removeItem(self.cursor_anchor_point)
655
            self.cursor_anchor_point = None
656
657
658
def edit_links(scheme, source_node, sink_node, initial_links=None,
0 ignored issues
show
Comprehensibility Bug introduced by
scheme is re-defining a name which is already available in the outer-scope (previously defined on line 31).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
659
               parent=None):
660
    """
661
    Show and execute the `EditLinksDialog`.
662
    Optional `initial_links` list can provide a list of initial
663
    `(source, sink)` channel tuples to show in the view, otherwise
664
    the dialog is populated with existing links in the scheme (passing
665
    an empty list will disable all initial links).
666
667
    """
668
    log.info("Constructing a Link Editor dialog.")
669
670
    dlg = EditLinksDialog(parent, windowTitle="Edit Links")
671
672
    # all SchemeLinks between the two nodes.
673
    links = scheme.find_links(source_node=source_node, sink_node=sink_node)
674
    existing_links = [(link.source_channel, link.sink_channel)
675
                      for link in links]
676
677
    if initial_links is None:
678
        initial_links = list(existing_links)
679
680
    dlg.setNodes(source_node, sink_node)
681
    dlg.setLinks(initial_links)
682
683
    log.info("Executing a Link Editor Dialog.")
684
    rval = dlg.exec_()
685
686
    if rval == EditLinksDialog.Accepted:
687
        edited_links = dlg.links()
688
689
        # Differences
690
        links_to_add = set(edited_links) - set(existing_links)
691
        links_to_remove = set(existing_links) - set(edited_links)
692
        return rval, list(links_to_add), list(links_to_remove)
693
    else:
694
        return rval, [], []
695
696
697
def add_links_plan(scheme, links, force_replace=False):
0 ignored issues
show
Comprehensibility Bug introduced by
scheme is re-defining a name which is already available in the outer-scope (previously defined on line 31).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
698
    """
699
    Return a plan for adding a list of links to the scheme.
700
    """
701
    links_to_add = list(links)
702
    links_to_remove = [conflicting_single_link(scheme, link)
703
                       for link in links]
704
    links_to_remove = [_f for _f in links_to_remove if _f]
705
706
    if not force_replace:
707
        links_to_add, links_to_remove = remove_duplicates(links_to_add,
708
                                                          links_to_remove)
709
    return links_to_add, links_to_remove
710
711
712
def conflicting_single_link(scheme, link):
0 ignored issues
show
Comprehensibility Bug introduced by
scheme is re-defining a name which is already available in the outer-scope (previously defined on line 31).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
713
    """
714
    Find and return an existing link in `scheme` connected to the same
715
    input channel as `link` if the channel has the 'single' flag.
716
    If no such channel exists (or sink channel is not 'single')
717
    return `None`.
718
719
    """
720
721
    if link.sink_channel.single:
722
        existing = scheme.find_links(
723
            sink_node=link.sink_node,
724
            sink_channel=link.sink_channel
725
        )
726
727
        if existing:
728
            assert len(existing) == 1
729
            return existing[0]
730
    return None
731
732
733
def remove_duplicates(links_to_add, links_to_remove):
734
    def link_key(link):
735
        return (link.source_node, link.source_channel,
736
                link.sink_node, link.sink_channel)
737
738
    add_keys = list(map(link_key, links_to_add))
739
    remove_keys = list(map(link_key, links_to_remove))
740
    duplicate_keys = set(add_keys).intersection(remove_keys)
741
742
    def not_duplicate(link):
743
        return link_key(link) not in duplicate_keys
744
745
    links_to_add = list(filter(not_duplicate, links_to_add))
746
    links_to_remove = list(filter(not_duplicate, links_to_remove))
747
    return links_to_add, links_to_remove
748
749
750
class NewNodeAction(UserInteraction):
751
    """
752
    Present the user with a quick menu for node selection and
753
    create the selected node.
754
755
    """
756
757
    def mousePressEvent(self, event):
758
        if event.button() == Qt.RightButton:
759
            self.create_new(event.screenPos())
760
            self.end()
761
762
    def create_new(self, pos, search_text=""):
763
        """
764
        Create a new widget with a `QuickMenu` at `pos` (in screen
765
        coordinates).
766
767
        """
768
        menu = self.document.quickMenu()
769
        menu.setFilterFunc(None)
770
771
        action = menu.exec_(pos, search_text)
772
        if action:
773
            item = action.property("item")
774
            desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
775
            # Get the scene position
776
            view = self.document.view()
777
            pos = view.mapToScene(view.mapFromGlobal(pos))
778
779
            node = self.document.newNodeHelper(desc,
780
                                               position=(pos.x(), pos.y()))
781
            self.document.addNode(node)
782
            return node
783
784
785
class RectangleSelectionAction(UserInteraction):
786
    """
787
    Select items in the scene using a Rectangle selection
788
    """
789
    def __init__(self, document, *args, **kwargs):
790
        UserInteraction.__init__(self, document, *args, **kwargs)
791
        # The initial selection at drag start
792
        self.initial_selection = None
793
        # Selection when last updated in a mouseMoveEvent
794
        self.last_selection = None
795
        # A selection rect (`QRectF`)
796
        self.selection_rect = None
797
        # Keyboard modifiers
798
        self.modifiers = 0
799
800
    def mousePressEvent(self, event):
801
        pos = event.scenePos()
802
        any_item = self.scene.item_at(pos)
803
        if not any_item and event.button() & Qt.LeftButton:
804
            self.modifiers = event.modifiers()
805
            self.selection_rect = QRectF(pos, QSizeF(0, 0))
806
            self.rect_item = QGraphicsRectItem(
0 ignored issues
show
Coding Style introduced by
The attribute rect_item was defined outside __init__.

It is generally a good practice to initialize all attributes to default values in the __init__ method:

class Foo:
    def __init__(self, x=None):
        self.x = x
Loading history...
807
                self.selection_rect.normalized()
808
            )
809
810
            self.rect_item.setPen(
811
                QPen(QBrush(QColor(51, 153, 255, 192)),
812
                     0.4, Qt.SolidLine, Qt.RoundCap)
813
            )
814
815
            self.rect_item.setBrush(
816
                QBrush(QColor(168, 202, 236, 192))
817
            )
818
819
            self.rect_item.setZValue(-100)
820
821
            # Clear the focus if necessary.
822
            if not self.scene.stickyFocus():
823
                self.scene.clearFocus()
824
825
            if not self.modifiers & Qt.ControlModifier:
826
                self.scene.clearSelection()
827
828
            event.accept()
829
            return True
830
        else:
831
            self.cancel(self.ErrorReason)
832
            return False
833
834
    def mouseMoveEvent(self, event):
835
        if not self.rect_item.scene():
836
            # Add the rect item to the scene when the mouse moves.
837
            self.scene.addItem(self.rect_item)
838
        self.update_selection(event)
839
        return True
840
841
    def mouseReleaseEvent(self, event):
842
        if event.button() == Qt.LeftButton:
843
            if self.initial_selection is None:
844
                # A single click.
845
                self.scene.clearSelection()
846
            else:
847
                self.update_selection(event)
848
        self.end()
849
        return True
850
851
    def update_selection(self, event):
852
        """
853
        Update the selection rectangle from a QGraphicsSceneMouseEvent
854
        `event` instance.
855
856
        """
857
        if self.initial_selection is None:
858
            self.initial_selection = set(self.scene.selectedItems())
859
            self.last_selection = self.initial_selection
860
861
        pos = event.scenePos()
862
        self.selection_rect = QRectF(self.selection_rect.topLeft(), pos)
863
864
        # Make sure the rect_item does not cause the scene rect to grow.
865
        rect = self._bound_selection_rect(self.selection_rect.normalized())
866
867
        # Need that 0.5 constant otherwise the sceneRect will still
868
        # grow (anti-aliasing correction by QGraphicsScene?)
869
        pw = self.rect_item.pen().width() + 0.5
870
871
        self.rect_item.setRect(rect.adjusted(pw, pw, -pw, -pw))
872
873
        selected = self.scene.items(self.selection_rect.normalized(),
874
                                    Qt.IntersectsItemShape,
875
                                    Qt.AscendingOrder)
876
877
        selected = set([item for item in selected if \
878
                        item.flags() & Qt.ItemIsSelectable])
879
880
        if self.modifiers & Qt.ControlModifier:
881
            for item in selected | self.last_selection | \
882
                    self.initial_selection:
883
                item.setSelected(
884
                    (item in selected) ^ (item in self.initial_selection)
885
                )
886
        else:
887
            for item in selected.union(self.last_selection):
888
                item.setSelected(item in selected)
889
890
        self.last_selection = set(self.scene.selectedItems())
891
892
    def end(self):
893
        self.initial_selection = None
894
        self.last_selection = None
895
        self.modifiers = 0
896
897
        self.rect_item.hide()
898
        if self.rect_item.scene() is not None:
899
            self.scene.removeItem(self.rect_item)
900
        UserInteraction.end(self)
901
902
    def viewport_rect(self):
903
        """
904
        Return the bounding rect of the document's viewport on the scene.
905
        """
906
        view = self.document.view()
907
        vsize = view.viewport().size()
908
        viewportrect = QRect(0, 0, vsize.width(), vsize.height())
909
        return view.mapToScene(viewportrect).boundingRect()
910
911
    def _bound_selection_rect(self, rect):
912
        """
913
        Bound the selection `rect` to a sensible size.
914
        """
915
        srect = self.scene.sceneRect()
916
        vrect = self.viewport_rect()
917
        maxrect = srect.united(vrect)
918
        return rect.intersected(maxrect)
919
920
921
class EditNodeLinksAction(UserInteraction):
922
    """
923
    Edit multiple links between two :class:`SchemeNode` instances using
924
    a :class:`EditLinksDialog`
925
926
    Parameters
927
    ----------
928
    document : :class:`SchemeEditWidget`
929
        The editor widget.
930
    source_node : :class:`SchemeNode`
931
        The source (link start) node for the link editor.
932
    sink_node : :class:`SchemeNode`
933
        The sink (link end) node for the link editor.
934
935
    """
936
    def __init__(self, document, source_node, sink_node, *args, **kwargs):
937
        UserInteraction.__init__(self, document, *args, **kwargs)
938
        self.source_node = source_node
939
        self.sink_node = sink_node
940
941
    def edit_links(self, initial_links=None):
942
        """
943
        Show and execute the `EditLinksDialog`.
944
        Optional `initial_links` list can provide a list of initial
945
        `(source, sink)` channel tuples to show in the view, otherwise
946
        the dialog is populated with existing links in the scheme (passing
947
        an empty list will disable all initial links).
948
949
        """
950
        log.info("Constructing a Link Editor dialog.")
951
952
        dlg = EditLinksDialog(self.document, windowTitle="Edit Links")
953
954
        links = self.scheme.find_links(source_node=self.source_node,
955
                                       sink_node=self.sink_node)
956
        existing_links = [(link.source_channel, link.sink_channel)
957
                          for link in links]
958
959
        if initial_links is None:
960
            initial_links = list(existing_links)
961
962
        dlg.setNodes(self.source_node, self.sink_node)
963
        dlg.setLinks(initial_links)
964
965
        log.info("Executing a Link Editor Dialog.")
966
        rval = dlg.exec_()
967
968
        if rval == EditLinksDialog.Accepted:
969
            links = dlg.links()
970
971
            links_to_add = set(links) - set(existing_links)
972
            links_to_remove = set(existing_links) - set(links)
973
974
            stack = self.document.undoStack()
975
            stack.beginMacro("Edit Links")
976
977
            # First remove links into a 'Single' sink channel,
978
            # but only the ones that do not have self.source_node as
979
            # a source (they will be removed later from links_to_remove)
980
            for _, sink_channel in links_to_add:
981
                if sink_channel.single:
982
                    existing = self.scheme.find_links(
983
                        sink_node=self.sink_node,
984
                        sink_channel=sink_channel
985
                    )
986
987
                    existing = [link for link in existing
988
                                if link.source_node is not self.source_node]
989
990
                    if existing:
991
                        assert len(existing) == 1
992
                        self.document.removeLink(existing[0])
993
994
            for source_channel, sink_channel in links_to_remove:
995
                links = self.scheme.find_links(source_node=self.source_node,
996
                                               source_channel=source_channel,
997
                                               sink_node=self.sink_node,
998
                                               sink_channel=sink_channel)
999
                assert len(links) == 1
1000
                self.document.removeLink(links[0])
1001
1002
            for source_channel, sink_channel in links_to_add:
1003
                link = scheme.SchemeLink(self.source_node, source_channel,
1004
                                         self.sink_node, sink_channel)
1005
1006
                self.document.addLink(link)
1007
1008
            stack.endMacro()
1009
1010
1011
def point_to_tuple(point):
1012
    """
1013
    Convert a QPointF into a (x, y) tuple.
1014
    """
1015
    return (point.x(), point.y())
1016
1017
1018
class NewArrowAnnotation(UserInteraction):
1019
    """
1020
    Create a new arrow annotation handler.
1021
    """
1022
    def __init__(self, document, *args, **kwargs):
1023
        UserInteraction.__init__(self, document, *args, **kwargs)
1024
        self.down_pos = None
1025
        self.arrow_item = None
1026
        self.annotation = None
1027
        self.color = "red"
1028
1029
    def start(self):
1030
        self.document.view().setCursor(Qt.CrossCursor)
1031
1032
        helpevent = QuickHelpTipEvent(
1033
            self.tr("Click and drag to create a new arrow"),
1034
            self.tr('<h3>New arrow annotation</h3>'
1035
                    '<p>Click and drag to create a new arrow annotation</p>'
1036
#                    '<a href="help://orange-canvas/arrow-annotations>'
1037
#                    'More ...</a>'
1038
                    )
1039
        )
1040
        QCoreApplication.postEvent(self.document, helpevent)
1041
1042
        UserInteraction.start(self)
1043
1044
    def setColor(self, color):
1045
        """
1046
        Set the color for the new arrow.
1047
        """
1048
        self.color = color
1049
1050
    def mousePressEvent(self, event):
1051
        if event.button() == Qt.LeftButton:
1052
            self.down_pos = event.scenePos()
1053
            event.accept()
1054
            return True
1055
1056
    def mouseMoveEvent(self, event):
1057
        if event.buttons() & Qt.LeftButton:
1058
            if self.arrow_item is None and \
1059
                    (self.down_pos - event.scenePos()).manhattanLength() > \
1060
                    QApplication.instance().startDragDistance():
1061
1062
                annot = scheme.SchemeArrowAnnotation(
1063
                    point_to_tuple(self.down_pos),
1064
                    point_to_tuple(event.scenePos())
1065
                )
1066
                annot.set_color(self.color)
1067
                item = self.scene.add_annotation(annot)
1068
1069
                self.arrow_item = item
1070
                self.annotation = annot
1071
1072
            if self.arrow_item is not None:
1073
                p1, p2 = map(self.arrow_item.mapFromScene,
1074
                             (self.down_pos, event.scenePos()))
1075
                self.arrow_item.setLine(QLineF(p1, p2))
1076
1077
            event.accept()
1078
            return True
1079
1080
    def mouseReleaseEvent(self, event):
1081
        if event.button() == Qt.LeftButton:
1082
            if self.arrow_item is not None:
1083
                p1, p2 = self.down_pos, event.scenePos()
1084
1085
                # Commit the annotation to the scheme
1086
                self.annotation.set_line(point_to_tuple(p1),
1087
                                         point_to_tuple(p2))
1088
1089
                self.document.addAnnotation(self.annotation)
1090
1091
                p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2))
1092
                self.arrow_item.setLine(QLineF(p1, p2))
1093
1094
            self.end()
1095
            return True
1096
1097
    def end(self):
1098
        self.down_pos = None
1099
        self.arrow_item = None
1100
        self.annotation = None
1101
        self.document.view().setCursor(Qt.ArrowCursor)
1102
1103
        # Clear the help tip
1104
        helpevent = QuickHelpTipEvent("", "")
1105
        QCoreApplication.postEvent(self.document, helpevent)
1106
1107
        UserInteraction.end(self)
1108
1109
1110
def rect_to_tuple(rect):
1111
    """
1112
    Convert a QRectF into a (x, y, width, height) tuple.
1113
    """
1114
    return rect.x(), rect.y(), rect.width(), rect.height()
1115
1116
1117
class NewTextAnnotation(UserInteraction):
1118
    """
1119
    A New Text Annotation interaction handler
1120
    """
1121
    def __init__(self, document, *args, **kwargs):
1122
        UserInteraction.__init__(self, document, *args, **kwargs)
1123
        self.down_pos = None
1124
        self.annotation_item = None
1125
        self.annotation = None
1126
        self.control = None
1127
        self.font = document.font()
1128
1129
    def setFont(self, font):
1130
        self.font = font
1131
1132
    def start(self):
1133
        self.document.view().setCursor(Qt.CrossCursor)
1134
1135
        helpevent = QuickHelpTipEvent(
1136
            self.tr("Click to create a new text annotation"),
1137
            self.tr('<h3>New text annotation</h3>'
1138
                    '<p>Click (and drag to resize) on the canvas to create '
1139
                    'a new text annotation item.</p>'
1140
#                    '<a href="help://orange-canvas/text-annotations">'
1141
#                    'More ...</a>'
1142
                    )
1143
        )
1144
        QCoreApplication.postEvent(self.document, helpevent)
1145
1146
        UserInteraction.start(self)
1147
1148
    def createNewAnnotation(self, rect):
1149
        """
1150
        Create a new TextAnnotation at with `rect` as the geometry.
1151
        """
1152
        annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect))
1153
        font = {"family": str(self.font.family()),
1154
                "size": self.font.pixelSize()}
1155
        annot.set_font(font)
1156
1157
        item = self.scene.add_annotation(annot)
1158
        item.setTextInteractionFlags(Qt.TextEditorInteraction)
1159
        item.setFramePen(QPen(Qt.DashLine))
1160
1161
        self.annotation_item = item
1162
        self.annotation = annot
1163
        self.control = controlpoints.ControlPointRect()
1164
        self.control.rectChanged.connect(
1165
            self.annotation_item.setGeometry
1166
        )
1167
        self.scene.addItem(self.control)
1168
1169
    def mousePressEvent(self, event):
1170
        if event.button() == Qt.LeftButton:
1171
            self.down_pos = event.scenePos()
1172
            return True
1173
1174
    def mouseMoveEvent(self, event):
1175
        if event.buttons() & Qt.LeftButton:
1176
            if self.annotation_item is None and \
1177
                    (self.down_pos - event.scenePos()).manhattanLength() > \
1178
                    QApplication.instance().startDragDistance():
1179
                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1180
                self.createNewAnnotation(rect)
1181
1182
            if self.annotation_item is not None:
1183
                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1184
                self.control.setRect(rect)
1185
1186
            return True
1187
1188
    def mouseReleaseEvent(self, event):
1189
        if event.button() == Qt.LeftButton:
1190
            if self.annotation_item is None:
1191
                self.createNewAnnotation(QRectF(event.scenePos(),
1192
                                                event.scenePos()))
1193
                rect = self.defaultTextGeometry(event.scenePos())
1194
1195
            else:
1196
                rect = QRectF(self.down_pos, event.scenePos()).normalized()
1197
1198
            # Commit the annotation to the scheme.
1199
            self.annotation.rect = rect_to_tuple(rect)
1200
1201
            self.document.addAnnotation(self.annotation)
1202
1203
            self.annotation_item.setGeometry(rect)
1204
1205
            self.control.rectChanged.disconnect(
1206
                self.annotation_item.setGeometry
1207
            )
1208
            self.control.hide()
1209
1210
            # Move the focus to the editor.
1211
            self.annotation_item.setFramePen(QPen(Qt.NoPen))
1212
            self.annotation_item.setFocus(Qt.OtherFocusReason)
1213
            self.annotation_item.startEdit()
1214
1215
            self.end()
1216
1217
    def defaultTextGeometry(self, point):
1218
        """
1219
        Return the default text geometry. Used in case the user single
1220
        clicked in the scene.
1221
1222
        """
1223
        font = self.annotation_item.font()
1224
        metrics = QFontMetrics(font)
1225
        spacing = metrics.lineSpacing()
1226
        margin = self.annotation_item.document().documentMargin()
1227
1228
        rect = QRectF(QPointF(point.x(), point.y() - spacing - margin),
1229
                      QSizeF(150, spacing + 2 * margin))
1230
        return rect
1231
1232
    def end(self):
1233
        if self.control is not None:
1234
            self.scene.removeItem(self.control)
1235
1236
        self.control = None
1237
        self.down_pos = None
1238
        self.annotation_item = None
1239
        self.annotation = None
1240
        self.document.view().setCursor(Qt.ArrowCursor)
1241
1242
        # Clear the help tip
1243
        helpevent = QuickHelpTipEvent("", "")
1244
        QCoreApplication.postEvent(self.document, helpevent)
1245
1246
        UserInteraction.end(self)
1247
1248
1249
class ResizeTextAnnotation(UserInteraction):
1250
    """
1251
    Resize a Text Annotation interaction handler.
1252
    """
1253
    def __init__(self, document, *args, **kwargs):
1254
        UserInteraction.__init__(self, document, *args, **kwargs)
1255
        self.item = None
1256
        self.annotation = None
1257
        self.control = None
1258
        self.savedFramePen = None
1259
        self.savedRect = None
1260
1261
    def mousePressEvent(self, event):
1262
        pos = event.scenePos()
1263
        if self.item is None:
1264
            item = self.scene.item_at(pos, items.TextAnnotation)
1265
            if item is not None and not item.hasFocus():
1266
                self.editItem(item)
1267
                return False
1268
1269
        return UserInteraction.mousePressEvent(self, event)
1270
1271
    def editItem(self, item):
1272
        annotation = self.scene.annotation_for_item(item)
1273
        rect = item.geometry()  # TODO: map to scene if item has a parent.
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
1274
        control = controlpoints.ControlPointRect(rect=rect)
1275
        self.scene.addItem(control)
1276
1277
        self.savedFramePen = item.framePen()
1278
        self.savedRect = rect
1279
1280
        control.rectEdited.connect(item.setGeometry)
1281
        control.setFocusProxy(item)
1282
1283
        item.setFramePen(QPen(Qt.DashDotLine))
1284
        item.geometryChanged.connect(self.__on_textGeometryChanged)
1285
1286
        self.item = item
1287
1288
        self.annotation = annotation
1289
        self.control = control
1290
1291
    def commit(self):
1292
        """
1293
        Commit the current item geometry state to the document.
1294
        """
1295
        rect = self.item.geometry()
1296
        if self.savedRect != rect:
1297
            command = commands.SetAttrCommand(
1298
                self.annotation, "rect",
1299
                (rect.x(), rect.y(), rect.width(), rect.height()),
1300
                name="Edit text geometry"
1301
            )
1302
            self.document.undoStack().push(command)
1303
            self.savedRect = rect
1304
1305
    def __on_editingFinished(self):
1306
        self.commit()
1307
        self.end()
1308
1309
    def __on_rectEdited(self, rect):
1310
        self.item.setGeometry(rect)
1311
1312
    def __on_textGeometryChanged(self):
1313
        if not self.control.isControlActive():
1314
            rect = self.item.geometry()
1315
            self.control.setRect(rect)
1316
1317
    def cancel(self, reason=UserInteraction.OtherReason):
1318
        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1319
        if self.item is not None and self.savedRect is not None:
1320
            self.item.setGeometry(self.savedRect)
1321
1322
        UserInteraction.cancel(self, reason)
1323
1324
    def end(self):
1325
        if self.control is not None:
1326
            self.scene.removeItem(self.control)
1327
1328
        if self.item is not None:
1329
            self.item.setFramePen(self.savedFramePen)
1330
1331
        self.item = None
1332
        self.annotation = None
1333
        self.control = None
1334
1335
        UserInteraction.end(self)
1336
1337
1338
class ResizeArrowAnnotation(UserInteraction):
1339
    """
1340
    Resize an Arrow Annotation interaction handler.
1341
    """
1342
    def __init__(self, document, *args, **kwargs):
1343
        UserInteraction.__init__(self, document, *args, **kwargs)
1344
        self.item = None
1345
        self.annotation = None
1346
        self.control = None
1347
        self.savedLine = None
1348
1349
    def mousePressEvent(self, event):
1350
        pos = event.scenePos()
1351
        if self.item is None:
1352
            item = self.scene.item_at(pos, items.ArrowAnnotation)
1353
            if item is not None and not item.hasFocus():
1354
                self.editItem(item)
1355
                return False
1356
1357
        return UserInteraction.mousePressEvent(self, event)
1358
1359
    def editItem(self, item):
1360
        annotation = self.scene.annotation_for_item(item)
1361
        control = controlpoints.ControlPointLine()
1362
        self.scene.addItem(control)
1363
1364
        line = item.line()
1365
        self.savedLine = line
1366
1367
        p1, p2 = map(item.mapToScene, (line.p1(), line.p2()))
1368
1369
        control.setLine(QLineF(p1, p2))
1370
        control.setFocusProxy(item)
1371
        control.lineEdited.connect(self.__on_lineEdited)
1372
1373
        item.geometryChanged.connect(self.__on_lineGeometryChanged)
1374
1375
        self.item = item
1376
        self.annotation = annotation
1377
        self.control = control
1378
1379
    def commit(self):
1380
        """Commit the current geometry of the item to the document.
1381
1382
        .. note:: Does nothing if the actual geometry is not changed.
1383
1384
        """
1385
        line = self.control.line()
1386
        p1, p2 = line.p1(), line.p2()
1387
1388
        if self.item.line() != self.savedLine:
1389
            command = commands.SetAttrCommand(
1390
                self.annotation,
1391
                "geometry",
1392
                ((p1.x(), p1.y()), (p2.x(), p2.y())),
1393
                name="Edit arrow geometry",
1394
            )
1395
            self.document.undoStack().push(command)
1396
            self.savedLine = self.item.line()
1397
1398
    def __on_editingFinished(self):
1399
        self.commit()
1400
        self.end()
1401
1402
    def __on_lineEdited(self, line):
1403
        p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2()))
1404
        self.item.setLine(QLineF(p1, p2))
1405
1406
    def __on_lineGeometryChanged(self):
1407
        # Possible geometry change from out of our control, for instance
1408
        # item move as a part of a selection group.
1409
        if not self.control.isControlActive():
1410
            line = self.item.line()
1411
            p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2()))
1412
            self.control.setLine(QLineF(p1, p2))
1413
1414
    def cancel(self, reason=UserInteraction.OtherReason):
1415
        log.debug("ResizeArrowAnnotation.cancel(%s)", reason)
1416
        if self.item is not None and self.savedLine is not None:
1417
            self.item.setLine(self.savedLine)
1418
1419
        UserInteraction.cancel(self, reason)
1420
1421
    def end(self):
1422
        if self.control is not None:
1423
            self.scene.removeItem(self.control)
1424
1425
        if self.item is not None:
1426
            self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged)
1427
1428
        self.control = None
1429
        self.item = None
1430
        self.annotation = None
1431
1432
        UserInteraction.end(self)
1433