Source code for shotgun_fields.bubble_widget
# Copyright (c) 2016 Shotgun Software Inc.
#
# CONFIDENTIAL AND PROPRIETARY
#
# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit
# Source Code License included in this distribution package. See LICENSE.
# By accessing, using, copying or modifying this work you indicate your
# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights
# not expressly granted therein are reserved by Shotgun Software Inc.
import sgtk
from sgtk.platform.qt import QtCore, QtGui
from tank_vendor.six import unichr
[docs]class BubbleWidget(QtGui.QFrame):
"""
This class represents a drawable "bubble" to display in a :class:`.BubbleEditWidget`
This widget will stores data for the object it represents. The data can be set
and accessed via the respective ``get_data()`` and ``set_data()`` methods.
The widget can display an optional image along with its text. See the display
related methods ``set_text()`` and ``set_image()`` below.
:signal: ``remove_clicked()`` - emitted when the widget's ``x`` button clicked.
"""
# signal emitted when the widget's remove button was clicked
remove_clicked = QtCore.Signal()
def __init__(self, parent=None):
"""Initialize the widget.
:param parent: This widget's parent widget
:type parent: :class:`~PySide.QtGui.QWidget`
"""
super(BubbleWidget, self).__init__(parent)
# placeholder for the underlying data this widget represents in the editor
self._data = None
# should not grow or shrink
self.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed)
# style the look of the bubble
self.setObjectName("bubble")
self.setStyleSheet(
"""
#bubble {
border: 1px solid black;
border-radius: 5px;
background-color: %s;
}
"""
% self.palette().color(QtGui.QPalette.Button).name()
)
# create a remove button for the widget.
# extract a close button icon from the style and use it
self.remove_button = QtGui.QPushButton(self)
style = self.remove_button.style()
icon = style.standardIcon(QtGui.QStyle.SP_TitleBarCloseButton)
self.remove_button.setIcon(icon)
self.remove_button.setFlat(True)
self.remove_button.setStyleSheet("border: none")
# placeholder for the bubble's image
self.image_label = QtGui.QLabel(self)
# color the text to use the PTR highlight color
text_color = QtGui.QColor(
sgtk.platform.current_bundle().style_constants["SG_HIGHLIGHT_COLOR"]
)
self.text_label = QtGui.QLabel(self)
palette = self.text_label.palette()
palette.setColor(QtGui.QPalette.WindowText, text_color)
self.text_label.setPalette(palette)
# layout the widgets
self.layout = QtGui.QHBoxLayout()
self.layout.setSizeConstraint(QtGui.QLayout.SetMinimumSize)
self.layout.setContentsMargins(3, 1, 3, 1)
self.layout.setSpacing(2)
self.layout.addWidget(self.image_label, QtCore.Qt.AlignVCenter)
self.layout.addWidget(self.text_label, QtCore.Qt.AlignVCenter)
self.layout.addWidget(self.remove_button, QtCore.Qt.AlignVCenter)
self.setLayout(self.layout)
# emit the "remove_clicked" signal when the button is clicked.
self.remove_button.clicked.connect(lambda: self.remove_clicked.emit())
[docs] def set_text(self, label_text):
"""
Set the bubble's display text.
:param str label_text: The display text
"""
self.text_label.setText(label_text)
[docs] def set_image(self, url):
"""
Set the bubble's display image.
:param str url: The image url to display in the bubble.
"""
self.image_label.setText("<img src='%s'/>" % url)
[docs] def set_removable(self, removable):
"""
Set whether or not the bubble is removable.
Shows or hides the ``x`` button depending on the value of the
``removable`` argument.
:param bool removable: ``True`` if the bubble is removable, ``False`` otherwise.
"""
if removable:
self.remove_button.show()
else:
self.remove_button.hide()
[docs] def get_data(self):
"""
Returns the underlying data object this widget represents.
The return type is intentionally unspecified since, in theory, the
bubble could represent any type of data.
"""
return self._data
[docs] def set_data(self, data):
"""
Set the underlying data object that this widget represents.
The type of ``data`` is intentionally unspecified since, in theory, the
bubble could represent any type of data.
"""
self._data = data
[docs]class BubbleEditWidget(QtGui.QTextEdit):
"""
This is a base class for "bubble" entry widgets.
Each object is represented by a "bubble" similar to email address entry
widgets in modern email clients. Subclasses will typically handle the user
interaction and decide when a new "bubble" should be added to the widget.
"""
# used as a placeholder for a bubble in the editor
_OBJECT_REPLACEMENT_CHAR = unichr(0xFFFC)
def __init__(self, parent=None):
"""
Initialize the widget.
:param parent: This widget's parent widget
:type parent: :class:`~PySide.QtGui.QWidget`
"""
super(BubbleEditWidget, self).__init__(parent)
self._char_formats = {}
self._bubble_text_object = _BubbleTextObject(self)
self.document().documentLayout().registerHandler(
_BubbleTextObject.OBJECT_TYPE, self._bubble_text_object
)
self.setMouseTracking(True)
self.viewport().installEventFilter(self)
self.setMinimumWidth(180)
[docs] def add_bubble(self, bubble):
"""
Add the supplied :class:`.BubbleWidget` instance to the editor.
:param bubble: The bubble widget instance.
:return: A unique id for the added bubble
:rtype: :obj:`int`
"""
bubble_id = self._bubble_text_object.add_bubble(bubble)
# create a character format for the bubble
char_format = QtGui.QTextCharFormat()
char_format.setObjectType(self._bubble_text_object.OBJECT_TYPE)
char_format.setProperty(
self._bubble_text_object.BUBBLE_DATA_PROPERTY, bubble_id
)
# keep a reference to the char format so that we can map a cursor to this
# bubble later on
self._char_formats[bubble_id] = char_format
bubble.remove_clicked.connect(lambda: self.remove_bubble(bubble_id))
# insert the bubble character into the text editor and char format it properly
cursor = self.textCursor()
cursor.beginEditBlock()
cursor.insertText(self._OBJECT_REPLACEMENT_CHAR, char_format)
cursor.endEditBlock()
self.setTextCursor(cursor)
return bubble_id
[docs] def clear(self):
"""
Clears all bubbles from the editor.
"""
for char_format in self._char_formats.values():
del char_format
self._bubble_text_object.clear()
super(BubbleEditWidget, self).clear()
[docs] def clear_typed_text(self):
"""
Clears only typed text (not bubbles) from the editor.
"""
text = self.toPlainText()
cursor = self.textCursor()
cursor.beginEditBlock()
# iterate over all characters in the editor. remove any that aren't the
# special replacement character. iterate backwards so that indices don't change
for i in reversed(range(0, len(text))):
if text[i] == self._OBJECT_REPLACEMENT_CHAR:
continue
cursor.setPosition(i, QtGui.QTextCursor.MoveAnchor)
cursor.movePosition(QtGui.QTextCursor.Right, QtGui.QTextCursor.KeepAnchor)
cursor.removeSelectedText()
cursor.endEditBlock()
def eventFilter(self, object, event):
"""
Attempts to identify clicks on one of the editor's bubble widget's
remove button.
:param object: The observed object.
:type object: :class:`~PySide.QtCore.QObject`
:param event: The event to filter.
:type object: :class:`~PySide.QtCore.Qt.QEvent`
:return: True'' if the event was filtered, ''False'' otherwise.
"""
if not isinstance(event, QtGui.QMouseEvent):
# only pass on mouse events
return False
# @todo:
# can't seem to figure out how to map the viewport position to the
# scroll area position. tried all combinations of mapto/from...
# resulting to just adding the scroll values. please fix if you can
edit_pos = QtCore.QPoint(
event.pos().x() + self.horizontalScrollBar().value(),
event.pos().y() + self.verticalScrollBar().value(),
)
# for mouse events find the actual widget at the position
doc = self.document()
cursor_pos = doc.documentLayout().hitTest(edit_pos, QtCore.Qt.ExactHit)
char_format = doc.documentLayout().format(cursor_pos)
bubble_id = char_format.property(_BubbleTextObject.BUBBLE_DATA_PROPERTY)
bubble = self.get_bubble(bubble_id)
if bubble is None:
self.viewport().setCursor(QtCore.Qt.IBeamCursor)
return False
self.viewport().setCursor(QtCore.Qt.ArrowCursor)
if event.type() == QtCore.QEvent.MouseButtonPress:
# if we are clicking on the button, do so
bubble_pos = bubble.mapFromParent(edit_pos)
child_widget = bubble.childAt(bubble_pos)
if isinstance(child_widget, QtGui.QPushButton):
child_widget.click()
return True
return False
[docs] def get_bubble(self, bubble_id):
"""
Returns a bubble widget based on the supplied id.
The ``bubble_id`` should correspond to the unique ID returned by the
:meth:`.add_bubble` method.
:param bubble_id: The id of the bubble to retrieve.
:return: A bubble widget or ``None`` if not match is found
:rtype: :class:`.BubbleWidget`
"""
if not bubble_id in self._char_formats:
return None
text = self.toPlainText()
cursor = self.textCursor()
# loop over each character until a replacement character with a known
# char format that matches the supplied id is found.
for i in range(0, len(text)):
if text[i] != self._OBJECT_REPLACEMENT_CHAR:
continue
cursor.setPosition(i + 1, QtGui.QTextCursor.MoveAnchor)
char_format = cursor.charFormat()
if self._char_formats[bubble_id] != char_format:
continue
# bubble is in the text
return self._bubble_text_object.get_bubble(bubble_id)
return None
[docs] def get_bubbles(self):
"""
Similar to ``get_bubble``, but returns all bubble widgets.
:return: List of :class:`.BubbleWidget` classes
:rtype: list
"""
text = self.toPlainText()
cursor = self.textCursor()
char_formats = []
bubbles = []
# find char formats for bubbles in the text editor
for i in range(0, len(text)):
if text[i] != self._OBJECT_REPLACEMENT_CHAR:
continue
cursor.setPosition(i + 1, QtGui.QTextCursor.MoveAnchor)
char_format = cursor.charFormat()
if not char_format in self._char_formats.values():
continue
char_formats.append(char_format)
# get the corresponding bubbles for each found char format
for bubble_id in self._char_formats:
if self._char_formats[bubble_id] in char_formats:
bubbles.append(self.get_bubble(bubble_id))
return bubbles
[docs] def get_typed_text(self):
"""
Returns a :obj:`str` representing the text typed in the editor.
"""
char_list = [
c for c in self.toPlainText() if c != self._OBJECT_REPLACEMENT_CHAR
]
return "".join(char_list)
[docs] def remove_bubble(self, bubble_id):
"""
Remove a bubble matching the supplied id.
:param int bubble_id: The unique id of the bubble to reomve.
:return: The removed qt widget
The ``bubble_id`` should correspond to the unique ID returned by the
:meth:`.add_bubble` method.
"""
if not self.get_bubble(bubble_id):
return
text = self.toPlainText()
cursor = self.textCursor()
# locate the bubble by iterating over each character in the editor.
# when the match is found, remove it.
for i in range(0, len(text)):
if text[i] != self._OBJECT_REPLACEMENT_CHAR:
continue
cursor.setPosition(i + 1, QtGui.QTextCursor.MoveAnchor)
char_format = cursor.charFormat()
if self._char_formats[bubble_id] != char_format:
continue
# we found the bubble's object character.
cursor.beginEditBlock()
cursor.movePosition(QtGui.QTextCursor.Left, QtGui.QTextCursor.KeepAnchor)
cursor.removeSelectedText()
cursor.endEditBlock()
del self._char_formats[bubble_id]
self.update()
return
class _BubbleTextObject(QtGui.QPyTextObject):
"""
Handles the display of bubble widgets within text documents.
"""
BUBBLE_DATA_PROPERTY = 1
OBJECT_TYPE = QtGui.QTextFormat.UserFormat + 1
USING_PYQT = hasattr(QtCore, "QVariant")
def __init__(self, parent=None):
"""
Initialize the object.
:param parent: This widget's parent object
:type parent: :class:`~PySide.QtGui.QObject`
:return:
"""
super(_BubbleTextObject, self).__init__(parent)
# lookup of bubble widgets by id
self._bubbles = {}
# count for unique ids within this instance
self._next_id = 0
def add_bubble(self, bubble_widget):
"""Make the object aware of this bubble widget.
:param bubble_widget: The bubble widget to add.
:type bubble_widget: :class:`.BubbleWidget`
:return: The id of the added bubble
"""
# the widget may have been added and removed
bubble_id = self._next_id
self._next_id += 1
bubble_widget.id = bubble_id
self._bubbles[bubble_id] = bubble_widget
return bubble_id
def clear(self):
"""Forget about all the known widgets."""
self._bubbles = {}
def drawObject(self, painter, rect, doc, pos_in_document, char_format):
"""Draw the appropriate widget based on the supplied char format."""
# determine the bubble to draw
bubble_id = char_format.property(self.BUBBLE_DATA_PROPERTY)
bubble = self.get_bubble(bubble_id)
bubble.setGeometry(rect.toRect())
# now paint!
painter.save()
try:
painter.translate(rect.topLeft().toPoint())
# WEIRD! It seems pyside and pyqt actually have different signatures for this method
if self.USING_PYQT:
# pyqt is using the flags parameter, which seems inconsistent with QT
# http://pyqt.sourceforge.net/Docs/PyQt4/qwidget.html#render
bubble.render(
painter,
QtCore.QPoint(0, 1),
QtGui.QRegion(),
QtGui.QWidget.DrawChildren,
)
else:
# pyside is using the renderFlags parameter which seems correct
bubble.render(
painter, QtCore.QPoint(0, 1), renderFlags=QtGui.QWidget.DrawChildren
)
finally:
painter.restore()
def get_bubble(self, bubble_id):
"""Retrieve a bubble widget for the supplied id."""
if bubble_id in self._bubbles:
return self._bubbles[bubble_id]
return None
def intrinsicSize(self, doc, pos_in_document, char_format):
"""Returns the ``sizeHint`` for the bubble widget for the supplied char format."""
bubble_id = char_format.property(self.BUBBLE_DATA_PROPERTY)
bubble = self.get_bubble(bubble_id)
return bubble.sizeHint()