1""" 2========================= 3User Interaction Handlers 4========================= 5 6User interaction handlers for a :class:`~.SchemeEditWidget`. 7 8User interactions encapsulate the logic of user interactions with the 9scheme document. 10 11All interactions are subclasses of :class:`UserInteraction`. 12 13 14""" 15import typing 16from typing import Optional, Any, Tuple, List, Set, Iterable, Sequence, Dict 17 18import abc 19import logging 20from functools import reduce 21 22from AnyQt.QtWidgets import ( 23 QApplication, QGraphicsRectItem, QGraphicsSceneMouseEvent, 24 QGraphicsSceneContextMenuEvent, QWidget, QGraphicsItem, 25 QGraphicsSceneDragDropEvent, QMenu, QAction 26) 27from AnyQt.QtGui import QPen, QBrush, QColor, QFontMetrics, QKeyEvent, QFont 28from AnyQt.QtCore import ( 29 Qt, QObject, QCoreApplication, QSizeF, QPointF, QRect, QRectF, QLineF, 30 QPoint, QMimeData, 31) 32from AnyQt.QtCore import pyqtSignal as Signal 33 34from orangecanvas.document.commands import UndoCommand 35from .usagestatistics import UsageStatistics 36from ..registry.description import WidgetDescription, OutputSignal, InputSignal 37from ..registry.qt import QtWidgetRegistry, tooltip_helper, whats_this_helper 38from .. import scheme 39from ..scheme import ( 40 SchemeNode as Node, SchemeLink as Link, Scheme, WorkflowEvent, 41 compatible_channels 42) 43from ..canvas import items 44from ..canvas.items import controlpoints 45from ..gui.quickhelp import QuickHelpTipEvent 46from . import commands 47from .editlinksdialog import EditLinksDialog 48 49if typing.TYPE_CHECKING: 50 from .schemeedit import SchemeEditWidget 51 52 A = typing.TypeVar("A") 53 #: Output/Input pair of a link 54 OIPair = Tuple[OutputSignal, InputSignal] 55 56try: 57 from importlib.metadata import EntryPoint, entry_points 58except ImportError: 59 from importlib_metadata import EntryPoint, entry_points 60 61 62log = logging.getLogger(__name__) 63 64 65def assert_not_none(optional): 66 # type: (Optional[A]) -> A 67 assert optional is not None 68 return optional 69 70 71class UserInteraction(QObject): 72 """ 73 Base class for user interaction handlers. 74 75 Parameters 76 ---------- 77 document : :class:`~.SchemeEditWidget` 78 An scheme editor instance with which the user is interacting. 79 parent : :class:`QObject`, optional 80 A parent QObject 81 deleteOnEnd : bool, optional 82 Should the UserInteraction be deleted when it finishes (``True`` 83 by default). 84 85 """ 86 # Cancel reason flags 87 88 #: No specified reason 89 NoReason = 0 90 #: User canceled the operation (e.g. pressing ESC) 91 UserCancelReason = 1 92 #: Another interaction was set 93 InteractionOverrideReason = 3 94 #: An internal error occurred 95 ErrorReason = 4 96 #: Other (unspecified) reason 97 OtherReason = 5 98 99 #: Emitted when the interaction is set on the scene. 100 started = Signal() 101 102 #: Emitted when the interaction finishes successfully. 103 finished = Signal() 104 105 #: Emitted when the interaction ends (canceled or finished) 106 ended = Signal() 107 108 #: Emitted when the interaction is canceled. 109 canceled = Signal([], [int]) 110 111 def __init__(self, document, parent=None, deleteOnEnd=True): 112 # type: ('SchemeEditWidget', Optional[QObject], bool) -> None 113 super().__init__(parent) 114 self.document = document 115 self.scene = document.scene() 116 scheme_ = document.scheme() 117 assert scheme_ is not None 118 self.scheme = scheme_ # type: scheme.Scheme 119 self.suggestions = document.suggestions() 120 self.deleteOnEnd = deleteOnEnd 121 122 self.cancelOnEsc = False 123 124 self.__finished = False 125 self.__canceled = False 126 self.__cancelReason = self.NoReason 127 128 def start(self): 129 # type: () -> None 130 """ 131 Start the interaction. This is called by the :class:`CanvasScene` when 132 the interaction is installed. 133 134 .. note:: Must be called from subclass implementations. 135 136 """ 137 self.started.emit() 138 139 def end(self): 140 # type: () -> None 141 """ 142 Finish the interaction. Restore any leftover state in this method. 143 144 .. note:: This gets called from the default :func:`cancel` 145 implementation. 146 147 """ 148 self.__finished = True 149 150 if self.scene.user_interaction_handler is self: 151 self.scene.set_user_interaction_handler(None) 152 153 if self.__canceled: 154 self.canceled.emit() 155 self.canceled[int].emit(self.__cancelReason) 156 else: 157 self.finished.emit() 158 159 self.ended.emit() 160 161 if self.deleteOnEnd: 162 self.deleteLater() 163 164 def cancel(self, reason=OtherReason): 165 # type: (int) -> None 166 """ 167 Cancel the interaction with `reason`. 168 """ 169 170 self.__canceled = True 171 self.__cancelReason = reason 172 173 self.end() 174 175 def isFinished(self): 176 # type: () -> bool 177 """ 178 Is the interaction finished. 179 """ 180 return self.__finished 181 182 def isCanceled(self): 183 # type: () -> bool 184 """ 185 Was the interaction canceled. 186 """ 187 return self.__canceled 188 189 def cancelReason(self): 190 # type: () -> int 191 """ 192 Return the reason the interaction was canceled. 193 """ 194 return self.__cancelReason 195 196 def mousePressEvent(self, event): 197 # type: (QGraphicsSceneMouseEvent) -> bool 198 """ 199 Handle a `QGraphicsScene.mousePressEvent`. 200 """ 201 return False 202 203 def mouseMoveEvent(self, event): 204 # type: (QGraphicsSceneMouseEvent) -> bool 205 """ 206 Handle a `GraphicsScene.mouseMoveEvent`. 207 """ 208 return False 209 210 def mouseReleaseEvent(self, event): 211 # type: (QGraphicsSceneMouseEvent) -> bool 212 """ 213 Handle a `QGraphicsScene.mouseReleaseEvent`. 214 """ 215 return False 216 217 def mouseDoubleClickEvent(self, event): 218 # type: (QGraphicsSceneMouseEvent) -> bool 219 """ 220 Handle a `QGraphicsScene.mouseDoubleClickEvent`. 221 """ 222 return False 223 224 def keyPressEvent(self, event): 225 # type: (QKeyEvent) -> bool 226 """ 227 Handle a `QGraphicsScene.keyPressEvent` 228 """ 229 if self.cancelOnEsc and event.key() == Qt.Key_Escape: 230 self.cancel(self.UserCancelReason) 231 return False 232 233 def keyReleaseEvent(self, event): 234 # type: (QKeyEvent) -> bool 235 """ 236 Handle a `QGraphicsScene.keyPressEvent` 237 """ 238 return False 239 240 def contextMenuEvent(self, event): 241 # type: (QGraphicsSceneContextMenuEvent) -> bool 242 """ 243 Handle a `QGraphicsScene.contextMenuEvent` 244 """ 245 return False 246 247 def dragEnterEvent(self, event): 248 # type: (QGraphicsSceneDragDropEvent) -> bool 249 """ 250 Handle a `QGraphicsScene.dragEnterEvent` 251 252 .. versionadded:: 0.1.20 253 """ 254 return False 255 256 def dragMoveEvent(self, event): 257 # type: (QGraphicsSceneDragDropEvent) -> bool 258 """ 259 Handle a `QGraphicsScene.dragMoveEvent` 260 261 .. versionadded:: 0.1.20 262 """ 263 return False 264 265 def dragLeaveEvent(self, event): 266 # type: (QGraphicsSceneDragDropEvent) -> bool 267 """ 268 Handle a `QGraphicsScene.dragLeaveEvent` 269 270 .. versionadded:: 0.1.20 271 """ 272 return False 273 274 def dropEvent(self, event): 275 # type: (QGraphicsSceneDragDropEvent) -> bool 276 """ 277 Handle a `QGraphicsScene.dropEvent` 278 279 .. versionadded:: 0.1.20 280 """ 281 return False 282 283 284class NoPossibleLinksError(ValueError): 285 pass 286 287 288class UserCanceledError(ValueError): 289 pass 290 291 292def reversed_arguments(func): 293 """ 294 Return a function with reversed argument order. 295 """ 296 def wrapped(*args): 297 return func(*reversed(args)) 298 return wrapped 299 300 301class NewLinkAction(UserInteraction): 302 """ 303 User drags a new link from an existing `NodeAnchorItem` to create 304 a connection between two existing nodes or to a new node if the release 305 is over an empty area, in which case a quick menu for new node selection 306 is presented to the user. 307 308 """ 309 # direction of the drag 310 FROM_SOURCE = 1 311 FROM_SINK = 2 312 313 def __init__(self, document, *args, **kwargs): 314 super().__init__(document, *args, **kwargs) 315 self.from_item = None # type: Optional[items.NodeItem] 316 self.from_signal = None # type: Optional[Union[InputSignal, OutputSignal]] 317 self.direction = 0 # type: int 318 self.showing_incompatible_widget = False # type: bool 319 320 # An `NodeItem` currently under the mouse as a possible 321 # link drop target. 322 self.current_target_item = None # type: Optional[items.NodeItem] 323 # A temporary `LinkItem` used while dragging. 324 self.tmp_link_item = None # type: Optional[items.LinkItem] 325 # An temporary `AnchorPoint` inserted into `current_target_item` 326 self.tmp_anchor_point = None # type: Optional[items.AnchorPoint] 327 # An `AnchorPoint` following the mouse cursor 328 self.cursor_anchor_point = None # type: Optional[items.AnchorPoint] 329 # An UndoCommand 330 self.macro = None # type: Optional[UndoCommand] 331 332 # Cache viable signals of currently hovered node 333 self.__target_compatible_signals = None 334 335 self.cancelOnEsc = True 336 337 def remove_tmp_anchor(self): 338 # type: () -> None 339 """ 340 Remove a temporary anchor point from the current target item. 341 """ 342 assert self.current_target_item is not None 343 assert self.tmp_anchor_point is not None 344 if self.direction == self.FROM_SOURCE: 345 self.current_target_item.removeInputAnchor(self.tmp_anchor_point) 346 else: 347 self.current_target_item.removeOutputAnchor(self.tmp_anchor_point) 348 self.tmp_anchor_point = None 349 350 def update_tmp_anchor(self, item, scenePos): 351 # type: (items.NodeItem, QPointF) -> None 352 """ 353 If hovering over a new compatible channel, move it. 354 """ 355 assert self.tmp_anchor_point is not None 356 if self.direction == self.FROM_SOURCE: 357 signal = item.inputAnchorItem.signalAtPos(scenePos, 358 self.__target_compatible_signals) 359 else: 360 signal = item.outputAnchorItem.signalAtPos(scenePos, 361 self.__target_compatible_signals) 362 self.tmp_anchor_point.setSignal(signal) 363 364 def create_tmp_anchor(self, item, scenePos, viableLinks=None): 365 # type: (items.NodeItem, QPointF) -> None 366 """ 367 Create a new tmp anchor at the `item` (:class:`NodeItem`). 368 """ 369 assert self.tmp_anchor_point is None 370 if self.direction == self.FROM_SOURCE: 371 anchor = item.inputAnchorItem 372 signal = anchor.signalAtPos(scenePos, 373 self.__target_compatible_signals) 374 self.tmp_anchor_point = item.newInputAnchor(signal) 375 else: 376 anchor = item.outputAnchorItem 377 signal = anchor.signalAtPos(scenePos, 378 self.__target_compatible_signals) 379 self.tmp_anchor_point = item.newOutputAnchor(signal) 380 381 def can_connect(self, target_item): 382 # type: (items.NodeItem) -> bool 383 """ 384 Is the connection between `self.from_item` (item where the drag 385 started) and `target_item` possible. 386 387 If possible, initialize the variables regarding the node. 388 """ 389 if self.from_item is None: 390 return False 391 node1 = self.scene.node_for_item(self.from_item) 392 node2 = self.scene.node_for_item(target_item) 393 394 if self.direction == self.FROM_SOURCE: 395 links = self.scheme.propose_links(node1, node2, 396 source_signal=self.from_signal) 397 self.__target_compatible_signals = [l[1] for l in links] 398 else: 399 links = self.scheme.propose_links(node2, node1, 400 sink_signal=self.from_signal) 401 self.__target_compatible_signals = [l[0] for l in links] 402 403 return bool(links) 404 405 def set_link_target_anchor(self, anchor): 406 # type: (items.AnchorPoint) -> None 407 """ 408 Set the temp line target anchor. 409 """ 410 assert self.tmp_link_item is not None 411 if self.direction == self.FROM_SOURCE: 412 self.tmp_link_item.setSinkItem(None, anchor=anchor) 413 else: 414 self.tmp_link_item.setSourceItem(None, anchor=anchor) 415 416 def target_node_item_at(self, pos): 417 # type: (QPointF) -> Optional[items.NodeItem] 418 """ 419 Return a suitable :class:`NodeItem` at position `pos` on which 420 a link can be dropped. 421 """ 422 # Test for a suitable `NodeAnchorItem` or `NodeItem` at pos. 423 if self.direction == self.FROM_SOURCE: 424 anchor_type = items.SinkAnchorItem 425 else: 426 anchor_type = items.SourceAnchorItem 427 428 item = self.scene.item_at(pos, (anchor_type, items.NodeItem)) 429 430 if isinstance(item, anchor_type): 431 return item.parentNodeItem() 432 elif isinstance(item, items.NodeItem): 433 return item 434 else: 435 return None 436 437 def mousePressEvent(self, event): 438 # type: (QGraphicsSceneMouseEvent) -> bool 439 anchor_item = self.scene.item_at( 440 event.scenePos(), items.NodeAnchorItem 441 ) 442 if anchor_item is not None and event.button() == Qt.LeftButton: 443 # Start a new link starting at item 444 self.from_item = anchor_item.parentNodeItem() 445 if isinstance(anchor_item, items.SourceAnchorItem): 446 self.direction = NewLinkAction.FROM_SOURCE 447 else: 448 self.direction = NewLinkAction.FROM_SINK 449 450 event.accept() 451 452 helpevent = QuickHelpTipEvent( 453 self.tr("Create a new link"), 454 self.tr('<h3>Create new link</h3>' 455 '<p>Drag a link to an existing node or release on ' 456 'an empty spot to create a new node.</p>' 457 '<p>Hold Shift when releasing the mouse button to ' 458 'edit connections.</p>' 459# '<a href="help://orange-canvas/create-new-links">' 460# 'More ...</a>' 461 ) 462 ) 463 QCoreApplication.postEvent(self.document, helpevent) 464 return True 465 else: 466 # Whoever put us in charge did not know what he was doing. 467 self.cancel(self.ErrorReason) 468 return False 469 470 def mouseMoveEvent(self, event): 471 # type: (QGraphicsSceneMouseEvent) -> bool 472 if self.tmp_link_item is None: 473 # On first mouse move event create the temp link item and 474 # initialize it to follow the `cursor_anchor_point`. 475 self.tmp_link_item = items.LinkItem() 476 # An anchor under the cursor for the duration of this action. 477 self.cursor_anchor_point = items.AnchorPoint() 478 self.cursor_anchor_point.setPos(event.scenePos()) 479 480 # Set the `fixed` end of the temp link (where the drag started). 481 scenePos = event.scenePos() 482 483 if self.direction == self.FROM_SOURCE: 484 anchor = self.from_item.outputAnchorItem 485 else: 486 anchor = self.from_item.inputAnchorItem 487 anchor.setHovered(False) 488 anchor.setCompatibleSignals(None) 489 490 if anchor.anchorOpen(): 491 signal = anchor.signalAtPos(scenePos) 492 anchor.setKeepAnchorOpen(signal) 493 else: 494 signal = None 495 self.from_signal = signal 496 497 if self.direction == self.FROM_SOURCE: 498 self.tmp_link_item.setSourceItem(self.from_item, signal) 499 else: 500 self.tmp_link_item.setSinkItem(self.from_item, signal) 501 502 self.set_link_target_anchor(self.cursor_anchor_point) 503 self.scene.addItem(self.tmp_link_item) 504 505 assert self.cursor_anchor_point is not None 506 507 # `NodeItem` at the cursor position 508 item = self.target_node_item_at(event.scenePos()) 509 510 if self.current_target_item is not None and \ 511 (item is None or item is not self.current_target_item): 512 # `current_target_item` is no longer under the mouse cursor 513 # (was replaced by another item or the the cursor was moved over 514 # an empty scene spot. 515 log.info("%r is no longer the target.", self.current_target_item) 516 if self.direction == self.FROM_SOURCE: 517 anchor = self.current_target_item.inputAnchorItem 518 else: 519 anchor = self.current_target_item.outputAnchorItem 520 if self.showing_incompatible_widget: 521 anchor.setIncompatible(False) 522 self.showing_incompatible_widget = False 523 else: 524 self.remove_tmp_anchor() 525 anchor.setHovered(False) 526 anchor.setCompatibleSignals(None) 527 self.current_target_item = None 528 529 if item is not None and item is not self.from_item: 530 # The mouse is over a node item (different from the starting node) 531 if self.current_target_item is item: 532 # Mouse is over the same item 533 scenePos = event.scenePos() 534 # Move to new potential anchor 535 if not self.showing_incompatible_widget: 536 self.update_tmp_anchor(item, scenePos) 537 else: 538 self.set_link_target_anchor(self.cursor_anchor_point) 539 elif self.can_connect(item): 540 # Mouse is over a new item 541 log.info("%r is the new target.", item) 542 if self.direction == self.FROM_SOURCE: 543 item.inputAnchorItem.setCompatibleSignals( 544 self.__target_compatible_signals) 545 item.inputAnchorItem.setHovered(True) 546 else: 547 item.outputAnchorItem.setCompatibleSignals( 548 self.__target_compatible_signals) 549 item.outputAnchorItem.setHovered(True) 550 scenePos = event.scenePos() 551 self.create_tmp_anchor(item, scenePos) 552 self.set_link_target_anchor( 553 assert_not_none(self.tmp_anchor_point) 554 ) 555 self.current_target_item = item 556 self.showing_incompatible_widget = False 557 else: 558 log.info("%r does not have compatible channels", item) 559 self.__target_compatible_signals = [] 560 if self.direction == self.FROM_SOURCE: 561 anchor = item.inputAnchorItem 562 else: 563 anchor = item.outputAnchorItem 564 anchor.setCompatibleSignals( 565 self.__target_compatible_signals) 566 anchor.setHovered(True) 567 anchor.setIncompatible(True) 568 self.showing_incompatible_widget = True 569 self.set_link_target_anchor(self.cursor_anchor_point) 570 self.current_target_item = item 571 else: 572 self.set_link_target_anchor(self.cursor_anchor_point) 573 574 self.cursor_anchor_point.setPos(event.scenePos()) 575 576 return True 577 578 def mouseReleaseEvent(self, event): 579 # type: (QGraphicsSceneMouseEvent) -> bool 580 if self.tmp_link_item is not None: 581 item = self.target_node_item_at(event.scenePos()) 582 node = None # type: Optional[Node] 583 stack = self.document.undoStack() 584 585 self.macro = UndoCommand(self.tr("Add link")) 586 587 if item: 588 # If the release was over a node item then connect them 589 node = self.scene.node_for_item(item) 590 else: 591 # Release on an empty canvas part 592 # Show a quick menu popup for a new widget creation. 593 try: 594 node = self.create_new(event) 595 except Exception: 596 log.error("Failed to create a new node, ending.", 597 exc_info=True) 598 node = None 599 600 if node is not None: 601 commands.AddNodeCommand(self.scheme, node, 602 parent=self.macro) 603 604 if node is not None and not self.showing_incompatible_widget: 605 if self.direction == self.FROM_SOURCE: 606 source_node = self.scene.node_for_item(self.from_item) 607 source_signal = self.from_signal 608 sink_node = node 609 if item is not None and item.inputAnchorItem.anchorOpen(): 610 sink_signal = item.inputAnchorItem.signalAtPos( 611 event.scenePos(), 612 self.__target_compatible_signals 613 ) 614 else: 615 sink_signal = None 616 else: 617 source_node = node 618 if item is not None and item.outputAnchorItem.anchorOpen(): 619 source_signal = item.outputAnchorItem.signalAtPos( 620 event.scenePos(), 621 self.__target_compatible_signals 622 ) 623 else: 624 source_signal = None 625 sink_node = self.scene.node_for_item(self.from_item) 626 sink_signal = self.from_signal 627 self.suggestions.set_direction(self.direction) 628 self.connect_nodes(source_node, sink_node, 629 source_signal, sink_signal) 630 631 if not self.isCanceled() or not self.isFinished() and \ 632 self.macro is not None: 633 # Push (commit) the add link/node action on the stack. 634 stack.push(self.macro) 635 636 self.end() 637 return True 638 else: 639 self.end() 640 return False 641 642 def create_new(self, event): 643 # type: (QGraphicsSceneMouseEvent) -> Optional[Node] 644 """ 645 Create and return a new node with a `QuickMenu`. 646 """ 647 pos = event.screenPos() 648 menu = self.document.quickMenu() 649 node = self.scene.node_for_item(self.from_item) 650 from_signal = self.from_signal 651 from_desc = node.description 652 653 def is_compatible( 654 source_signal: OutputSignal, 655 source: WidgetDescription, 656 sink: WidgetDescription, 657 sink_signal: InputSignal 658 ) -> bool: 659 return any(scheme.compatible_channels(output, input) 660 for output 661 in ([source_signal] if source_signal else source.outputs) 662 for input 663 in ([sink_signal] if sink_signal else sink.inputs)) 664 665 from_sink = self.direction == self.FROM_SINK 666 if from_sink: 667 # Reverse the argument order. 668 is_compatible = reversed_arguments(is_compatible) 669 suggestion_sort = self.suggestions.get_source_suggestions(from_desc.name) 670 else: 671 suggestion_sort = self.suggestions.get_sink_suggestions(from_desc.name) 672 673 def sort(left, right): 674 # list stores frequencies, so sign is flipped 675 return suggestion_sort[left] > suggestion_sort[right] 676 677 menu.setSortingFunc(sort) 678 679 def filter(index): 680 desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE) 681 if isinstance(desc, WidgetDescription): 682 return is_compatible(from_signal, from_desc, desc, None) 683 else: 684 return False 685 686 menu.setFilterFunc(filter) 687 menu.triggerSearch() 688 try: 689 action = menu.exec_(pos) 690 finally: 691 menu.setFilterFunc(None) 692 693 if action: 694 item = action.property("item") 695 desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE) 696 pos = event.scenePos() 697 # a new widget should be placed so that the connection 698 # stays as it was 699 offset = 31 * (-1 if self.direction == self.FROM_SINK else 700 1 if self.direction == self.FROM_SOURCE else 0) 701 statistics = self.document.usageStatistics() 702 statistics.begin_extend_action(from_sink, node) 703 node = self.document.newNodeHelper(desc, 704 position=(pos.x() + offset, 705 pos.y())) 706 return node 707 else: 708 return None 709 710 def connect_nodes( 711 self, source_node: Node, sink_node: Node, 712 source_signal: Optional[OutputSignal] = None, 713 sink_signal: Optional[InputSignal] = None 714 ) -> None: 715 """ 716 Connect `source_node` to `sink_node`. If there are more then one 717 equally weighted and non conflicting links possible present a 718 detailed dialog for link editing. 719 720 """ 721 UsageStatistics.set_sink_anchor_open(sink_signal is not None) 722 UsageStatistics.set_source_anchor_open(source_signal is not None) 723 try: 724 possible = self.scheme.propose_links(source_node, sink_node, 725 source_signal, sink_signal) 726 727 log.debug("proposed (weighted) links: %r", 728 [(s1.name, s2.name, w) for s1, s2, w in possible]) 729 730 if not possible: 731 raise NoPossibleLinksError 732 733 source, sink, w = possible[0] 734 735 # just a list of signal tuples for now, will be converted 736 # to SchemeLinks later 737 links_to_add = [] # type: List[Link] 738 links_to_remove = [] # type: List[Link] 739 show_link_dialog = False 740 741 # Ambiguous new link request. 742 if len(possible) >= 2: 743 # Check for possible ties in the proposed link weights 744 _, _, w2 = possible[1] 745 if w == w2: 746 show_link_dialog = True 747 748 # Check for destructive action (i.e. would the new link 749 # replace a previous link) 750 if sink.single and self.scheme.find_links(sink_node=sink_node, 751 sink_channel=sink): 752 show_link_dialog = True 753 754 if show_link_dialog: 755 existing = self.scheme.find_links(source_node=source_node, 756 sink_node=sink_node) 757 758 if existing: 759 # edit_links will populate the view with existing links 760 initial_links = None 761 else: 762 initial_links = [(source, sink)] 763 764 try: 765 rstatus, links_to_add, links_to_remove = self.edit_links( 766 source_node, sink_node, initial_links 767 ) 768 except Exception: 769 log.error("Failed to edit the links", 770 exc_info=True) 771 raise 772 if rstatus == EditLinksDialog.Rejected: 773 raise UserCanceledError 774 else: 775 # links_to_add now needs to be a list of actual SchemeLinks 776 links_to_add = [ 777 scheme.SchemeLink(source_node, source, sink_node, sink) 778 ] 779 links_to_add, links_to_remove = \ 780 add_links_plan(self.scheme, links_to_add) 781 782 # Remove temp items before creating any new links 783 self.cleanup() 784 785 for link in links_to_remove: 786 commands.RemoveLinkCommand(self.scheme, link, 787 parent=self.macro) 788 789 for link in links_to_add: 790 # Check if the new requested link is a duplicate of an 791 # existing link 792 duplicate = self.scheme.find_links( 793 link.source_node, link.source_channel, 794 link.sink_node, link.sink_channel 795 ) 796 797 if not duplicate: 798 commands.AddLinkCommand(self.scheme, link, 799 parent=self.macro) 800 801 except scheme.IncompatibleChannelTypeError: 802 log.info("Cannot connect: invalid channel types.") 803 self.cancel() 804 except scheme.SchemeTopologyError: 805 log.info("Cannot connect: connection creates a cycle.") 806 self.cancel() 807 except NoPossibleLinksError: 808 log.info("Cannot connect: no possible links.") 809 self.cancel() 810 except UserCanceledError: 811 log.info("User canceled a new link action.") 812 self.cancel(UserInteraction.UserCancelReason) 813 except Exception: 814 log.error("An error occurred during the creation of a new link.", 815 exc_info=True) 816 self.cancel() 817 818 def edit_links( 819 self, 820 source_node: Node, 821 sink_node: Node, 822 initial_links: 'Optional[List[OIPair]]' = None 823 ) -> 'Tuple[int, List[Link], List[Link]]': 824 """ 825 Show and execute the `EditLinksDialog`. 826 Optional `initial_links` list can provide a list of initial 827 `(source, sink)` channel tuples to show in the view, otherwise 828 the dialog is populated with existing links in the scheme (passing 829 an empty list will disable all initial links). 830 831 """ 832 status, links_to_add_spec, links_to_remove_spec = \ 833 edit_links( 834 self.scheme, source_node, sink_node, initial_links, 835 parent=self.document 836 ) 837 838 if status == EditLinksDialog.Accepted: 839 links_to_add = [ 840 scheme.SchemeLink( 841 source_node, source_channel, 842 sink_node, sink_channel 843 ) for source_channel, sink_channel in links_to_add_spec 844 ] 845 links_to_remove = list(reduce( 846 list.__iadd__, ( 847 self.scheme.find_links( 848 source_node, source_channel, 849 sink_node, sink_channel 850 ) for source_channel, sink_channel in links_to_remove_spec 851 ), 852 [] 853 )) # type: List[Link] 854 conflicting = [conflicting_single_link(self.scheme, link) 855 for link in links_to_add] 856 conflicting = [link for link in conflicting if link is not None] 857 for link in conflicting: 858 if link not in links_to_remove: 859 links_to_remove.append(link) 860 861 return status, links_to_add, links_to_remove 862 else: 863 return status, [], [] 864 865 def end(self): 866 # type: () -> None 867 self.cleanup() 868 self.reset_open_anchor() 869 # Remove the help tip set in mousePressEvent 870 self.macro = None 871 helpevent = QuickHelpTipEvent("", "") 872 QCoreApplication.postEvent(self.document, helpevent) 873 super().end() 874 875 def cancel(self, reason=UserInteraction.OtherReason): 876 # type: (int) -> None 877 self.cleanup() 878 self.reset_open_anchor() 879 super().cancel(reason) 880 881 def cleanup(self): 882 # type: () -> None 883 """ 884 Cleanup all temporary items in the scene that are left. 885 """ 886 if self.tmp_link_item: 887 self.tmp_link_item.setSinkItem(None) 888 self.tmp_link_item.setSourceItem(None) 889 890 if self.tmp_link_item.scene(): 891 self.scene.removeItem(self.tmp_link_item) 892 893 self.tmp_link_item = None 894 895 if self.current_target_item: 896 if not self.showing_incompatible_widget: 897 self.remove_tmp_anchor() 898 else: 899 if self.direction == self.FROM_SOURCE: 900 anchor = self.current_target_item.inputAnchorItem 901 else: 902 anchor = self.current_target_item.outputAnchorItem 903 anchor.setIncompatible(False) 904 905 self.current_target_item = None 906 907 if self.cursor_anchor_point and self.cursor_anchor_point.scene(): 908 self.scene.removeItem(self.cursor_anchor_point) 909 self.cursor_anchor_point = None 910 911 def reset_open_anchor(self): 912 """ 913 This isn't part of cleanup, because it should retain its value 914 until the link is created. 915 """ 916 if self.direction == self.FROM_SOURCE: 917 anchor = self.from_item.outputAnchorItem 918 else: 919 anchor = self.from_item.inputAnchorItem 920 anchor.setKeepAnchorOpen(None) 921 922 923def edit_links( 924 scheme: Scheme, 925 source_node: Node, 926 sink_node: Node, 927 initial_links: 'Optional[List[OIPair]]' = None, 928 parent: 'Optional[QWidget]' = None 929) -> 'Tuple[int, List[OIPair], List[OIPair]]': 930 """ 931 Show and execute the `EditLinksDialog`. 932 Optional `initial_links` list can provide a list of initial 933 `(source, sink)` channel tuples to show in the view, otherwise 934 the dialog is populated with existing links in the scheme (passing 935 an empty list will disable all initial links). 936 937 """ 938 log.info("Constructing a Link Editor dialog.") 939 940 dlg = EditLinksDialog(parent, windowTitle="Edit Links") 941 942 # all SchemeLinks between the two nodes. 943 links = scheme.find_links(source_node=source_node, sink_node=sink_node) 944 existing_links = [(link.source_channel, link.sink_channel) 945 for link in links] 946 947 if initial_links is None: 948 initial_links = list(existing_links) 949 950 dlg.setNodes(source_node, sink_node) 951 dlg.setLinks(initial_links) 952 953 log.info("Executing a Link Editor Dialog.") 954 rval = dlg.exec_() 955 956 if rval == EditLinksDialog.Accepted: 957 edited_links = dlg.links() 958 959 # Differences 960 links_to_add = set(edited_links) - set(existing_links) 961 links_to_remove = set(existing_links) - set(edited_links) 962 return rval, list(links_to_add), list(links_to_remove) 963 else: 964 return rval, [], [] 965 966 967def add_links_plan(scheme, links, force_replace=False): 968 # type: (Scheme, Iterable[Link], bool) -> Tuple[List[Link], List[Link]] 969 """ 970 Return a plan for adding a list of links to the scheme. 971 """ 972 links_to_add = list(links) 973 links_to_remove = [conflicting_single_link(scheme, link) 974 for link in links] 975 links_to_remove = [link for link in links_to_remove if link is not None] 976 977 if not force_replace: 978 links_to_add, links_to_remove = remove_duplicates(links_to_add, 979 links_to_remove) 980 return links_to_add, links_to_remove 981 982 983def conflicting_single_link(scheme, link): 984 # type: (Scheme, Link) -> Optional[Link] 985 """ 986 Find and return an existing link in `scheme` connected to the same 987 input channel as `link` if the channel has the 'single' flag. 988 If no such channel exists (or sink channel is not 'single') 989 return `None`. 990 """ 991 if link.sink_channel.single: 992 existing = scheme.find_links( 993 sink_node=link.sink_node, 994 sink_channel=link.sink_channel 995 ) 996 997 if existing: 998 assert len(existing) == 1 999 return existing[0] 1000 return None 1001 1002 1003def remove_duplicates(links_to_add, links_to_remove): 1004 # type: (List[Link], List[Link]) -> Tuple[List[Link], List[Link]] 1005 def link_key(link): 1006 # type: (Link) -> Tuple[Node, OutputSignal, Node, InputSignal] 1007 return (link.source_node, link.source_channel, 1008 link.sink_node, link.sink_channel) 1009 1010 add_keys = list(map(link_key, links_to_add)) 1011 remove_keys = list(map(link_key, links_to_remove)) 1012 duplicate_keys = set(add_keys).intersection(remove_keys) 1013 1014 def not_duplicate(link): 1015 # type: (Link) -> bool 1016 return link_key(link) not in duplicate_keys 1017 1018 links_to_add = list(filter(not_duplicate, links_to_add)) 1019 links_to_remove = list(filter(not_duplicate, links_to_remove)) 1020 return links_to_add, links_to_remove 1021 1022 1023class NewNodeAction(UserInteraction): 1024 """ 1025 Present the user with a quick menu for node selection and 1026 create the selected node. 1027 """ 1028 def mousePressEvent(self, event): 1029 # type: (QGraphicsSceneMouseEvent) -> bool 1030 if event.button() == Qt.RightButton: 1031 self.create_new(event.screenPos()) 1032 self.end() 1033 return True 1034 1035 def create_new(self, pos, search_text=""): 1036 # type: (QPoint, str) -> Optional[Node] 1037 """ 1038 Create and add new node to the workflow using `QuickMenu` popup at 1039 `pos` (in screen coordinates). 1040 """ 1041 menu = self.document.quickMenu() 1042 menu.setFilterFunc(None) 1043 1044 # compares probability of the user needing the widget as a source 1045 def defaultSort(left, right): 1046 default_suggestions = self.suggestions.get_default_suggestions() 1047 left_frequency = sum(default_suggestions[left].values()) 1048 right_frequency = sum(default_suggestions[right].values()) 1049 return left_frequency > right_frequency 1050 1051 menu.setSortingFunc(defaultSort) 1052 1053 action = menu.exec_(pos, search_text) 1054 if action: 1055 item = action.property("item") 1056 desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE) 1057 # Get the scene position 1058 view = self.document.view() 1059 pos = view.mapToScene(view.mapFromGlobal(pos)) 1060 1061 statistics = self.document.usageStatistics() 1062 statistics.begin_action(UsageStatistics.QuickMenu) 1063 node = self.document.newNodeHelper(desc, 1064 position=(pos.x(), pos.y())) 1065 self.document.addNode(node) 1066 return node 1067 else: 1068 return None 1069 1070 1071class RectangleSelectionAction(UserInteraction): 1072 """ 1073 Select items in the scene using a Rectangle selection 1074 """ 1075 def __init__(self, document, *args, **kwargs): 1076 # type: (SchemeEditWidget, Any, Any) -> None 1077 super().__init__(document, *args, **kwargs) 1078 # The initial selection at drag start 1079 self.initial_selection = None # type: Optional[Set[QGraphicsItem]] 1080 # Selection when last updated in a mouseMoveEvent 1081 self.last_selection = None # type: Optional[Set[QGraphicsItem]] 1082 # A selection rect (`QRectF`) 1083 self.selection_rect = None # type: Optional[QRectF] 1084 # Keyboard modifiers 1085 self.modifiers = Qt.NoModifier 1086 self.rect_item = None # type: Optional[QGraphicsRectItem] 1087 1088 def mousePressEvent(self, event): 1089 # type: (QGraphicsSceneMouseEvent) -> bool 1090 pos = event.scenePos() 1091 any_item = self.scene.item_at(pos) 1092 if not any_item and event.button() & Qt.LeftButton: 1093 self.modifiers = event.modifiers() 1094 self.selection_rect = QRectF(pos, QSizeF(0, 0)) 1095 self.rect_item = QGraphicsRectItem( 1096 self.selection_rect.normalized() 1097 ) 1098 1099 self.rect_item.setPen( 1100 QPen(QBrush(QColor(51, 153, 255, 192)), 1101 0.4, Qt.SolidLine, Qt.RoundCap) 1102 ) 1103 1104 self.rect_item.setBrush( 1105 QBrush(QColor(168, 202, 236, 192)) 1106 ) 1107 1108 self.rect_item.setZValue(-100) 1109 1110 # Clear the focus if necessary. 1111 if not self.scene.stickyFocus(): 1112 self.scene.clearFocus() 1113 1114 if not self.modifiers & Qt.ControlModifier: 1115 self.scene.clearSelection() 1116 1117 event.accept() 1118 return True 1119 else: 1120 self.cancel(self.ErrorReason) 1121 return False 1122 1123 def mouseMoveEvent(self, event): 1124 # type: (QGraphicsSceneMouseEvent) -> bool 1125 if self.rect_item is not None and not self.rect_item.scene(): 1126 # Add the rect item to the scene when the mouse moves. 1127 self.scene.addItem(self.rect_item) 1128 self.update_selection(event) 1129 return True 1130 1131 def mouseReleaseEvent(self, event): 1132 # type: (QGraphicsSceneMouseEvent) -> bool 1133 if event.button() == Qt.LeftButton: 1134 if self.initial_selection is None: 1135 # A single click. 1136 self.scene.clearSelection() 1137 else: 1138 self.update_selection(event) 1139 self.end() 1140 return True 1141 1142 def update_selection(self, event): 1143 # type: (QGraphicsSceneMouseEvent) -> None 1144 """ 1145 Update the selection rectangle from a QGraphicsSceneMouseEvent 1146 `event` instance. 1147 """ 1148 if self.initial_selection is None: 1149 self.initial_selection = set(self.scene.selectedItems()) 1150 self.last_selection = self.initial_selection 1151 1152 assert self.selection_rect is not None 1153 assert self.rect_item is not None 1154 assert self.initial_selection is not None 1155 assert self.last_selection is not None 1156 1157 pos = event.scenePos() 1158 self.selection_rect = QRectF(self.selection_rect.topLeft(), pos) 1159 1160 # Make sure the rect_item does not cause the scene rect to grow. 1161 rect = self._bound_selection_rect(self.selection_rect.normalized()) 1162 1163 # Need that 0.5 constant otherwise the sceneRect will still 1164 # grow (anti-aliasing correction by QGraphicsScene?) 1165 pw = self.rect_item.pen().width() + 0.5 1166 1167 self.rect_item.setRect(rect.adjusted(pw, pw, -pw, -pw)) 1168 1169 selected = self.scene.items(self.selection_rect.normalized(), 1170 Qt.IntersectsItemShape, 1171 Qt.AscendingOrder) 1172 1173 selected = set([item for item in selected if \ 1174 item.flags() & Qt.ItemIsSelectable]) 1175 1176 if self.modifiers & Qt.ControlModifier: 1177 for item in selected | self.last_selection | \ 1178 self.initial_selection: 1179 item.setSelected( 1180 (item in selected) ^ (item in self.initial_selection) 1181 ) 1182 else: 1183 for item in selected.union(self.last_selection): 1184 item.setSelected(item in selected) 1185 1186 self.last_selection = set(self.scene.selectedItems()) 1187 1188 def end(self): 1189 # type: () -> None 1190 self.initial_selection = None 1191 self.last_selection = None 1192 self.modifiers = Qt.NoModifier 1193 if self.rect_item is not None: 1194 self.rect_item.hide() 1195 if self.rect_item.scene() is not None: 1196 self.scene.removeItem(self.rect_item) 1197 super().end() 1198 1199 def viewport_rect(self): 1200 # type: () -> QRectF 1201 """ 1202 Return the bounding rect of the document's viewport on the scene. 1203 """ 1204 view = self.document.view() 1205 vsize = view.viewport().size() 1206 viewportrect = QRect(0, 0, vsize.width(), vsize.height()) 1207 return view.mapToScene(viewportrect).boundingRect() 1208 1209 def _bound_selection_rect(self, rect): 1210 # type: (QRectF) -> QRectF 1211 """ 1212 Bound the selection `rect` to a sensible size. 1213 """ 1214 srect = self.scene.sceneRect() 1215 vrect = self.viewport_rect() 1216 maxrect = srect.united(vrect) 1217 return rect.intersected(maxrect) 1218 1219 1220class EditNodeLinksAction(UserInteraction): 1221 """ 1222 Edit multiple links between two :class:`SchemeNode` instances using 1223 a :class:`EditLinksDialog` 1224 1225 Parameters 1226 ---------- 1227 document : :class:`SchemeEditWidget` 1228 The editor widget. 1229 source_node : :class:`SchemeNode` 1230 The source (link start) node for the link editor. 1231 sink_node : :class:`SchemeNode` 1232 The sink (link end) node for the link editor. 1233 1234 """ 1235 def __init__(self, document, source_node, sink_node, *args, **kwargs): 1236 # type: (SchemeEditWidget, Node, Node, Any, Any) -> None 1237 super().__init__(document, *args, **kwargs) 1238 self.source_node = source_node 1239 self.sink_node = sink_node 1240 1241 def edit_links(self, initial_links=None): 1242 # type: (Optional[List[OIPair]]) -> None 1243 """ 1244 Show and execute the `EditLinksDialog`. 1245 Optional `initial_links` list can provide a list of initial 1246 `(source, sink)` channel tuples to show in the view, otherwise 1247 the dialog is populated with existing links in the scheme (passing 1248 an empty list will disable all initial links). 1249 1250 """ 1251 log.info("Constructing a Link Editor dialog.") 1252 1253 dlg = EditLinksDialog(self.document, windowTitle="Edit Links") 1254 1255 links = self.scheme.find_links(source_node=self.source_node, 1256 sink_node=self.sink_node) 1257 existing_links = [(link.source_channel, link.sink_channel) 1258 for link in links] 1259 1260 if initial_links is None: 1261 initial_links = list(existing_links) 1262 1263 dlg.setNodes(self.source_node, self.sink_node) 1264 dlg.setLinks(initial_links) 1265 1266 log.info("Executing a Link Editor Dialog.") 1267 rval = dlg.exec_() 1268 1269 if rval == EditLinksDialog.Accepted: 1270 links_spec = dlg.links() 1271 1272 links_to_add = set(links_spec) - set(existing_links) 1273 links_to_remove = set(existing_links) - set(links_spec) 1274 1275 stack = self.document.undoStack() 1276 stack.beginMacro("Edit Links") 1277 1278 # First remove links into a 'Single' sink channel, 1279 # but only the ones that do not have self.source_node as 1280 # a source (they will be removed later from links_to_remove) 1281 for _, sink_channel in links_to_add: 1282 if sink_channel.single: 1283 existing = self.scheme.find_links( 1284 sink_node=self.sink_node, 1285 sink_channel=sink_channel 1286 ) 1287 1288 existing = [link for link in existing 1289 if link.source_node is not self.source_node] 1290 1291 if existing: 1292 assert len(existing) == 1 1293 self.document.removeLink(existing[0]) 1294 1295 for source_channel, sink_channel in links_to_remove: 1296 links = self.scheme.find_links(source_node=self.source_node, 1297 source_channel=source_channel, 1298 sink_node=self.sink_node, 1299 sink_channel=sink_channel) 1300 assert len(links) == 1 1301 self.document.removeLink(links[0]) 1302 1303 for source_channel, sink_channel in links_to_add: 1304 link = scheme.SchemeLink(self.source_node, source_channel, 1305 self.sink_node, sink_channel) 1306 1307 self.document.addLink(link) 1308 1309 stack.endMacro() 1310 1311 1312def point_to_tuple(point): 1313 # type: (QPointF) -> Tuple[float, float] 1314 """ 1315 Convert a QPointF into a (x, y) tuple. 1316 """ 1317 return (point.x(), point.y()) 1318 1319 1320class NewArrowAnnotation(UserInteraction): 1321 """ 1322 Create a new arrow annotation handler. 1323 """ 1324 def __init__(self, document, *args, **kwargs): 1325 # type: (SchemeEditWidget, Any, Any) -> None 1326 super().__init__(document, *args, **kwargs) 1327 self.down_pos = None # type: Optional[QPointF] 1328 self.arrow_item = None # type: Optional[items.ArrowAnnotation] 1329 self.annotation = None # type: Optional[scheme.SchemeArrowAnnotation] 1330 self.color = "red" 1331 self.cancelOnEsc = True 1332 1333 def start(self): 1334 # type: () -> None 1335 self.document.view().setCursor(Qt.CrossCursor) 1336 1337 helpevent = QuickHelpTipEvent( 1338 self.tr("Click and drag to create a new arrow"), 1339 self.tr('<h3>New arrow annotation</h3>' 1340 '<p>Click and drag to create a new arrow annotation</p>' 1341# '<a href="help://orange-canvas/arrow-annotations>' 1342# 'More ...</a>' 1343 ) 1344 ) 1345 QCoreApplication.postEvent(self.document, helpevent) 1346 1347 super().start() 1348 1349 def setColor(self, color): 1350 """ 1351 Set the color for the new arrow. 1352 """ 1353 self.color = color 1354 1355 def mousePressEvent(self, event): 1356 # type: (QGraphicsSceneMouseEvent) -> bool 1357 if event.button() == Qt.LeftButton: 1358 self.down_pos = event.scenePos() 1359 event.accept() 1360 return True 1361 else: 1362 return super().mousePressEvent(event) 1363 1364 def mouseMoveEvent(self, event): 1365 # type: (QGraphicsSceneMouseEvent) -> bool 1366 if event.buttons() & Qt.LeftButton: 1367 assert self.down_pos is not None 1368 if self.arrow_item is None and \ 1369 (self.down_pos - event.scenePos()).manhattanLength() > \ 1370 QApplication.instance().startDragDistance(): 1371 1372 annot = scheme.SchemeArrowAnnotation( 1373 point_to_tuple(self.down_pos), 1374 point_to_tuple(event.scenePos()) 1375 ) 1376 annot.set_color(self.color) 1377 item = self.scene.add_annotation(annot) 1378 1379 self.arrow_item = item 1380 self.annotation = annot 1381 1382 if self.arrow_item is not None: 1383 p1, p2 = map(self.arrow_item.mapFromScene, 1384 (self.down_pos, event.scenePos())) 1385 self.arrow_item.setLine(QLineF(p1, p2)) 1386 1387 event.accept() 1388 return True 1389 else: 1390 return super().mouseMoveEvent(event) 1391 1392 def mouseReleaseEvent(self, event): 1393 # type: (QGraphicsSceneMouseEvent) -> bool 1394 if event.button() == Qt.LeftButton: 1395 if self.arrow_item is not None: 1396 assert self.down_pos is not None and self.annotation is not None 1397 p1, p2 = self.down_pos, event.scenePos() 1398 # Commit the annotation to the scheme 1399 self.annotation.set_line(point_to_tuple(p1), 1400 point_to_tuple(p2)) 1401 1402 self.document.addAnnotation(self.annotation) 1403 1404 p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2)) 1405 self.arrow_item.setLine(QLineF(p1, p2)) 1406 1407 self.end() 1408 return True 1409 else: 1410 return super().mouseReleaseEvent(event) 1411 1412 def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None 1413 if self.arrow_item is not None: 1414 self.scene.removeItem(self.arrow_item) 1415 self.arrow_item = None 1416 super().cancel(reason) 1417 1418 def end(self): 1419 # type: () -> None 1420 self.down_pos = None 1421 self.arrow_item = None 1422 self.annotation = None 1423 self.document.view().setCursor(Qt.ArrowCursor) 1424 1425 # Clear the help tip 1426 helpevent = QuickHelpTipEvent("", "") 1427 QCoreApplication.postEvent(self.document, helpevent) 1428 1429 super().end() 1430 1431 1432def rect_to_tuple(rect): 1433 # type: (QRectF) -> Tuple[float, float, float, float] 1434 """ 1435 Convert a QRectF into a (x, y, width, height) tuple. 1436 """ 1437 return rect.x(), rect.y(), rect.width(), rect.height() 1438 1439 1440class NewTextAnnotation(UserInteraction): 1441 """ 1442 A New Text Annotation interaction handler 1443 """ 1444 def __init__(self, document, *args, **kwargs): 1445 # type: (SchemeEditWidget, Any, Any) -> None 1446 super().__init__(document, *args, **kwargs) 1447 self.down_pos = None # type: Optional[QPointF] 1448 self.annotation_item = None # type: Optional[items.TextAnnotation] 1449 self.annotation = None # type: Optional[scheme.SchemeTextAnnotation] 1450 self.control = None # type: Optional[controlpoints.ControlPointRect] 1451 self.font = document.font() # type: QFont 1452 self.cancelOnEsc = True 1453 1454 def setFont(self, font): 1455 # type: (QFont) -> None 1456 self.font = QFont(font) 1457 1458 def start(self): 1459 # type: () -> None 1460 self.document.view().setCursor(Qt.CrossCursor) 1461 1462 helpevent = QuickHelpTipEvent( 1463 self.tr("Click to create a new text annotation"), 1464 self.tr('<h3>New text annotation</h3>' 1465 '<p>Click (and drag to resize) on the canvas to create ' 1466 'a new text annotation item.</p>' 1467# '<a href="help://orange-canvas/text-annotations">' 1468# 'More ...</a>' 1469 ) 1470 ) 1471 QCoreApplication.postEvent(self.document, helpevent) 1472 1473 super().start() 1474 1475 def createNewAnnotation(self, rect): 1476 # type: (QRectF) -> None 1477 """ 1478 Create a new TextAnnotation at with `rect` as the geometry. 1479 """ 1480 annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect)) 1481 font = {"family": self.font.family(), 1482 "size": self.font.pixelSize()} 1483 annot.set_font(font) 1484 1485 item = self.scene.add_annotation(annot) 1486 item.setTextInteractionFlags(Qt.TextEditorInteraction) 1487 item.setFramePen(QPen(Qt.DashLine)) 1488 1489 self.annotation_item = item 1490 self.annotation = annot 1491 self.control = controlpoints.ControlPointRect() 1492 self.control.rectChanged.connect(item.setGeometry) 1493 self.scene.addItem(self.control) 1494 1495 def mousePressEvent(self, event): 1496 # type: (QGraphicsSceneMouseEvent) -> bool 1497 if event.button() == Qt.LeftButton: 1498 self.down_pos = event.scenePos() 1499 return True 1500 return super().mousePressEvent(event) 1501 1502 def mouseMoveEvent(self, event): 1503 # type: (QGraphicsSceneMouseEvent) -> bool 1504 if event.buttons() & Qt.LeftButton: 1505 assert self.down_pos is not None 1506 if self.annotation_item is None and \ 1507 (self.down_pos - event.scenePos()).manhattanLength() > \ 1508 QApplication.instance().startDragDistance(): 1509 rect = QRectF(self.down_pos, event.scenePos()).normalized() 1510 self.createNewAnnotation(rect) 1511 1512 if self.annotation_item is not None: 1513 assert self.control is not None 1514 rect = QRectF(self.down_pos, event.scenePos()).normalized() 1515 self.control.setRect(rect) 1516 return True 1517 return super().mouseMoveEvent(event) 1518 1519 def mouseReleaseEvent(self, event): 1520 # type: (QGraphicsSceneMouseEvent) -> bool 1521 if event.button() == Qt.LeftButton: 1522 if self.annotation_item is None: 1523 self.createNewAnnotation(QRectF(event.scenePos(), 1524 event.scenePos())) 1525 rect = self.defaultTextGeometry(event.scenePos()) 1526 else: 1527 assert self.down_pos is not None 1528 rect = QRectF(self.down_pos, event.scenePos()).normalized() 1529 assert self.annotation_item is not None 1530 assert self.control is not None 1531 assert self.annotation is not None 1532 # Commit the annotation to the scheme. 1533 self.annotation.rect = rect_to_tuple(rect) 1534 1535 self.document.addAnnotation(self.annotation) 1536 1537 self.annotation_item.setGeometry(rect) 1538 1539 self.control.rectChanged.disconnect( 1540 self.annotation_item.setGeometry 1541 ) 1542 self.control.hide() 1543 1544 # Move the focus to the editor. 1545 self.annotation_item.setFramePen(QPen(Qt.NoPen)) 1546 self.annotation_item.setFocus(Qt.OtherFocusReason) 1547 self.annotation_item.startEdit() 1548 1549 self.end() 1550 return True 1551 return super().mouseMoveEvent(event) 1552 1553 def defaultTextGeometry(self, point): 1554 # type: (QPointF) -> QRectF 1555 """ 1556 Return the default text geometry. Used in case the user single 1557 clicked in the scene. 1558 """ 1559 assert self.annotation_item is not None 1560 font = self.annotation_item.font() 1561 metrics = QFontMetrics(font) 1562 spacing = metrics.lineSpacing() 1563 margin = self.annotation_item.document().documentMargin() 1564 1565 rect = QRectF(QPointF(point.x(), point.y() - spacing - margin), 1566 QSizeF(150, spacing + 2 * margin)) 1567 return rect 1568 1569 def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None 1570 if self.annotation_item is not None: 1571 self.annotation_item.clearFocus() 1572 self.scene.removeItem(self.annotation_item) 1573 self.annotation_item = None 1574 super().cancel(reason) 1575 1576 def end(self): 1577 # type: () -> None 1578 if self.control is not None: 1579 self.scene.removeItem(self.control) 1580 1581 self.control = None 1582 self.down_pos = None 1583 self.annotation_item = None 1584 self.annotation = None 1585 self.document.view().setCursor(Qt.ArrowCursor) 1586 1587 # Clear the help tip 1588 helpevent = QuickHelpTipEvent("", "") 1589 QCoreApplication.postEvent(self.document, helpevent) 1590 1591 super().end() 1592 1593 1594class ResizeTextAnnotation(UserInteraction): 1595 """ 1596 Resize a Text Annotation interaction handler. 1597 """ 1598 def __init__(self, document, *args, **kwargs): 1599 # type: (SchemeEditWidget, Any, Any) -> None 1600 super().__init__(document, *args, **kwargs) 1601 self.item = None # type: Optional[items.TextAnnotation] 1602 self.annotation = None # type: Optional[scheme.SchemeTextAnnotation] 1603 self.control = None # type: Optional[controlpoints.ControlPointRect] 1604 self.savedFramePen = None # type: Optional[QPen] 1605 self.savedRect = None # type: Optional[QRectF] 1606 1607 def mousePressEvent(self, event): 1608 # type: (QGraphicsSceneMouseEvent) -> bool 1609 pos = event.scenePos() 1610 if event.button() & Qt.LeftButton and self.item is None: 1611 item = self.scene.item_at(pos, items.TextAnnotation) 1612 if item is not None and not item.hasFocus(): 1613 self.editItem(item) 1614 return False 1615 return super().mousePressEvent(event) 1616 1617 def editItem(self, item): 1618 # type: (items.TextAnnotation) -> None 1619 annotation = self.scene.annotation_for_item(item) 1620 rect = item.geometry() # TODO: map to scene if item has a parent. 1621 control = controlpoints.ControlPointRect(rect=rect) 1622 self.scene.addItem(control) 1623 1624 self.savedFramePen = item.framePen() 1625 self.savedRect = rect 1626 1627 control.rectEdited.connect(item.setGeometry) 1628 control.setFocusProxy(item) 1629 1630 item.setFramePen(QPen(Qt.DashDotLine)) 1631 item.geometryChanged.connect(self.__on_textGeometryChanged) 1632 1633 self.item = item 1634 1635 self.annotation = annotation 1636 self.control = control 1637 1638 def commit(self): 1639 # type: () -> None 1640 """ 1641 Commit the current item geometry state to the document. 1642 """ 1643 if self.item is None: 1644 return 1645 rect = self.item.geometry() 1646 if self.savedRect != rect: 1647 command = commands.SetAttrCommand( 1648 self.annotation, "rect", 1649 (rect.x(), rect.y(), rect.width(), rect.height()), 1650 name="Edit text geometry" 1651 ) 1652 self.document.undoStack().push(command) 1653 self.savedRect = rect 1654 1655 def __on_editingFinished(self): 1656 # type: () -> None 1657 self.commit() 1658 self.end() 1659 1660 def __on_rectEdited(self, rect): 1661 # type: (QRectF) -> None 1662 assert self.item is not None 1663 self.item.setGeometry(rect) 1664 1665 def __on_textGeometryChanged(self): 1666 # type: () -> None 1667 assert self.control is not None and self.item is not None 1668 if not self.control.isControlActive(): 1669 rect = self.item.geometry() 1670 self.control.setRect(rect) 1671 1672 def cancel(self, reason=UserInteraction.OtherReason): 1673 # type: (int) -> None 1674 log.debug("ResizeTextAnnotation.cancel(%s)", reason) 1675 if self.item is not None and self.savedRect is not None: 1676 self.item.setGeometry(self.savedRect) 1677 super().cancel(reason) 1678 1679 def end(self): 1680 # type: () -> None 1681 if self.control is not None: 1682 self.scene.removeItem(self.control) 1683 1684 if self.item is not None and self.savedFramePen is not None: 1685 self.item.setFramePen(self.savedFramePen) 1686 1687 self.item = None 1688 self.annotation = None 1689 self.control = None 1690 1691 super().end() 1692 1693 1694class ResizeArrowAnnotation(UserInteraction): 1695 """ 1696 Resize an Arrow Annotation interaction handler. 1697 """ 1698 def __init__(self, document, *args, **kwargs): 1699 # type: (SchemeEditWidget, Any, Any) -> None 1700 super().__init__(document, *args, **kwargs) 1701 self.item = None # type: Optional[items.ArrowAnnotation] 1702 self.annotation = None # type: Optional[scheme.SchemeArrowAnnotation] 1703 self.control = None # type: Optional[controlpoints.ControlPointLine] 1704 self.savedLine = None # type: Optional[QLineF] 1705 1706 def mousePressEvent(self, event): 1707 # type: (QGraphicsSceneMouseEvent) -> bool 1708 pos = event.scenePos() 1709 if self.item is None: 1710 item = self.scene.item_at(pos, items.ArrowAnnotation) 1711 if item is not None and not item.hasFocus(): 1712 self.editItem(item) 1713 return False 1714 1715 return super().mousePressEvent(event) 1716 1717 def editItem(self, item): 1718 # type: (items.ArrowAnnotation) -> None 1719 annotation = self.scene.annotation_for_item(item) 1720 control = controlpoints.ControlPointLine() 1721 self.scene.addItem(control) 1722 1723 line = item.line() 1724 self.savedLine = line 1725 1726 p1, p2 = map(item.mapToScene, (line.p1(), line.p2())) 1727 1728 control.setLine(QLineF(p1, p2)) 1729 control.setFocusProxy(item) 1730 control.lineEdited.connect(self.__on_lineEdited) 1731 1732 item.geometryChanged.connect(self.__on_lineGeometryChanged) 1733 1734 self.item = item 1735 self.annotation = annotation 1736 self.control = control 1737 1738 def commit(self): 1739 # type: () -> None 1740 """Commit the current geometry of the item to the document. 1741 1742 Does nothing if the actual geometry has not changed. 1743 """ 1744 if self.control is None or self.item is None: 1745 return 1746 line = self.control.line() 1747 p1, p2 = line.p1(), line.p2() 1748 1749 if self.item.line() != self.savedLine: 1750 command = commands.SetAttrCommand( 1751 self.annotation, 1752 "geometry", 1753 ((p1.x(), p1.y()), (p2.x(), p2.y())), 1754 name="Edit arrow geometry", 1755 ) 1756 self.document.undoStack().push(command) 1757 self.savedLine = self.item.line() 1758 1759 def __on_editingFinished(self): 1760 # type: () -> None 1761 self.commit() 1762 self.end() 1763 1764 def __on_lineEdited(self, line): 1765 # type: (QLineF) -> None 1766 if self.item is not None: 1767 p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2())) 1768 self.item.setLine(QLineF(p1, p2)) 1769 1770 def __on_lineGeometryChanged(self): 1771 # type: () -> None 1772 # Possible geometry change from out of our control, for instance 1773 # item move as a part of a selection group. 1774 assert self.control is not None and self.item is not None 1775 if not self.control.isControlActive(): 1776 assert self.item is not None 1777 line = self.item.line() 1778 p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2())) 1779 self.control.setLine(QLineF(p1, p2)) 1780 1781 def cancel(self, reason=UserInteraction.OtherReason): 1782 # type: (int) -> None 1783 log.debug("ResizeArrowAnnotation.cancel(%s)", reason) 1784 if self.item is not None and self.savedLine is not None: 1785 self.item.setLine(self.savedLine) 1786 1787 super().cancel(reason) 1788 1789 def end(self): 1790 # type: () -> None 1791 if self.control is not None: 1792 self.scene.removeItem(self.control) 1793 1794 if self.item is not None: 1795 self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged) 1796 1797 self.control = None 1798 self.item = None 1799 self.annotation = None 1800 1801 super().end() 1802 1803 1804class DropHandler(abc.ABC): 1805 """ 1806 An abstract drop handler. 1807 1808 .. versionadded:: 0.1.20 1809 """ 1810 @abc.abstractmethod 1811 def accepts(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: 1812 """ 1813 Returns True if a `document` can accept a drop of the data from `event`. 1814 """ 1815 return False 1816 1817 @abc.abstractmethod 1818 def doDrop(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: 1819 """ 1820 Complete the drop of data from `event` onto the `document`. 1821 """ 1822 return False 1823 1824 1825class DropHandlerAction(abc.ABC): 1826 @abc.abstractmethod 1827 def actionFromDropEvent( 1828 self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' 1829 ) -> QAction: 1830 """ 1831 Create and return an QAction representing a drop action. 1832 1833 This action is used to disambiguate between possible drop actions. 1834 1835 The action can have sub menus, however all actions in submenus **must** 1836 have the `DropHandler` instance set as their `QAction.data()`. 1837 1838 The actions **must not** execute the actual drop from their triggered 1839 slot connections. The drop will be dispatched to the `action.data()` 1840 handler's `doDrop()` after that action is triggered and the menu is 1841 closed. 1842 """ 1843 raise NotImplementedError 1844 1845 1846class NodeFromMimeDataDropHandler(DropHandler, DropHandlerAction): 1847 """ 1848 Create a new node from dropped mime data. 1849 1850 Subclasses must override `canDropMimeData`, `parametersFromMimeData`, 1851 and `qualifiedName`. 1852 1853 .. versionadded:: 0.1.20 1854 """ 1855 @abc.abstractmethod 1856 def qualifiedName(self) -> str: 1857 """ 1858 The qualified name for the node created by this handler. The handler 1859 will not be invoked if this name does not appear in the registry 1860 associated with the workflow. 1861 """ 1862 raise NotImplementedError 1863 1864 @abc.abstractmethod 1865 def canDropMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> bool: 1866 """ 1867 Can the handler create a node from the drop mime data. 1868 1869 Reimplement this in a subclass to check if the `data` has appropriate 1870 format. 1871 """ 1872 raise NotImplementedError 1873 1874 @abc.abstractmethod 1875 def parametersFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Dict[str, Any]': 1876 """ 1877 Return the node parameters based from the drop mime data. 1878 """ 1879 raise NotImplementedError 1880 1881 def accepts(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: 1882 """Reimplemented.""" 1883 reg = document.registry() 1884 if not reg.has_widget(self.qualifiedName()): 1885 return False 1886 return self.canDropMimeData(document, event.mimeData()) 1887 1888 def nodeFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Node': 1889 reg = document.registry() 1890 wd = reg.widget(self.qualifiedName()) 1891 node = document.newNodeHelper(wd) 1892 parameters = self.parametersFromMimeData(document, data) 1893 node.properties = parameters 1894 return node 1895 1896 def doDrop(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: 1897 """Reimplemented.""" 1898 reg = document.registry() 1899 if not reg.has_widget(self.qualifiedName()): 1900 return False 1901 node = self.nodeFromMimeData(document, event.mimeData()) 1902 node.position = (event.scenePos().x(), event.scenePos().y()) 1903 activate = self.shouldActivateNode() 1904 wd = document.widgetManager() 1905 if activate and wd is not None: 1906 def activate(node_, widget): 1907 if node_ is node: 1908 try: 1909 self.activateNode(document, node, widget) 1910 finally: 1911 # self-disconnect the slot 1912 wd.widget_for_node_added.disconnect(activate) 1913 wd.widget_for_node_added.connect(activate) 1914 document.addNode(node) 1915 if activate: 1916 QApplication.postEvent(node, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) 1917 return True 1918 1919 def shouldActivateNode(self) -> bool: 1920 """ 1921 Should the new dropped node activate (open GUI controller) immediately. 1922 1923 If this method returns `True` then the `activateNode` method will be 1924 called after the node has been added and the GUI controller created. 1925 1926 The default implementation returns False. 1927 """ 1928 return False 1929 1930 def activateNode(self, document: 'SchemeEditWidget', node: 'Node', widget: 'QWidget') -> None: 1931 """ 1932 Activate (open) the `node`'s GUI controller `widget` after a 1933 completed drop. 1934 1935 Reimplement this if the node requires further configuration via the 1936 GUI. 1937 1938 The default implementation delegates to the :class:`WidgetManager` 1939 associated with the document. 1940 """ 1941 wd = document.widgetManager() 1942 if wd is not None: 1943 wd.activate_widget_for_node(node, widget) 1944 else: 1945 widget.show() 1946 1947 def actionFromDropEvent( 1948 self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' 1949 ) -> QAction: 1950 """Reimplemented.""" 1951 reg = document.registry() 1952 ac = QAction(None) 1953 ac.setData(self) 1954 if reg is not None: 1955 desc = reg.widget(self.qualifiedName()) 1956 ac.setText(desc.name) 1957 ac.setToolTip(tooltip_helper(desc)) 1958 ac.setWhatsThis(whats_this_helper(desc)) 1959 else: 1960 ac.setText(f"{self.qualifiedName()}") 1961 ac.setEnabled(False) 1962 ac.setVisible(False) 1963 return ac 1964 1965 1966def load_entry_point( 1967 ep: EntryPoint, log: logging.Logger = None, 1968) -> Tuple['EntryPoint', Any]: 1969 if log is None: 1970 log = logging.getLogger(__name__) 1971 try: 1972 value = ep.load() 1973 except (ImportError, AttributeError): 1974 log.exception("Could not load %s", ep) 1975 except Exception: # noqa 1976 log.exception("Unexpected Error; %s will be skipped", ep) 1977 else: 1978 return ep, value 1979 1980 1981def iter_load_entry_points( 1982 iter: Iterable[EntryPoint], log: logging.Logger = None, 1983): 1984 if log is None: 1985 log = logging.getLogger(__name__) 1986 for ep in iter: 1987 try: 1988 ep, value = load_entry_point(ep, log) 1989 except Exception: 1990 pass 1991 else: 1992 yield ep, value 1993 1994 1995class PluginDropHandler(DropHandler): 1996 """ 1997 Delegate drop event processing to plugin drop handlers. 1998 1999 .. versionadded:: 0.1.20 2000 """ 2001 #: The default entry point group 2002 ENTRY_POINT = "orangecanvas.document.interactions.DropHandler" 2003 2004 def __init__(self, group=ENTRY_POINT, **kwargs): 2005 super().__init__(**kwargs) 2006 self.__group = group 2007 2008 def iterEntryPoints(self) -> Iterable['EntryPoint']: 2009 """ 2010 Return an iterator over all entry points. 2011 """ 2012 return entry_points().get(self.__group, []) 2013 2014 __entryPoints = None 2015 2016 def entryPoints(self) -> Iterable[Tuple['EntryPoint', 'DropHandler']]: 2017 """ 2018 Return an iterator over entry points and instantiated drop handlers. 2019 """ 2020 eps = [] 2021 if self.__entryPoints: 2022 ep_iter = self.__entryPoints 2023 store_eps = lambda ep, value: None 2024 else: 2025 ep_iter = self.iterEntryPoints() 2026 ep_iter = iter_load_entry_points(ep_iter, log) 2027 store_eps = lambda ep, value: eps.append((ep, value)) 2028 2029 for ep, value in ep_iter: 2030 if not issubclass(value, DropHandler): 2031 log.error( 2032 f"{ep} yielded {type(value)}, expected a " 2033 f"{DropHandler} subtype" 2034 ) 2035 continue 2036 try: 2037 handler = value() 2038 except Exception: # noqa 2039 log.exception("Error in default constructor of %s", value) 2040 else: 2041 yield ep, handler 2042 store_eps(ep, value) 2043 2044 self.__entryPoints = tuple(eps) 2045 2046 __accepts: Sequence[Tuple[EntryPoint, DropHandler]] = () 2047 2048 def accepts(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: 2049 """ 2050 Reimplemented. 2051 2052 Accept the event if any plugin handlers accept the event. 2053 """ 2054 accepts = [] 2055 self.__accepts = () 2056 for ep, handler in self.entryPoints(): 2057 if handler.accepts(document, event): 2058 accepts.append((ep, handler)) 2059 self.__accepts = tuple(accepts) 2060 return bool(accepts) 2061 2062 def doDrop( 2063 self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' 2064 ) -> bool: 2065 """ 2066 Reimplemented. 2067 2068 Dispatch the drop to the plugin handler that accepted the event. 2069 In case there are multiple handlers that accepted the event, a menu 2070 is used to select the handler. 2071 """ 2072 handler: Optional[DropHandler] = None 2073 if len(self.__accepts) == 1: 2074 ep, handler = self.__accepts[0] 2075 elif len(self.__accepts) > 1: 2076 menu = QMenu(event.widget()) 2077 for ep_, handler_ in self.__accepts: 2078 ac = action_for_handler(handler_, document, event) 2079 if ac is None: 2080 ac = menu.addAction(ep_.name, ) 2081 else: 2082 menu.addAction(ac) 2083 ac.setParent(menu) 2084 if not ac.toolTip(): 2085 ac.setToolTip(f"{ep_.name} ({ep_.module_name})") 2086 ac.setData(handler_) 2087 action = menu.exec(event.screenPos()) 2088 if action is not None: 2089 handler = action.data() 2090 if handler is None: 2091 return False 2092 return handler.doDrop(document, event) 2093 2094 2095def action_for_handler(handler: DropHandler, document, event) -> Optional[QAction]: 2096 if isinstance(handler, DropHandlerAction): 2097 return handler.actionFromDropEvent(document, event) 2098 else: 2099 return None 2100 2101 2102class DropAction(UserInteraction): 2103 """ 2104 A drop action on the workflow. 2105 """ 2106 def __init__( 2107 self, document, *args, dropHandlers: Sequence[DropHandler] = (), 2108 **kwargs 2109 ) -> None: 2110 super().__init__(document, *args, **kwargs) 2111 self.__designatedAction: Optional[DropHandler] = None 2112 self.__dropHandlers = dropHandlers 2113 2114 def dropHandlers(self) -> Iterable[DropHandler]: 2115 """Return an iterable over drop handlers.""" 2116 return iter(self.__dropHandlers) 2117 2118 def canHandleDrop(self, event: 'QGraphicsSceneDragDropEvent') -> bool: 2119 """ 2120 Can this interactions handle the drop `event`. 2121 2122 The default implementation checks each `dropHandler` if it 2123 :func:`~DropHandler.accepts` the event. The first such handler that 2124 accepts is selected to be the designated handler and will receive 2125 the drop (:func:`~DropHandler.doDrop`). 2126 """ 2127 for ep in self.dropHandlers(): 2128 if ep.accepts(self.document, event): 2129 self.__designatedAction = ep 2130 return True 2131 else: 2132 return False 2133 2134 def dragEnterEvent(self, event): 2135 if self.canHandleDrop(event): 2136 event.acceptProposedAction() 2137 return True 2138 else: 2139 return False 2140 2141 def dragMoveEvent(self, event): 2142 if self.canHandleDrop(event): 2143 event.acceptProposedAction() 2144 return True 2145 else: 2146 return False 2147 2148 def dragLeaveEvent(self, even): 2149 self.__designatedAction = None 2150 self.end() 2151 return False 2152 2153 def dropEvent(self, event): 2154 if self.__designatedAction is not None: 2155 try: 2156 res = self.__designatedAction.doDrop(self.document, event) 2157 except Exception: # noqa 2158 log.exception("") 2159 res = False 2160 if res: 2161 event.acceptProposedAction() 2162 self.end() 2163 return True 2164 else: 2165 self.end() 2166 return False 2167