From 93bfdbdd35be263204152fdd106e53fe0f1fa772 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 12 Aug 2016 12:14:01 +0200 Subject: [PATCH 1/5] canvas/annotations: Add support for inline text markup editing --- Orange/canvas/canvas/items/annotationitem.py | 200 ++++++++++++++++--- Orange/canvas/canvas/scene.py | 18 +- Orange/canvas/document/commands.py | 14 +- Orange/canvas/document/interactions.py | 10 +- Orange/canvas/document/schemeedit.py | 73 +++---- Orange/canvas/scheme/annotations.py | 60 +++++- Orange/canvas/scheme/readwrite.py | 36 ++-- conda-recipe/meta.yaml | 1 + requirements-gui.txt | 2 + scripts/macos/requirements.txt | 1 + 10 files changed, 310 insertions(+), 105 deletions(-) diff --git a/Orange/canvas/canvas/items/annotationitem.py b/Orange/canvas/canvas/items/annotationitem.py index ed07ab496af..1446f6adf73 100644 --- a/Orange/canvas/canvas/items/annotationitem.py +++ b/Orange/canvas/canvas/items/annotationitem.py @@ -1,17 +1,24 @@ import logging +from collections import OrderedDict +from xml.sax.saxutils import escape + +import docutils.core +import CommonMark from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsPathItem, QGraphicsWidget, QGraphicsTextItem, - QGraphicsDropShadowEffect + QGraphicsDropShadowEffect, QMenu ) from AnyQt.QtGui import ( QPainterPath, QPainterPathStroker, QPolygonF, QColor, QPen ) from AnyQt.QtCore import ( - Qt, QPointF, QSizeF, QRectF, QLineF, QEvent, QT_VERSION + Qt, QPointF, QSizeF, QRectF, QLineF, QEvent, QMetaObject, QT_VERSION +) +from AnyQt.QtCore import ( + pyqtSignal as Signal, pyqtProperty as Property, pyqtSlot as Slot ) -from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property log = logging.getLogger(__name__) @@ -42,7 +49,7 @@ class GraphicsTextEdit(QGraphicsTextItem): """ def __init__(self, *args, **kwargs): QGraphicsTextItem.__init__(self, *args, **kwargs) - + self.setAcceptHoverEvents(True) self.__placeholderText = "" def setPlaceholderText(self, text): @@ -84,15 +91,102 @@ def paint(self, painter, option, widget=None): painter.drawText(brect, Qt.AlignTop | Qt.AlignLeft, text) +def render_plain(content): + """ + Return a html fragment for a plain pre-formatted text + + Parameters + ---------- + content : str + Plain text content + + Returns + ------- + html : str + """ + return '

' + escape(content) + "

" + + +def render_html(content): + """ + Return a html fragment unchanged. + + Parameters + ---------- + content : str + Html text. + + Returns + ------- + html : str + """ + return content + + +def render_markdown(content): + """ + Return a html fragment from markdown text content + + Parameters + ---------- + content : str + A markdown formatted text + + Returns + ------- + html : str + """ + return CommonMark.commonmark(content) + + +def render_rst(content): + """ + Return a html fragment from a RST text content + + Parameters + ---------- + content : str + A RST formatted text content + + Returns + ------- + html : str + """ + overrides = { + "report_level": 10, # suppress errors from appearing in the html + "output-encoding": "utf-8" + } + html = docutils.core.publish_string( + content, writer_name="html", + settings_overrides=overrides + ) + return html.decode("utf-8") + + class TextAnnotation(Annotation): - """Text annotation item for the canvas scheme. + """ + Text annotation item for the canvas scheme. + Text interaction (if enabled) is started by double clicking the item. """ + #: Emitted when the editing is finished (i.e. the item loses edit focus). editingFinished = Signal() - """Emitted when the editing is finished (i.e. the item loses focus).""" + #: Emitted when the text content changes on user interaction. textEdited = Signal() - """Emitted when the edited text changes.""" + + #: Emitted when the text annotation's contents change + #: (`content` or `contentType` changed) + contentChanged = Signal() + + #: Mapping of supported content types to corresponding + #: content -> html transformer. + ContentRenderer = OrderedDict([ + ("text/plain", render_plain), + ("text/rst", render_rst), + ("text/markdown", render_markdown), + ("text/html", render_html), + ]) # type: Dict[str, Callable[[str], [str]]] def __init__(self, parent=None, **kwargs): Annotation.__init__(self, parent, **kwargs) @@ -101,7 +195,14 @@ def __init__(self, parent=None, **kwargs): self.setFocusPolicy(Qt.ClickFocus) + self.__contentType = "text/plain" + self.__content = "" + self.__renderer = render_plain + self.__textMargins = (2, 2, 2, 2) + self.__textInteractionFlags = Qt.NoTextInteraction + self.__defaultInteractionFlags = ( + Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard) rect = self.geometry().translated(-self.pos()) self.__framePen = QPen(Qt.NoPen) @@ -109,13 +210,13 @@ def __init__(self, parent=None, **kwargs): self.__framePathItem.setPen(self.__framePen) self.__textItem = GraphicsTextEdit(self) + self.__textItem.setOpenExternalLinks(True) self.__textItem.setPlaceholderText(self.tr("Enter text here")) self.__textItem.setPos(2, 2) self.__textItem.setTextWidth(rect.width() - 4) self.__textItem.setTabChangesFocus(True) - self.__textItem.setTextInteractionFlags(Qt.NoTextInteraction) + self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) self.__textItem.setFont(self.font()) - self.__textInteractionFlags = Qt.NoTextInteraction layout = self.__textItem.document().documentLayout() layout.documentSizeChanged.connect(self.__onDocumentSizeChanged) @@ -163,18 +264,31 @@ def __updateFrameStyle(self): self.__framePathItem.setPen(pen) + def contentType(self): + return self.__contentType + + def setContent(self, content, contentType="text/plain"): + if self.__content != content or self.__contentType != contentType: + self.__contentType = contentType + self.__content = content + self.__updateRenderedContent() + self.contentChanged.emit() + + def content(self): + return self.__content + def setPlainText(self, text): - """Set the annotation plain text. + """Set the annotation text as plain text. """ - self.__textItem.setPlainText(text) + self.setContent(text, "text/plain") def toPlainText(self): return self.__textItem.toPlainText() def setHtml(self, text): - """Set the annotation rich text. + """Set the annotation text as html. """ - self.__textItem.setHtml(text) + self.setContent(text, "text/html") def toHtml(self): return self.__textItem.toHtml() @@ -233,6 +347,7 @@ def mouseDoubleClickEvent(self, event): def startEdit(self): """Start the annotation text edit process. """ + self.__textItem.setPlainText(self.__content) self.__textItem.setTextInteractionFlags(self.__textInteractionFlags) self.__textItem.setFocus(Qt.MouseFocusReason) @@ -245,7 +360,9 @@ def startEdit(self): def endEdit(self): """End the annotation edit. """ - self.__textItem.setTextInteractionFlags(Qt.NoTextInteraction) + content = self.__textItem.toPlainText() + + self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) self.__textItem.removeSceneEventFilter(self) self.__textItem.document().contentsChanged.disconnect( self.textEdited @@ -253,20 +370,23 @@ def endEdit(self): cursor = self.__textItem.textCursor() cursor.clearSelection() self.__textItem.setTextCursor(cursor) + self.__content = content + self.editingFinished.emit() + # Cannot change the textItem's html immediately, this method is + # invoked from it. + # TODO: Separate the editor from the view. + QMetaObject.invokeMethod( + self, "__updateRenderedContent", Qt.QueuedConnection) def __onDocumentSizeChanged(self, size): # The size of the text document has changed. Expand the text # control rect's height if the text no longer fits inside. - try: - rect = self.geometry() - _, top, _, bottom = self.textMargins() - if rect.height() < (size.height() + bottom + top): - rect.setHeight(size.height() + bottom + top) - self.setGeometry(rect) - except Exception: - log.error("error in __onDocumentSizeChanged", - exc_info=True) + rect = self.geometry() + _, top, _, bottom = self.textMargins() + if rect.height() < (size.height() + bottom + top): + rect.setHeight(size.height() + bottom + top) + self.setGeometry(rect) def __updateFrame(self): rect = self.geometry() @@ -283,8 +403,11 @@ def resizeEvent(self, event): QGraphicsWidget.resizeEvent(self, event) def sceneEventFilter(self, obj, event): - if obj is self.__textItem and event.type() == QEvent.FocusOut: - self.__textItem.focusOutEvent(event) + if obj is self.__textItem and event.type() == QEvent.FocusOut and \ + event.reason() not in [Qt.ActiveWindowFocusReason, + Qt.PopupFocusReason, + Qt.MenuBarFocusReason]: + # self.__textItem.focusOutEvent(event) self.endEdit() return True @@ -302,6 +425,33 @@ def changeEvent(self, event): Annotation.changeEvent(self, event) + @Slot() + def __updateRenderedContent(self): + try: + renderer = TextAnnotation.ContentRenderer[self.__contentType] + except KeyError: + renderer = render_plain + self.__textItem.setHtml(renderer(self.__content)) + + def contextMenuEvent(self, event): + if event.modifiers() & Qt.AltModifier: + menu = QMenu(event.widget()) + menu.setAttribute(Qt.WA_DeleteOnClose) + + menu.addAction("text/plain") + menu.addAction("text/markdown") + menu.addAction("text/rst") + menu.addAction("text/html") + + @menu.triggered.connect + def ontriggered(action): + self.setContent(self.content(), action.text()) + + menu.popup(event.screenPos()) + event.accept() + else: + event.ignore() + class ArrowItem(GraphicsPathObject): diff --git a/Orange/canvas/canvas/scene.py b/Orange/canvas/canvas/scene.py index 3566c51e064..396d9020069 100644 --- a/Orange/canvas/canvas/scene.py +++ b/Orange/canvas/canvas/scene.py @@ -550,7 +550,6 @@ def add_annotation(self, scheme_annot): if isinstance(scheme_annot, scheme.SchemeTextAnnotation): item = items.TextAnnotation() - item.setPlainText(scheme_annot.text) x, y, w, h = scheme_annot.rect item.setPos(x, y) item.resize(w, h) @@ -558,8 +557,8 @@ def add_annotation(self, scheme_annot): font = font_from_dict(scheme_annot.font, item.font()) item.setFont(font) - scheme_annot.text_changed.connect(item.setPlainText) - + item.setContent(scheme_annot.content, scheme_annot.content_type) + scheme_annot.content_changed.connect(item.setContent) elif isinstance(scheme_annot, scheme.SchemeArrowAnnotation): item = items.ArrowAnnotation() start, end = scheme_annot.start_pos, scheme_annot.end_pos @@ -597,10 +596,7 @@ def remove_annotation(self, scheme_annotation): ) if isinstance(scheme_annotation, scheme.SchemeTextAnnotation): - scheme_annotation.text_changed.disconnect( - item.setPlainText - ) - + scheme_annotation.content_changed.disconnect(item.setContent) self.remove_annotation_item(item) def annotation_items(self): @@ -813,7 +809,7 @@ def mousePressEvent(self, event): # Right (context) click on the node item. If the widget is not # in the current selection then select the widget (only the widget). - # Else simply return and let customContextMenuReqested signal + # Else simply return and let customContextMenuRequested signal # handle it shape_item = self.item_at(event.scenePos(), items.NodeItem) if shape_item and event.button() == Qt.RightButton and \ @@ -856,6 +852,12 @@ def keyReleaseEvent(self, event): return return QGraphicsScene.keyReleaseEvent(self, event) + def contextMenuEvent(self, event): + if self.user_interaction_handler and \ + self.user_interaction_handler.contextMenuEvent(event): + return + super().contextMenuEvent(event) + def set_user_interaction_handler(self, handler): if self.user_interaction_handler and \ not self.user_interaction_handler.isFinished(): diff --git a/Orange/canvas/document/commands.py b/Orange/canvas/document/commands.py index 7064a6dbc01..929fb0e582f 100644 --- a/Orange/canvas/document/commands.py +++ b/Orange/canvas/document/commands.py @@ -170,18 +170,22 @@ def undo(self): class TextChangeCommand(QUndoCommand): - def __init__(self, scheme, annotation, old, new, parent=None): + def __init__(self, scheme, annotation, + old_content, old_content_type, + new_content, new_content_type, parent=None): QUndoCommand.__init__(self, "Change text", parent) self.scheme = scheme self.annotation = annotation - self.old = old - self.new = new + self.old_content = old_content + self.old_content_type = old_content_type + self.new_content = new_content + self.new_content_type = new_content_type def redo(self): - self.annotation.text = self.new + self.annotation.set_content(self.new_content, self.new_content_type) def undo(self): - self.annotation.text = self.old + self.annotation.set_content(self.old_content, self.old_content_type) class SetAttrCommand(QUndoCommand): diff --git a/Orange/canvas/document/interactions.py b/Orange/canvas/document/interactions.py index d56297f6c6a..f6db8ccdb5c 100644 --- a/Orange/canvas/document/interactions.py +++ b/Orange/canvas/document/interactions.py @@ -188,6 +188,12 @@ def keyReleaseEvent(self, event): """ return False + def contextMenuEvent(self, event): + """ + Handle a `QGraphicsScene.contextMenuEvent` + """ + return False + class NoPossibleLinksError(ValueError): pass @@ -1262,7 +1268,7 @@ def __init__(self, document, *args, **kwargs): def mousePressEvent(self, event): pos = event.scenePos() - if self.item is None: + if event.button() & Qt.LeftButton and self.item is None: item = self.scene.item_at(pos, items.TextAnnotation) if item is not None and not item.hasFocus(): self.editItem(item) @@ -1317,7 +1323,7 @@ def __on_textGeometryChanged(self): self.control.setRect(rect) def cancel(self, reason=UserInteraction.OtherReason): - log.debug("ResizeArrowAnnotation.cancel(%s)", reason) + log.debug("ResizeTextAnnotation.cancel(%s)", reason) if self.item is not None and self.savedRect is not None: self.item.setGeometry(self.savedRect) diff --git a/Orange/canvas/document/schemeedit.py b/Orange/canvas/document/schemeedit.py index d2c68c53814..f4c47758bae 100644 --- a/Orange/canvas/document/schemeedit.py +++ b/Orange/canvas/document/schemeedit.py @@ -368,10 +368,6 @@ def __setupUi(self): view = CanvasView(scene) view.setFrameStyle(CanvasView.NoFrame) view.setRenderHint(QPainter.Antialiasing) - view.setContextMenuPolicy(Qt.CustomContextMenu) - view.customContextMenuRequested.connect( - self.__onCustomContextMenuRequested - ) self.__view = view self.__scene = scene @@ -951,7 +947,7 @@ def eventFilter(self, obj, event): # Filter the scene's drag/drop events. if obj is self.scene(): etype = event.type() - if etype == QEvent.GraphicsSceneDragEnter or \ + if etype == QEvent.GraphicsSceneDragEnter or \ etype == QEvent.GraphicsSceneDragMove: mime_data = event.mimeData() if mime_data.hasFormat( @@ -1185,6 +1181,32 @@ def sceneKeyReleaseEvent(self, event): return False def sceneContextMenuEvent(self, event): + scenePos = event.scenePos() + globalPos = event.screenPos() + + item = self.scene().item_at(scenePos, items.NodeItem) + if item is not None: + self.__widgetMenu.popup(globalPos) + return True + + item = self.scene().item_at(scenePos, items.LinkItem, + buttons=Qt.RightButton) + if item is not None: + link = self.scene().link_for_item(item) + self.__linkEnableAction.setChecked(link.enabled) + self.__contextMenuTarget = link + self.__linkMenu.popup(globalPos) + return True + + item = self.scene().item_at(scenePos) + if not item and \ + self.__quickMenuTriggers & SchemeEditWidget.RightClicked: + action = interactions.NewNodeAction(self) + + with disabled(self.__undoAction), disabled(self.__redoAction): + action.create_new(globalPos) + return True + return False def _setUserInteractionHandler(self, handler): @@ -1337,11 +1359,17 @@ def __onEditingFinished(self, item): Text annotation editing has finished. """ annot = self.__scene.annotation_for_item(item) - text = str(item.toPlainText()) - if annot.text != text: + + content_type = item.contentType() + content = item.content() + + if annot.text != content or annot.content_type != content_type: self.__undoStack.push( - commands.TextChangeCommand(self.scheme(), annot, - annot.text, text) + commands.TextChangeCommand( + self.scheme(), annot, + annot.text, annot.content_type, + content, content_type + ) ) def __toggleNewArrowAnnotation(self, checked): @@ -1416,33 +1444,6 @@ def __onArrowColorTriggered(self, action): if isinstance(handler, interactions.NewArrowAnnotation): handler.setColor(action.data()) - def __onCustomContextMenuRequested(self, pos): - scenePos = self.view().mapToScene(pos) - globalPos = self.view().mapToGlobal(pos) - - item = self.scene().item_at(scenePos, items.NodeItem) - if item is not None: - self.__widgetMenu.popup(globalPos) - return - - item = self.scene().item_at(scenePos, items.LinkItem, - buttons=Qt.RightButton) - if item is not None: - link = self.scene().link_for_item(item) - self.__linkEnableAction.setChecked(link.enabled) - self.__contextMenuTarget = link - self.__linkMenu.popup(globalPos) - return - - item = self.scene().item_at(scenePos) - if not item and \ - self.__quickMenuTriggers & SchemeEditWidget.RightClicked: - action = interactions.NewNodeAction(self) - - with disabled(self.__undoAction), disabled(self.__redoAction): - action.create_new(globalPos) - return - def __onRenameAction(self): """ Rename was requested for the selected widget. diff --git a/Orange/canvas/scheme/annotations.py b/Orange/canvas/scheme/annotations.py index 3c034127ebc..505a02402f1 100644 --- a/Orange/canvas/scheme/annotations.py +++ b/Orange/canvas/scheme/annotations.py @@ -102,16 +102,21 @@ class SchemeTextAnnotation(BaseSchemeAnnotation): Text annotation in the scheme. """ + # Signal emitted when the annotation content change. + content_changed = Signal(str, str) + # Signal emitted when the annotation text changes. text_changed = Signal(str) # Signal emitted when the annotation text font changes. font_changed = Signal(dict) - def __init__(self, rect, text="", font=None, anchor=None, parent=None): + def __init__(self, rect, text="", content_type="text/plain", font=None, + anchor=None, parent=None): BaseSchemeAnnotation.__init__(self, parent) self.__rect = rect - self.__text = text + self.__content = text + self.__content_type = content_type self.__font = {} if font is None else font self.__anchor = anchor @@ -150,24 +155,65 @@ def geometry(self): def set_text(self, text): """ Set the annotation text. + + Same as `set_content(text, "text/plain")` """ check_type(text, str) text = str(text) - if self.__text != text: - self.__text = text - self.text_changed.emit(text) + self.set_content(text, "text/plain") def text(self): """ Annotation text. + + .. deprecated:: + Use `content` instead. """ - return self.__text + return self.__content text = Property(tuple, fget=text, fset=set_text) + @property + def content_type(self): + """ + Return the annotations' content type. + + Currently this will be 'text/plain', 'text/html' or 'text/rst'. + """ + return self.__content_type + + @property + def content(self): + """ + The annotation content. + + How the content is interpreted/displayed depends on `content_type`. + """ + return self.__content + + def set_content(self, content, content_type="text/plain"): + """ + Set the annotation content. + + Parameters + ---------- + content : str + The content. + content_type : str + Content type. Currently supported are 'text/plain' 'text/html' + (subset supported by `QTextDocument`) and `text/rst`. + """ + if self.__content != content or self.__content_type != content_type: + text_changed = self.__content != content + self.__content = content + self.__content_type = content_type + self.content_changed.emit(content, content_type) + if text_changed: + self.text_changed.emit(content) + def set_font(self, font): """ - Set the annotation's font as a dictionary of font properties + Set the annotation's default font as a dictionary of font properties (at the moment only family and size are used). >>> annotation.set_font({"family": "Helvetica", "size": 16}) diff --git a/Orange/canvas/scheme/readwrite.py b/Orange/canvas/scheme/readwrite.py index 5efe4af2001..fd1cd7e4625 100644 --- a/Orange/canvas/scheme/readwrite.py +++ b/Orange/canvas/scheme/readwrite.py @@ -85,12 +85,7 @@ def terminal_eval(source): """ node = ast.parse(source, "", mode="eval") - - try: - return _terminal_value(node.body) - except ValueError: - raise - raise ValueError("%r is not a terminal constant" % source) + return _terminal_value(node.body) def _terminal_value(node): @@ -426,7 +421,7 @@ def parse_scheme_v_1_0(etree, scheme, error_handler, widget_registry=None, _text_params = namedtuple( "_text_params", - ["geometry", "text", "font"]) + ["geometry", "text", "font", "content_type"]) _arrow_params = namedtuple( "_arrow_params", @@ -487,10 +482,13 @@ def parse_ows_etree_v_2_0(tree): if font_size: font["size"] = int(font_size) + content_type = annot.get("type", "text/plain") + annotation = _annotation( id=annot.get("id"), type="text", - params=_text_params(rect, annot.text or "", font), + params=_text_params(rect, annot.text or "", font, + content_type), ) elif annot.tag == "arrow": start = tuple_eval(annot.get("start", "(0, 0)")) @@ -727,8 +725,10 @@ def error_handler(exc): for annot_d in desc.annotations: params = annot_d.params if annot_d.type == "text": - annot = SchemeTextAnnotation(params.geometry, params.text, - params.font) + annot = SchemeTextAnnotation( + params.geometry, params.text, params.content_type, + params.font + ) elif annot_d.type == "arrow": start, end = params.geometry annot = SchemeArrowAnnotation(start, end, params.color) @@ -818,6 +818,7 @@ def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): data = None if isinstance(annotation, SchemeTextAnnotation): tag = "text" + attrs.update({"type": annotation.content_type}) attrs.update({"rect": repr(annotation.rect)}) # Save the font attributes @@ -827,21 +828,12 @@ def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): attrs = [(key, value) for key, value in attrs.items() if value is not None] attrs = dict((key, str(value)) for key, value in attrs) - - data = annotation.text - + data = annotation.content elif isinstance(annotation, SchemeArrowAnnotation): tag = "arrow" attrs.update({"start": repr(annotation.start_pos), - "end": repr(annotation.end_pos)}) - - # Save the arrow color - try: - color = annotation.color - attrs.update({"fill": color}) - except AttributeError: - pass - + "end": repr(annotation.end_pos), + "fill": annotation.color}) data = None else: log.warning("Can't save %r", annotation) diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index 0315718d21e..3a61a1bde54 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -49,6 +49,7 @@ requirements: - joblib - python.app # [osx] - dill # pickle anything + - commonmark test: # Python imports diff --git a/requirements-gui.txt b/requirements-gui.txt index 0948ead9326..3c2bc76f1bc 100644 --- a/requirements-gui.txt +++ b/requirements-gui.txt @@ -5,3 +5,5 @@ pyqtgraph>=0.10.0 # For add-ons' descriptions docutils + +CommonMark>=0.5.5 \ No newline at end of file diff --git a/scripts/macos/requirements.txt b/scripts/macos/requirements.txt index 1c59fb16c5c..0ae40c77c26 100644 --- a/scripts/macos/requirements.txt +++ b/scripts/macos/requirements.txt @@ -20,3 +20,4 @@ scipy==0.19.0 sip==4.19.2 six==1.10.0 xlrd==1.0.0 +CommonMark==0.7.3 From 38efe4fe17f5cac632cbd625024b9e3def0a8fcd Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Aug 2016 14:54:17 +0200 Subject: [PATCH 2/5] canvas/annotations: Handle links in rich text annotations --- Orange/canvas/canvas/items/annotationitem.py | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Orange/canvas/canvas/items/annotationitem.py b/Orange/canvas/canvas/items/annotationitem.py index 1446f6adf73..c9933fb399b 100644 --- a/Orange/canvas/canvas/items/annotationitem.py +++ b/Orange/canvas/canvas/items/annotationitem.py @@ -90,6 +90,29 @@ def paint(self, painter, option, widget=None): painter.setPen(QPen(color)) painter.drawText(brect, Qt.AlignTop | Qt.AlignLeft, text) + def hoverMoveEvent(self, event): + layout = self.document().documentLayout() + if layout.anchorAt(event.pos()): + self.setCursor(Qt.PointingHandCursor) + else: + self.unsetCursor() + super().hoverMoveEvent(event) + + def mousePressEvent(self, event): + flags = self.textInteractionFlags() + if flags & Qt.LinksAccessibleByMouse \ + and not flags & Qt.TextSelectableByMouse \ + and self.document().documentLayout().anchorAt(event.pos()): + # QGraphicsTextItem ignores the press event without + # Qt.TextSelectableByMouse flag set. This causes the + # corresponding mouse release to never get to this item + # and therefore no linkActivated/openUrl ... + super().mousePressEvent(event) + if not event.isAccepted(): + event.accept() + else: + super().mousePressEvent(event) + def render_plain(content): """ From 8a61be376840fe253371b26d3b684ad0bb659abe Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 7 Nov 2016 17:15:15 +0100 Subject: [PATCH 3/5] canvas/annotations: Better edit focus tracking --- Orange/canvas/canvas/items/annotationitem.py | 44 ++++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/Orange/canvas/canvas/items/annotationitem.py b/Orange/canvas/canvas/items/annotationitem.py index c9933fb399b..6db1e561918 100644 --- a/Orange/canvas/canvas/items/annotationitem.py +++ b/Orange/canvas/canvas/items/annotationitem.py @@ -47,10 +47,17 @@ class GraphicsTextEdit(QGraphicsTextItem): property (text displayed when no text is set). """ + #: Signal emitted when editing operation starts (the item receives edit + #: focus) + editingStarted = Signal() + #: Signal emitted when editing operation ends (the item loses edit focus) + editingFinished = Signal() + def __init__(self, *args, **kwargs): QGraphicsTextItem.__init__(self, *args, **kwargs) self.setAcceptHoverEvents(True) self.__placeholderText = "" + self.__editing = False # text editing in progress def setPlaceholderText(self, text): """ @@ -113,6 +120,27 @@ def mousePressEvent(self, event): else: super().mousePressEvent(event) + def setTextInteractionFlags(self, flags): + super().setTextInteractionFlags(flags) + if self.hasFocus() and flags & Qt.TextEditable and not self.__editing: + self.__editing = True + self.editingStarted.emit() + + def focusInEvent(self, event): + super().focusInEvent(event) + if self.textInteractionFlags() & Qt.TextEditable and \ + not self.__editing: + self.__editing = True + self.editingStarted.emit() + + def focusOutEvent(self, event): + super().focusOutEvent(event) + if self.__editing and \ + event.reason() not in {Qt.ActiveWindowFocusReason, + Qt.PopupFocusReason}: + self.__editing = False + self.editingFinished.emit() + def render_plain(content): """ @@ -240,6 +268,7 @@ def __init__(self, parent=None, **kwargs): self.__textItem.setTabChangesFocus(True) self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) self.__textItem.setFont(self.font()) + self.__textItem.editingFinished.connect(self.__textEditingFinished) layout = self.__textItem.document().documentLayout() layout.documentSizeChanged.connect(self.__onDocumentSizeChanged) @@ -373,9 +402,6 @@ def startEdit(self): self.__textItem.setPlainText(self.__content) self.__textItem.setTextInteractionFlags(self.__textInteractionFlags) self.__textItem.setFocus(Qt.MouseFocusReason) - - # Install event filter to find out when the text item loses focus. - self.__textItem.installSceneEventFilter(self) self.__textItem.document().contentsChanged.connect( self.textEdited ) @@ -425,16 +451,8 @@ def resizeEvent(self, event): self.__updateFrame() QGraphicsWidget.resizeEvent(self, event) - def sceneEventFilter(self, obj, event): - if obj is self.__textItem and event.type() == QEvent.FocusOut and \ - event.reason() not in [Qt.ActiveWindowFocusReason, - Qt.PopupFocusReason, - Qt.MenuBarFocusReason]: - # self.__textItem.focusOutEvent(event) - self.endEdit() - return True - - return Annotation.sceneEventFilter(self, obj, event) + def __textEditingFinished(self): + self.endEdit() def itemChange(self, change, value): if change == QGraphicsItem.ItemSelectedHasChanged: From 642676a68badc6f0d025a58c7ea5f06b61efb6aa Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 7 Nov 2016 18:39:25 +0100 Subject: [PATCH 4/5] canvas/annotations: Text annotation context menu fix --- Orange/canvas/canvas/items/annotationitem.py | 38 +++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/Orange/canvas/canvas/items/annotationitem.py b/Orange/canvas/canvas/items/annotationitem.py index 6db1e561918..9bef333cfff 100644 --- a/Orange/canvas/canvas/items/annotationitem.py +++ b/Orange/canvas/canvas/items/annotationitem.py @@ -240,7 +240,7 @@ class TextAnnotation(Annotation): ]) # type: Dict[str, Callable[[str], [str]]] def __init__(self, parent=None, **kwargs): - Annotation.__init__(self, parent, **kwargs) + super().__init__(None, **kwargs) self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemIsSelectable) @@ -269,11 +269,24 @@ def __init__(self, parent=None, **kwargs): self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) self.__textItem.setFont(self.font()) self.__textItem.editingFinished.connect(self.__textEditingFinished) - + if self.__textItem.scene() is not None: + self.__textItem.installSceneEventFilter(self) layout = self.__textItem.document().documentLayout() layout.documentSizeChanged.connect(self.__onDocumentSizeChanged) self.__updateFrame() + # set parent item at the end in order to ensure + # QGraphicsItem.ItemSceneHasChanged is delivered after initialization + if parent is not None: + self.setParentItem(parent) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemSceneHasChanged: + if self.__textItem.scene() is not None: + self.__textItem.installSceneEventFilter(self) + if change == QGraphicsItem.ItemSelectedHasChanged: + self.__updateFrameStyle() + return super().itemChange(change, value) def adjustSize(self): """Resize to a reasonable size. @@ -412,7 +425,6 @@ def endEdit(self): content = self.__textItem.toPlainText() self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) - self.__textItem.removeSceneEventFilter(self) self.__textItem.document().contentsChanged.disconnect( self.textEdited ) @@ -454,11 +466,17 @@ def resizeEvent(self, event): def __textEditingFinished(self): self.endEdit() - def itemChange(self, change, value): - if change == QGraphicsItem.ItemSelectedHasChanged: - self.__updateFrameStyle() - - return Annotation.itemChange(self, change, value) + def sceneEventFilter(self, obj, event): + if obj is self.__textItem and \ + not (self.__textItem.hasFocus() and + self.__textItem.textInteractionFlags() & Qt.TextEditable) and \ + event.type() in {QEvent.GraphicsSceneContextMenu} and \ + event.modifiers() & Qt.AltModifier: + # Handle Alt + context menu events here + self.contextMenuEvent(event) + event.accept() + return True + return super().sceneEventFilter(obj, event) def changeEvent(self, event): if event.type() == QEvent.FontChange: @@ -484,6 +502,10 @@ def contextMenuEvent(self, event): menu.addAction("text/rst") menu.addAction("text/html") + for action in menu.actions(): + action.setCheckable(True) + action.setChecked(action.text() == self.__contentType.lower()) + @menu.triggered.connect def ontriggered(action): self.setContent(self.content(), action.text()) From 570cc9b9599d910ed2d8d92a55ff0d41172d4a78 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 8 Nov 2016 12:50:55 +0100 Subject: [PATCH 5/5] canvas/annotations: Change context menu --- Orange/canvas/canvas/items/annotationitem.py | 45 ++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Orange/canvas/canvas/items/annotationitem.py b/Orange/canvas/canvas/items/annotationitem.py index 9bef333cfff..c4ff6d2b783 100644 --- a/Orange/canvas/canvas/items/annotationitem.py +++ b/Orange/canvas/canvas/items/annotationitem.py @@ -8,7 +8,7 @@ from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsPathItem, QGraphicsWidget, QGraphicsTextItem, - QGraphicsDropShadowEffect, QMenu + QGraphicsDropShadowEffect, QMenu, QAction, QActionGroup ) from AnyQt.QtGui import ( QPainterPath, QPainterPathStroker, QPolygonF, QColor, QPen @@ -496,20 +496,41 @@ def contextMenuEvent(self, event): if event.modifiers() & Qt.AltModifier: menu = QMenu(event.widget()) menu.setAttribute(Qt.WA_DeleteOnClose) + formatmenu = menu.addMenu("Render as") + group = QActionGroup(self, exclusive=True) + + def makeaction(text, parent, data=None, **kwargs): + action = QAction(text, parent, **kwargs) + if data is not None: + action.setData(data) + return action + + formatactions = [ + makeaction("Plain Text", group, checkable=True, + toolTip=self.tr("Render contents as plain text"), + data="text/plain"), + makeaction("HTML", group, checkable=True, + toolTip=self.tr("Render contents as HTML"), + data="text/html"), + makeaction("RST", group, checkable=True, + toolTip=self.tr("Render contents as RST " + "(reStructuredText)"), + data="text/rst"), + makeaction("Markdown", group, checkable=True, + toolTip=self.tr("Render contents as Markdown"), + data="text/markdown") + ] + for action in formatactions: + action.setChecked(action.data() == self.__contentType.lower()) + formatmenu.addAction(action) - menu.addAction("text/plain") - menu.addAction("text/markdown") - menu.addAction("text/rst") - menu.addAction("text/html") - - for action in menu.actions(): - action.setCheckable(True) - action.setChecked(action.text() == self.__contentType.lower()) - - @menu.triggered.connect def ontriggered(action): - self.setContent(self.content(), action.text()) + mimetype = action.data() + content = self.content() + self.setContent(content, mimetype) + self.editingFinished.emit() + menu.triggered.connect(ontriggered) menu.popup(event.screenPos()) event.accept() else: