Custom and Interactive Graphics Items

The predefined graphics items can be made movable, selectable, and focusable by calling setFlags() on them with suitable constants. Users can drag movable items with the mouse, and they can select selectable items by clicking them, and by using Ctrl+Click to select multiple items. Focusable items will receive key events, but will ignore them unless we create an item subclass with a key event handler. Similarly, we can make items responsive to mouse events by subclassing and implementing appropriate mouse event handlers.

In this section, we will use two of the predefined graphics items, and create two custom graphics item subclasses to show how to use graphics items, and how to control their behavior and appearance. We will also see how to load and save scenes, and how to print them. To do these things we will look at the Page Designer application shown in Figure 12.2. This program allows the user to create a page that can contain text, images, and boxes. Users can also create lines—these are just boxes that are 1 pixel wide or high. The images created by the user can be saved and loaded as .pgd files, a custom file format specific to this application, and they can be printed (or saved as PDF files) using a print dialog.

For the text items, a QGraphicsTextItem subclass is used, extended to allow the user to set the item's font and text by double-clicking. For the box (and line) items a QGraphicsItem subclass is used. This has a context menu, plus keyboard support for resizing, and it handles all its own drawing. The pixmap items simply use the built-in QGraphicsPixmapItem class, and the page and margin guidelines use the built-in QGraphicsRectItem class. The view that shows the scene is a QGraphicsView subclass that supports rubber-band selection and mouse-wheel scaling.

We will begin by looking at the QGraphicsView subclass. Then we will review the main form, and finally we will review the custom QGraphicsItem subclasses.

class GraphicsView(QGraphicsView):

def_init_(self, parent=None):

super(GraphicsView, self)._init_(parent)

self.setDragMode(QGraphicsView.RubberBandDrag)

self.setRenderHint(QPainter.Antialiasing)

self.setRenderHint(QPainter.TextAntialiasing)

def wheelEvent(self, event):

factor = 1.41 ** (-event.delta() / 240.0) self.scale(factor, factor)

The preceding code is the complete GraphicsView subclass. In the initializer we set the drag mode: This means that dragging on the view will cause PyQt to give us a rubber band, and every item touched by the rubber band will be

Pyside Move Qgraphicsitem
Figure 12.2 The Page Designer application

selected. The render hints are propagated to any item that is painted inside the view, so we do not need to set the hints for each individual graphics item.

The wheel event is called whenever the user rolls the mouse wheel, and it will cause the view to scale smaller or larger depending on which way the wheel is rolled. The effect of this is to change the apparent size of the page—the underlying scene is not changed at all. The math used in this event handler is rather tricky, but this isn't a problem since the method can be copied and pasted "as is".

Near the top of chap12/pagedesigner.pyw we have some global declarations.

MagicNumber = 0x70616765 FileVersion = 1

Dirty = False

The page size is in points for U.S. Letter-size paper. (The source code also has the A4 page size, commented out.) The magic number and file version are used by QDataStream, as we have seen in Chapter 8 and elsewhere. We also have a global dirty flag.

Partial function application

class MainForm(QDialog):

def_init_(self, parent=None):

super(MainForm, self)._init_(parent)

self.filename = QString() self.copiedItem = QByteArray() self.pasteOffset = 5 self.prevPoint = QPoint() self.addOffset = 5 self.borders = []

self.printer = QPrinter(QPrinter.HighResolution) self.printer.setPageSize(QPrinter.Letter)

The copied item is essentially a lump of binary data that describes the most recent item to be cut or copied. We store this data inside the application rather than on the clipboard because it is of no use to any other application. The paste offset is used when the user repeatedly pastes the same item, and the previous point and add offset are used when the user repeatedly adds the same item type. In both cases the newly added items are added at offset positions rather than exactly on top of the previous item. This makes it easier for the user to see where they are.

The borders list will contain two graphics items, both yellow rectangles: one giving the page outline and the other giving an outline inside the page allowing for some margin space. They are used as guidelines and are not saved or printed.

Although it is possible to create a QPrinter object when it is needed, by creating one and keeping it as an instance variable, we ensure that the user's settings, such as page size, are preserved between uses in the same session.

self.view = GraphicsView() self.scene = QGraphicsScene(self)

self.scene.setSceneRect(0, 0, PageSize[0], PageSize[1])

self.addBorders()

self.view.setScene(self.scene)

We have not shown the imports, but they include functools. This is needed because in the context menu we use the functools.partial() function to wrap the methods to call with a suitable argument.

The main form's initializer is quite long, so we will look at it in parts but omit code that is similar to what we have seen elsewhere—for example, where we create and lay out the form's buttons.

Table 12.1 Selected QGraphicsScene Methods

Syntax s.addEllipse(r, pn, b) s.addltem(g)

s.addPixmap(px) s.addPolygon(pg, pn, b)

s.addText(t, f) s.collidingltems(g)

s.removeltem(g) s.render(p)

s.setBackgroundBrush(b)

s.update()

s.views(

Description

Adds an ellipse bounded by QRectF r, outlined by QPen pn and filled with QBrush b, to QGraphicsScene s

Adds QGraphicsItem g to QGraphicsScene s.The other add*() methods are conveniences for creating and adding some of the built-in graphics items. Adds QLineF l, drawn with QPen pn, to s

Adds QPainterPath pp, outlined by QPen pn and filled with QBrush b, to QGraphicsScene s Adds QPixmap px to QGraphicsScene s

Adds QPolygon pg, outlined by QPen pn and filled with QBrush b, to QGraphicsScene s

Adds QRect r, outlined by QPen pn and filled with

QBrush b, to QGraphicsScene s

Adds text t using QFont f, to QGraphicsScene s

Returns a (possibly empty) list of the QGraphics-Items that QGraphicsItem g collides with Returns all the QGraphicsItems in QGraphicsScene s; using different arguments, those items that are at a particular point, or that are within or that intersect with a given rectangle, polygon, or painter path, can be returned Removes QGraphicsItem g from QGraphicsScene s; ownership passes to the caller Renders QGraphicsScene s on QPainter p; additional arguments can be used to control the source and destination rectangle

Sets QGraphicsScene s's background to QBrush b

Sets QGraphicsScene s's rectangle to position (x, y), with width w and height h; the arguments are floats

Schedules a paint event for QGraphicsScene s

Returns a (possibly empty) list of QGraphicsViews that are showing QGraphicsScene s

We create an instance of our custom GraphicsView class, as well as a standard QGraphicsScene. The rectangle we set on the scene is the "window", that is, the logical coordinate system that the scene will use—in this case, a rectangle with a top-left point of (0,0), and a width and height corresponding to the page's size in points.

The rest of the initializer creates and connects the buttons, and lays out the buttons and the view.

def addBorders(self): self.borders = []

rect = QRectF(0, 0, PageSize[0], PageSize[1]) self.borders.append(self.scene.addRect(rect, Qt.yellow)) margin = 5.25 * PointSize self.borders.append(self.scene.addRect(

rect.adjusted(margin, margin, -margin, -margin), Qt.yellow))

This method creates two QGraphicsRectItems, the first corresponding to the size of a page and the second (indicating the margins) inside the first. The QRect.adjusted() method returns a rectangle with its top-left and bottom-right points adjusted by the two sets of dx and dy pairs. In this case, the top left is moved right and down (by each being increased by margin amount) and the bottom right is moved left and up (by each being reduced by margin amount).

def removeBorders(self): while self.borders:

item = self.borders.pop() self.scene.removeItem(item) del item

When we print or save we do not want to include the borders. This method destructively retrieves each item from the self.borders list (in a random order), and removes the items from the scene. When an item is removed from a scene the scene automatically notifies its views so that they can repaint the uncovered area. An alternative to deleting is to call setVisible(False) to hide the borders.

The call to QGraphicsScene.removeItem() removes the item (and its children) from the scene, but it does not delete the item, instead passing ownership to its caller. So after the removeItem() call, the item still exists. We could just leave the item to be deleted when each item reference goes out of scope, but we prefer to explicitly delete the items to make it clear that we have taken ownership and really are deleting them.

def addPixmap(self):

path = QFileInfo(self.filename).path() \

if not self.filename.isEmpty() else "." fname = QFileDialog.getOpenFileName(self,

"Page Designer - Add Pixmap", path, "Pixmap Files (*.bmp *.jpg *.png *.xpm)")

if fname.isEmpty():

return self.createPixmapItem(QPixmap(fname), self.position())

When the user clicks the Add Pixmap button this method is called. We simply obtain the name of the image file the user wants to add to the page, and pass the work on to a createPixmapItem() method. We don't do everything in one method because splitting the functionality is more convenient—for example, for when we load pixmapsfrom a Page Designer .pgd file. The position() method is used to get the position where an item should be added; we will review it shortly.

def createPixmapItem(self, pixmap, position, matrix=QMatrix()): item = QGraphicsPixmapItem(pixmap) item.setFlags(QGraphicsItem.ItemIsSelectable|

QGraphicsItem.ItemIsMovable) item.setPos(position) item.setMatrix(matrix) self.scene.clearSelection() self.scene.addItem(item) item.setSelected(True) global Dirty Dirty = True

The graphics view classes include QGraphicsPixmapItem which is perfect for showing images in scenes. QGraphicsItem's have three flags in Qt 4.2, ItemIs-Movable, ItemIsSelectable and ItemIsFocusable. (Qt 4.3 adds ItemClipsToShape, ItemClipsChildrenToShape, and ItemIgnoresTransformations, this last particularly useful for showing text that we don't want the view to transform.)

Having created the item, we set its position in the scene. The setPos() method is the only item method that works in terms of scene coordinates; all the others work in item local logical coordinates. We do not have to set a transformation matrix (and the one returned by QMatrix() is the identity matrix), but we want an explicit matrix so that we can use it when we come to save and load (or copy and paste) the scene's items.*

The QMatrix class holds a 3 x 3 matrix and is specifically designed for graphical transformations, rather than being a general matrix class. As such, it is a rare example of a poorly named Qt class. From Qt 4.3, QMatrix has been superceded by the more sensibly named QTransform class, which is also capable of more powerful transformations since it uses a 4 x 4 matrix.

Once the item is set up, we clear any existing selections and add the item to the scene. Then we select it, ready for the user to interact with it.

def position(self):

point = self.mapFromGlobal(QCursor.pos()) if not self.view.geometry().contains(point):

*An identity matrix in this context is one that, when set, causes no transformations to occur.

coord = random.randint(36, 144) point = QPoint(coord, coord) else:

if point == self.prevPoint:

point += QPoint(self.addOffset, self.addOffset) self.addOffset += 5 else:

self.addOffset = 5 self.prevPoint = point return self.view.mapToScene(point)

This method is used to provide a position in the scene where a newly added item should go. If the mouse is over the view, we use the mouse position provided by QCursor.pos()—"cursor" in this context means mouse cursor—but add an offset if an item has just been added at the same place. This means that if the user repeatedly presses an Add button, each successive item will be offset from the one before, making it easier for the user to see and interact with them. If the mouse is outside the view, we put the item at a semirandom position near the top left of the scene.

The mapFromGlobal() method converts a screen coordinate into a physical widget coordinate as used by the view. But scenes use their own logical coordinate system, so we must use QGraphicsView.mapToScene() to convert the physical coordinate into a scene coordinate.

def addText(self):

dialog = TextItemDlg(position=self.position(), scene=self.scene, parent=self)

dialog.exec_()

This method is called when the user clicks the Add Text button. It pops up a smart add/edit item dialog, shown in Figure 12.3. If the user clicks OK, a new item is added with the text and font of their choice. We won't discuss the dialog, since it isn't relevant to graphics programming; its source code is in chap12/pagedesigner.pyw.

We do not need to keep a reference to the added item because we pass ownership of it to the scene inside the smart dialog.

def addBox(self):

BoxItem(self.position(), self.scene)

This method is called when the user clicks the Add Box button. The user can resize the box, even turning it into a line (by reducing the width or height to 1 pixel) by using the arrow keys, as we will see.

Again, we don't need to keep a reference to the added box item, because ownership is given to the scene.

Page Designer - Add Text item

Page Designer - Add Text item tucÂed tjfi warm in- a itttfe ùa&Âet bedstead^ carefîdfa-t/ùsfio&ef/ orb a- ¿out settee, ùntnt t/itiit'/y /// frttftf <t/ f/tt' fire, cvul cfW to- it, asjf/ii& cofi&thutiotir toetv, cuutlogou& ta tJiat, pf'a mgffirit, arid 'it toas, e&setiltal to toast fustb ¿troton, toAile he toa& «vy neut-.

tucÂed tjfi warm in- a itttfe ùa&Âet bedstead^ carefîdfa-t/ùsfio&ef/ orb a- ¿out settee, ùntnt t/itiit'/y /// frttftf <t/ f/tt' fire, cvul cfW to- it, asjf/ii& cofi&thutiotir toetv, cuutlogou& ta tJiat, pf'a mgffirit, arid 'it toas, e&setiltal to toast fustb ¿troton, toAile he toa& «vy neut-.

Font: Snell Roundhand BEack|

Figure 12.3 Adding a new text item

We want the user to be able to cut, copy, and paste items inside Page Designer, but since the items are not meaningful for other applications we will not use the clipboard.

def copy(self):

item = self.selectedItem() if item is None: return self.copiedItem.clear() self.pasteOffset = 5

stream = QDataStream(self.copiedItem, QIODevice.WriteOnly) self.writeItemToStream(stream, item)

If the user invokes the copy action we start by seeing whether there is exactly one selected item. If there is, we clear the copied item byte array, and create a data stream to write to the byte array. There is no need to use QDataStream.set-Version() because the data stream is used only for cutting, copying, and pasting during a single run of the application, so using whatever happens to be the current version is fine. We will look at the writeItemToStream() and the corresponding readItemFromStream() methods later.

def selectedItem(self):

This method returns the one selected item, or None if there are no selected items or if there are two or more selected items. The QGraphicsScene.selectedItems() method returns a list of the selected items. There are also items() methods that return lists of the items that intersect a particular point or are inside a particular rectangle or polygon, and there is a collidingItems() method to report collisions.

items = self.scene.selectedItems() if len(items) == 1:

return items[0] return None def cut(self):

item = self.selectedItem() if item is None:

return self.copy()

self.scene.removeItem(item) del item

This method copies the selected item using copy(), and then removes it from the scene. As mentioned when we discussed removing the border rectangles, removeItem() only removes an item from the scene; it does not delete the item. We could leave the item to be deleted when the item reference goes out of scope, but we prefer to explicitly delete it to make it clear that we have taken ownership and are really deleting the item.

def paste(self):

if self.copiedItem.isEmpty(): return stream = QDataStream(self.copiedItem, QIODevice.ReadOnly) self.readItemFromStream(stream, self.pasteOffset)

If an item has been cut or copied to the copied item, we simply create a data stream and read the item's data from the copied item byte array. The read-ItemFromStream() method takes care of creating the item and adding it to the scene.

def writeItemToStream(self, stream, item): if isinstance(item, QGraphicsTextItem):

stream << QString("Text") << item.pos() << item.matrix() \ << item.toPlainText() << item.font() elif isinstance(item, QGraphicsPixmapItem): stream << QString("Pixmap") << item.pos() \ << item.matrix() << item.pixmap() elif isinstance(item, BoxItem):

stream << QString("Box") << item.pos() << item.matrix() \

<< item.rect stream.writeInt16(item.style)

This method is used by copy(), cut() (indirectly), and save(). For each item it writes a string that describes the item's type, then the item's position and transformation matrix, and then any extra item-specific data. For text items, the extra data is the item's text and font; for pixmap items, the extra data is the pixmap itself—which means that the .pgd file could be quite large; and for boxes, the extra data is the box's rectangle and line style.

def readItemFromStream(self, stream, offset=0): type = QString() position = QPointF()

matrix = QMatrix()

stream >> type >> position >> matrix if offset:

position += QPointF(offset, offset) if type == "Text": text = QString() font = QFont() stream >> text >> font

TextItem(text, position, self.scene, font, matrix) elif type == "Box": rect = QRectF() stream >> rect style = Qt.PenStyle(stream.readInt16()) BoxItem(position, self.scene, style, rect, matrix) elif type == "Pixmap": pixmap = QPixmap() stream >> pixmap self.createPixmapItem(pixmap, position, matrix)

This method is used both by paste() and by open() (which loads a .pgd file). It begins by reading in the type, position, and matrix which are stored for every type of item. Then, it adjusts the position by the offset—this is used only if the item is being pasted. Next, the item-specific data is read and a suitable item created using the data that has been gathered.

The TextItem and BoxItem initializers, and the createPixmapItem() method, all create the appropriate graphics items and pass ownership to the scene.

def rotate(self):

for item in self.scene.selectedItems(): item.rotate(30)

If the user clicks Rotate, any selected items are rotated by 30°. There are no child items used in this application, but if any of the rotated items had child items, these too would be rotated.

def delete(self):

items = self.scene.selectedItems() if len(items) and QMessageBox.question(self, "Page Designer - Delete", "Delete %d item%s?" % (len(items), "s" if len(items) != 1 else ""), QMessageBox.Yes|QMessageBox.No) == QMessageBox.Yes: while items:

item = items.pop() self.scene.removeItem(item) del item global Dirty

Dirty = True

If the user clicks Delete and there is at least one selected item, they are asked whether they want to delete the selected items, and if they do, each selected item is deleted.

def print_(self):

dialog = QPrintDialog(self.printer) if dialog.exec_():

painter = QPainter(self.printer)

painter.setRenderHint(QPainter.Antialiasing)

painter.setRenderHint(QPainter.TextAntialiasing)

self.scene.clearSelection()

self.removeBorders()

self.scene.render(painter)

self.addBorders()

A QPrinter is a paint device, just like a QWidget or a QImage, so we can easily paint onto a printer. Here we have taken advantage of the QGraphicsScene.render() convenience method, which paints the entire scene (or a selected portion of it) onto a paint device. Before painting, we remove the borders (the yellow rectangles), and after painting we restore the borders. We also clear the selection before painting, since some items may be rendered differently if they are selected. A similar QGraphicsView.render() method can be used to render the scene (or a selected portion of it) as seen.

We will omit the code for saving and loading .pgd files, since it is very similar to what we have seen before when working with binary files. For saving, we create a QDataStream, call setVersion() on it, and write a magic number and a file version. Then we iterate over all the items in the scene, calling writeIt-emToStream() parameterized by the data stream and by the item for each call. For loading, we also create a QDataStream. Then we read in the magic number and file version, and if they are correct, we delete all the existing items. As long as the file has data in it, we call readItemFromStream() parameterized by the stream. This method reads the item data and creates the items, adding them to the scene as it goes.

We have seen how the application works as a whole, and how to create and use items of two of the predefined graphics item classes, namely, QGraphicsRectItem and QGraphicsPixmapItem. Now we will turn our attention to custom graphics view items. We will begin by looking at the TextItem subclass; this extends the QGraphicsTextItem class with additional behavior, but leaves all the drawing to the base class. Then we will look at the BoxItem class; this class has code for both behavior and drawing.

class TextItem(QGraphicsTextItem):

def __init__(self, text, position, scene, font=QFont("Times", PointSize), matrix=QMatrix()):

super(TextItem, self)._init_(text)

self.setFlags(QGraphicsItem.ItemIsSelectable|

QGraphicsItem.ItemIsMovable) self.setFont(font) self.setPos(position) self.setMatrix(matrix) scene.clearSelection() scene.addItem(self) self.setSelected(True) global Dirty Dirty = True

The TextItem's initializer is very similar to the createPixmapItem() method that creates and initializes QGraphicsPixmapItems. We provide a default font and a default matrix (the identity matrix) if none is supplied to the initializer.

def parentWidget(self):

return self.scene().views()[0]

An item's parent is either another item or a scene. But sometimes we need to know the visible widget in which the item appears, that is, the item's view. The scene is available to items and can return a list of the views that are showing the scene. For convenience, we have assumed that there is always at least one view showing our scene and that we consider the first view to be the "parent" view.

def itemChange(self, change, variant):

if change != QGraphicsItem.ItemSelectedChange: global Dirty Dirty = True return QGraphicsTextItem.itemChange(self, change, variant)

If the user interacts with an item—for example, moving or selecting it—this method is called. If the interaction is not merely a change in selection status, we set the global dirty flag.

Two caveats apply to itemChange() reimplementations. First, we must always return the result of calling the base class implementation, and second, we must never do anything inside this method that will lead to another (recursive) item-Change() call. In particular, we must never call setPos() inside itemChange().

def mouseDoubleClickEvent(self, event):

dialog = TextItemDlg(self, self.parentWidget()) dialog.exec_()

If the user double-clicks the item, we pop up a smart dialog through which the user can change the item's text and font. This is the same dialog that we used for adding a text item.

Was this article helpful?

+5 -4
Video Marketing Gold

Video Marketing Gold

Do you already know the huge impact that video could have on your online business BUT have no idea where to begin with it? Discover Exactly How You Can Start Taking Advantage of Video Marketing In Your Online Business... Even If You're a Total Newbie... Starting Today.

Get My Free Ebook


Responses

  • Mebrahtu
    How to add Line between two images in qgraphicsview using pyqt?
    8 years ago
  • anna
    How to create a rubber band selection box in pygame?
    8 years ago
  • Procopio
    Where to print stuff pyqt application?
    8 years ago
  • kristen hopwood
    How to add QGraphicsitem when clicked QGraphicsscene?
    8 years ago
  • stanley brown
    How to create a subclass of qgraphicsitem?
    8 years ago
  • Zemzem
    How to select qgraphicsitem?
    8 years ago
  • Mackenzie
    How to create custom QGraphicsPixmapItem class in qt?
    8 years ago
  • doris andersen
    How to highlight a qgraphicspixmapitem on double click?
    7 years ago
  • mehari
    How to remove qgraphicstextitem dotted box in qt?
    4 years ago

Post a comment