Graphics with QCanvas

QCanvas offers a higher-level interface for doing graphics than QPainter provides. A QCanvas can contain items of any shape and uses double buffering internally to avoid flicker. For applications that need to present many user-manipulable items, like data visualization programs and 2D games, using QCanvas is often a better approach than reimplementing QWidget::paintEvent() or QScrollView::drawContents() and painting everything manually.

The items shown on a QCanvas are instances of QCanvasItem or of one of its subclasses. Qt provides a useful set of predefined subclasses: QCanvasLine, QCanvasRectangle, QCanvasPolygon, QCanvasPolygonalItem, QCanvasEllipse, QCanvasSpline, QCanvasSprite, and QCanvasText. These classes can themselves be subclassed to provide custom canvas items.

A QCanvas and its QCanvasItems are purely data and have no visual representation. To render the canvas and its items, we must use a QCanvasView widget. This separation of the data from its visual representation makes it possible to have multiple QCanvasView widgets visualizing the same canvas. Each of these QCanvasViews can present its own portion of the canvas, possibly with different transformation matrices.

QCanvas is highly optimized to handle a large number of items. When an item changes, QCanvas only redraws the "chunks" that have changed. It also provides an efficient collision-detection algorithm. For these reasons alone, it's worth considering QCanvas as an alternative to reimplementing QWidget::paintEvent() or QScrollView::drawContents().

Figure 8.9. The DiagramView widget

To demonstrate QCanvas usage, we present the code for the DiagramView widget, a minimalist diagram editor. The widget supports two kinds of shapes (boxes and lines) and provides a context menu that lets the user add new boxes and lines, copy and paste them, delete them, and edit their properties.

class DiagramView : public QCanvasView { Q_OBJECT public: DiagramView(QCanvas *canvas, QWidget *parent = 0, const char *name = 0); public slots: void cut(); void copy(); void paste(); void del(); void properties(); void addBox(); void addLine(); void bringToFront(); void sendToBack();

The DiagramView class inherits QCanvasView, which itself inherits QScrollView. It provides many public slots that an application could connect to. The slots are also used by the widget itself to implement its context menu.

protected: void contentsContextMenuEvent(QContextMenuEvent *event); void contentsMousePressEvent(QMouseEvent *event); void contentsMouseMoveEvent(QMouseEvent *event); void contentsMouseDoubleClickEvent(QMouseEvent *event); private: void createActions(); void addItem(QCanvasItem *item); void setActiveItem(QCanvasItem *item); void showNewItem(QCanvasItem *item); QCanvasItem *pendingItem; QCanvasItem *activeItem; QPoint lastPos; int minZ; int maxZ; QAction *cutAct; QAction *copyAct; ... QAction *sendToBackAct; };

The protected and private members of the class will be explained shortly.

Figure 8.10. The DiagramBox and DiagramLine canvas items

Along with the DiagramView class, we also need to define two custom canvas item classes to represent the shapes we want to draw. We will call these classes DiagramBox and DiagramLine.

class DiagramBox : public QCanvasRectangle { public: enum { RTTI = 1001 }; DiagramBox(QCanvas *canvas); ~DiagramBox(); void setText(const QString &newText); QString text() const { return str; } void drawShape(QPainter &painter); QRect boundingRect() const; int rtti() const { return RTTI; } private: QString str; };

The DiagramBox class is a type of canvas item that displays a box and a piece of text. It inherits some of its functionality from QCanvasRectangle, a QCanvasItem subclass that displays a rectangle. To QCanvasRectangle we add the ability to show some text in the middle of the rectangle and the ability to show tiny squares ("handles") at each corner to indicate that an item is active. In a realworld application, we would make it possible to click and drag the handles to resize the box, but to keep the code short we will not do so here.

The rtti() function is reimplemented from QCanvasItem. Its name stands for "run-time type identification", and by comparing its return value with the RTTI constant, we can determine whether an arbitrary item in the canvas is a DiagramBox or not. We could perform the same check using C++'s dynamic_cast() mechanism, but that would restrict us to C++ compilers that support this feature.

The value of 1001 is arbitrary. Any value above 1000 is acceptable, as long as it doesn't collide with other item types used in the same application.

class DiagramLine : public QCanvasLine { public: enum { RTTI = 1002 }; DiagramLine(QCanvas *canvas); ~DiagramLine(); QPoint offset() const { return QPoint((int)x(), (int)y()); } void drawShape(QPainter &painter); QPointArray areaPoints() const; int rtti() const { return RTTI; } };

The DiagramLine class is a canvas item that displays a line. It inherits some of its functionality from QCanvasLine, and adds the ability to show handles at each end to indicate that the line is active.

Now we will review the implementations of these three classes.

DiagramView::DiagramView(QCanvas *canvas, QWidget *parent, const char *name) : QCanvasView(canvas, parent, name) { pendingItem = 0; activeItem = 0; minZ = 0; maxZ = 0; createActions(); }

The DiagramView constructor takes a canvas as its first argument and passes it on to the base class constructor. The DiagramView will show this canvas.

The QActions are created in the createActions() private function. We have implemented several versions of this function in earlier chapters, and this one follows the same pattern, so we will not reproduce it here.

void DiagramView::contentsContextMenuEvent(QContextMenuEvent *event) { QPopupMenu contextMenu(this); if (activeItem) { cutAct->addTo(&contextMenu); copyAct->addTo(&contextMenu); deleteAct->addTo(&contextMenu); contextMenu.insertSeparator(); bringToFrontAct->addTo(&contextMenu); sendToBackAct->addTo(&contextMenu); contextMenu.insertSeparator(); propertiesAct->addTo(&contextMenu); } else { pasteAct->addTo(&contextMenu); contextMenu.insertSeparator(); addBoxAct->addTo(&contextMenu); addLineAct->addTo(&contextMenu); } contextMenu.exec(event->globalPos()); }

The contentsContextMenuEvent() function is reimplemented from QScrollView to create a context menu.

Figure 8.11. The DiagramView widget's context menus

If an item is active, the menu is populated with the actions that make sense on an item: Cut, Copy, Delete, Bring to Front, Send to Back, and Properties. Otherwise, the menu is populated with Paste, Add Box, and Add Line.

void DiagramView::addBox() { addItem(new DiagramBox(canvas())); } void DiagramView::addLine() { addItem(new DiagramLine(canvas())); }

The addBox() and addLine() slots create a DiagramBox or a DiagramLine item on the canvas and then call addItem() to perform the rest of the work.

void DiagramView::addItem(QCanvasItem *item) { delete pendingItem; pendingItem = item; setActiveItem(0); setCursor(crossCursor); }

The addItem() private function changes the cursor to a crosshair and sets pendingItem to be the newly created item. The item is not visible in the canvas until we call show() on it.

When the user chooses Add Box or Add Line from the context menu, the cursor changes to a crosshair. The item is not actually added until the user clicks on the canvas.

void DiagramView::contentsMousePressEvent(QMouseEvent *event) { if (event->button() == LeftButton && pendingItem) { pendingItem->move(event->pos().x(), event->pos().y()); showNewItem(pendingItem); pendingItem = 0; unsetCursor(); } else { QCanvasItemList items = canvas()->collisions(event->pos()); if (items.empty()) setActiveItem(0); else setActiveItem(*items.begin()); } lastPos = event->pos(); }

If users press the left mouse button while the cursor is a crosshair, they have already asked to create a box or line, and have now clicked the canvas at the position where they want the new item to appear. We move the "pending" item to the position of the click, show it, and reset the cursor to the normal arrow cursor.

Any other mouse press event on the canvas is interpreted as an attempt to select or deselect an item. We call collisions() on the canvas to obtain a list of all the items under the cursor and make the first item the current item. If the list contains many items, the first one is always the one that is rendered on top of the others.

void DiagramView::contentsMouseMoveEvent(QMouseEvent *event) { if (event->state() & LeftButton) { if (activeItem) { activeItem->moveBy(event->pos().x() - lastPos.x(), event->pos().y() - lastPos.y()); lastPos = event->pos(); canvas()->update(); } } }

The user can move an item on the canvas by pressing the left mouse button on an item and dragging. Each time we get a mouse move event, we move the item by the horizontal and vertical distance by which the mouse moved and call update() on the canvas. Whenever we modify a canvas item, we must call update() to notify the canvas that it needs to redraw itself.

void DiagramView::contentsMouseDoubleClickEvent(QMouseEvent *event) { if (event->button() == LeftButton && activeItem && activeItem->rtti() == DiagramBox::RTTI) { DiagramBox *box = (DiagramBox *)activeItem; bool ok; QString newText = QInputDialog::getText( tr("Diagram"), tr("Enter new text:"), QLineEdit::Normal, box->text(), &ok, this); if (ok) { box->setText(newText); canvas()->update(); } } }

If the user double-clicks an item, we call the item's rtti() function and compare its return value with DiagramBox::RTTI (defined as 1001).

Figure 8.12. Changing the text of a DiagramBox item

If the item is a DiagramBox, we pop up a QInputDialog to allow the user to change the text shown in the box. The QInputDialog class provides a label, a line editor, an OK button, and a Cancel button.

void DiagramView::bringToFront() { if (activeItem) { ++maxZ; activeItem->setZ(maxZ); canvas()->update(); } }

The bringToFront() slot raises the currently active item to be on top of the other items in the canvas. This is accomplished by setting the item's z coordinate to a value that is higher than any other value attributed to an item so far. When two items occupy the same (x, y) position, the item that has the highest z value is shown in front of the other item. (If the z values are equal, QCanvas will break the tie by comparing the item pointers.)

void DiagramView::sendToBack() { if (activeItem) { --minZ; activeItem->setZ(minZ); canvas()->update(); } }

The sendToBack() slot puts the currently active item behind all the other items in the canvas. This is done by setting the item's z coordinate to a value that is lower than any other z value attributed to an item so far.

void DiagramView::cut() { copy(); del(); }

The cut() slot is trivial.

void DiagramView::copy() { if (activeItem) { QString str; if (activeItem->rtti() == DiagramBox::RTTI) { DiagramBox *box = (DiagramBox *) activeItem; str = QString("DiagramBox %1 %2 %3 %4 %5") .arg(box->width()) .arg(box->height()) .arg(box->pen().color().name()) .arg(box->brush().color().name()) .arg(box->text()); } else if (activeItem->rtti() == DiagramLine::RTTI) { DiagramLine * line = (DiagramLine *)activeItem; QPoint delta = line->endPoint() - line->startPoint(); str = QString("DiagramLine %1 %2 %3") .arg(delta.x()) .arg(delta.y()) .arg(line->pen().color().name()); } QApplication::clipboard()->setText(str); } }

The copy() slot converts the active item into a string and copies the string to the clipboard. The string contains all the information necessary to reconstruct the item. For example, a black-on-white 320 x 40 box containing "My Left Foot" would be represented by this string:

DiagramBox 320 40 #000000 #ffffff My Left Foot

We don't bother storing the position of the item on the canvas. When we paste the item, we simply put the duplicate near the canvas's top-left corner. Converting an object to a string is an easy way to add clipboard support, but it is also possible to put arbitrary binary data onto the clipboard, as we will see in Chapter 9 (Drag and Drop).

void DiagramView::paste() { QString str = QApplication::clipboard()->text(); QTextIStream in(&str); QString tag; in >> tag; if (tag == "DiagramBox") { int width; int height; QString lineColor; QString fillColor; QString text; in >> width >> height >> lineColor >>fillColor; text = in.read(); DiagramBox *box = new DiagramBox(canvas()); box->move(20, 20); box->setSize(width, height); box->setText(text); box->setPen(QColor(lineColor)); box->setBrush(QColor(fillColor)); showNewItem(box); } else if (tag == "DiagramLine") { int deltaX; int deltaY; QString lineColor; in >> deltaX >> deltaY >> lineColor; DiagramLine *line = new DiagramLine(canvas()); line->move(20, 20); line->setPoints(0, 0, deltaX, deltaY); line->setPen(QColor(lineColor)); showNewItem(line); } }

The paste() slot uses QTextIStream to parse the contents of the clipboard. QTextIStream works on whitespace-delimited fields in a similar way to cin. We extract each field using the >> operator, except the last field of the DiagramBox item, which might contain spaces. For this field, we use QTextStream::read(), which reads in the rest of the string.

void DiagramView::del() { if (activeItem) { QCanvasItem *item = activeItem; setActiveItem(0); delete item; canvas()->update(); } }

The del() slot deletes the active item and calls QCanvas::update() to redraw the canvas.

void DiagramView::properties() { if (activeItem) { PropertiesDialog dialog; dialog.exec(activeItem); } }

The properties() slot pops up a Properties dialog for the active item. The PropertiesDialog class is a "smart" dialog; we simply need to pass it a pointer to the item we want it to act on and it takes care of the rest.

Figure 8.13. The Properties dialog's two appearances

The .ui and .ui.h files for the PropertiesDialog are on the CD that accompanies this book.

void DiagramView::showNewItem(QCanvasItem *item) { setActiveItem(item); bringToFront(); item->show(); canvas()->update(); }

The showNewItem() private function is called from a few places in the code to make a newly created canvas item visible and active.

void DiagramView::setActiveItem(QCanvasItem *item) { if (item != activeItem) { if (activeItem) activeItem->setActive(false); activeItem = item; if (activeItem) activeItem->setActive(true); canvas()->update(); } }

Finally, the setActiveItem() private function clears the old active item's "active" flag, sets the activeItem variable, and sets the new active item's flag. The item's "active" flag is stored in QCanvasItem. Qt doesn't use the flag itself; it is provided purely for the convenience of subclasses. We use the flag in the DiagramBox and DiagramLine subclasses because we want them to paint themselves differently depending on whether they are active or not.

Let's now review the code for DiagramBox and DiagramLine.

const int Margin = 2; void drawActiveHandle(QPainter &painter, const QPoint ¢er) { painter.setPen(Qt::black); painter.setBrush(Qt::gray); painter.drawRect(center.x() - Margin, center.y() - Margin, 2 * Margin + 1, 2 * Margin + 1); }

The drawActiveHandle() function is used by both DiagramBox and DiagramLine to draw a tiny square indicating that an item is the active item.

DiagramBox::DiagramBox(QCanvas *canvas) : QCanvasRectangle (canvas) { setSize(100, 60); setPen(black); setBrush(white); str = "Text"; }

In the DiagramBox constructor, we set the size of the rectangle to 100 x 60. We also set the pen color to black and the brush color to white. The pen color is used to draw the box outline and the text, while the brush color is used for the background of the box.

DiagramBox::~DiagramBox() { hide(); }

The DiagramBox destructor calls hide() on the item. This is necessary for all classes that inherit from QCanvasPolygonlItem (QCanvasRectangle's base class) because of the way QCanvasPolygonalItem works.

void DiagramBox::setText(const QString &newText) { str = newText; update(); }

The setText() function sets the text shown in the box and calls QCanvasItem::update() to mark this item as changed. The next time the canvas repaints itself, it will know that it must repaint this item.

void DiagramBox::drawShape(QPainter &painter) { QCanvasRectangle::drawShape(painter); painter.drawText(rect(), AlignCenter, text()); )if (isActive()) { drawActiveHandle(painter, rect().topLeft()); drawActiveHandle(painter, rect().topRight()); drawActiveHandle(painter, rect().bottomLeft()); drawActiveHandle(painter, rect().bottomRight()); } }

The drawShape() function is reimplemented from QCanvasPolygonalItem to draw the text, and if the item is active, the four handles. We use the base class to draw the rectangle itself.

QRect DiagramBox::boundingRect() const { return QRect((int)x() - Margin, (int)y() - Margin, width() + 2 * Margin, height() + 2 * Margin); }

The boundingRect() function is reimplemented from QCanvasItem. It is used by QCanvas to perform collision-detection and to optimize painting. The rectangle it returns must be at least as large as the area painted in drawShape().

The default QCanvasRectangle implementation is not sufficient, because it does not take into account the handles that we paint at each corner of the rectangle if the item is active.

DiagramLine::DiagramLine(QCanvas *canvas) : QCanvasLine(canvas) { setPoints(0, 0, 0, 99); }

In the DiagramLine constructor, we set the two points that define the line to be (0, 0) and (0, 99). The result is a 100-pixel-long vertical line.

DiagramLine::~DiagramLine() { hide(); }

Again, we must call hide() in the destructor.

void DiagramLine::drawShape(QPainter &painter) { QCanvasLine::drawShape(painter); if (isActive()) { drawActiveHandle(painter, startPoint() + offset()); drawActiveHandle(painter, endPoint() + offset()); } }

The drawShape() function is reimplemented from QCanvasLine to draw handles at both ends of the line if the item is active. We use the base class to draw the line itself. The offset() function was implemented in the DiagramLine class definition. It returns the position of the item on the canvas.

QPointArray DiagramLine::areaPoints() const { const int Extra = Margin + 1; QPointArray points(6); QPoint pointA = startPoint() + offset(); QPoint pointB = endPoint() + offset(); if (pointA.x() > pointB.x()) swap(pointA, pointB); points[0] = pointA + QPoint(-Extra, -Extra); points[1] = pointA + QPoint(-Extra, +Extra); points[3] = pointB + QPoint(+Extra, +Extra); points[4] = pointB + QPoint(+Extra, -Extra); if (pointA.y() > pointB.y()) { points[2] = pointA + QPoint(+Extra, +Extra); points[5] = pointB + QPoint(-Extra, -Extra); } else { points[2] = pointB + QPoint(-Extra, +Extra); points[5] = pointA + QPoint(+Extra, -Extra); } return points; }

The areaPoints() function plays a similar role to the boundingRect() function in DiagramBox. For a diagonal line, and indeed for most polygons, a bounding rectangle is too crude an approximation. For these, we must reimplement areaPoints() and return the outline of the area painted by the item. The QCanvasLine implementation already returns a decent outline for a line, but it doesn't take the handles into account.

The first thing we do is to store the two points in pointA and pointB and to ensure that pointA is to the left of pointB, by swapping them if necessary using swap() (defined in ). Then there are only two cases to consider: ascending and descending lines.

The bounding area of a line is always represented by six points, but these points vary depending on whether the line is ascending or descending. Nevertheless, four of the six points(numbered 0, 1, 3, and 4) are the same in both cases. For example, points 0 and 1 are always located at the top-left and bottom-left corners of handle A; in contrast, point 2 is located at the bottom-right corner of handle A for an ascending line and at the bottom-left corner of handle B for a descending line.

Figure 8.14. The bounding area of a DiagramLine

Considering how little code we have written, the DiagramView widget already provides considerable functionality, with support for selecting and moving items and for context menus.

One thing that is missing is that the handles shown when an item is active cannot be dragged to resize the item. If we wanted to change that, we would probably take a different approach to the one we have used here. Instead of drawing the handles in the items' drawShape() functions, we would probably make each handle a canvas item. If we wanted the cursor to change when hovering over a handle, we would call setCursor() in real time as it is moved. For this to work, we would need to call setMouseTracking(true) first, because normally Qt only sends mouse move events when a mouse button is pressed.

Another obvious improvement would be to support multiple selections and item grouping. The Qt Quarterly article "Canvas Item Groupies", available online at http://doc.trolltech.com/qq/qq05-canvasitemgrouping.html, presents one way to achieve this.

This section has provided a working example of QCanvas and QCanvasView use, but it has not covered all of QCanvas's functionality. For example, canvas items can be set to move on the canvas at regular intervals by calling setVelocity(). See the documentation for QCanvas and its related classes for the details.

Категории