Skip to content

Commit

Permalink
canvas/annotations: Add support for inline text markup editing
Browse files Browse the repository at this point in the history
  • Loading branch information
ales-erjavec committed Jun 27, 2017
1 parent 5afe1c2 commit 93bfdbd
Show file tree
Hide file tree
Showing 10 changed files with 310 additions and 105 deletions.
200 changes: 175 additions & 25 deletions Orange/canvas/canvas/items/annotationitem.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 '<p style="white-space: pre-wrap;">' + escape(content) + "</p>"


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)
Expand All @@ -101,21 +195,28 @@ 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)
self.__framePathItem = QGraphicsPathItem(self)
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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand All @@ -245,28 +360,33 @@ 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
)
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()
Expand All @@ -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

Expand All @@ -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):

Expand Down
18 changes: 10 additions & 8 deletions Orange/canvas/canvas/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,16 +550,15 @@ 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)
item.setTextInteractionFlags(Qt.TextEditorInteraction)

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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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():
Expand Down
14 changes: 9 additions & 5 deletions Orange/canvas/document/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading

0 comments on commit 93bfdbd

Please sign in to comment.