Custom Views

PyQt provides several view classes that work well out of the box, including QListView, QTableView, and QTreeView. One thing that all these views have in common is that they are usually used to present data items textually—although all of them can also show icons and checkboxes if desired. An alternative to textual representations of data are visual representations, and for these we can use the graphics view classes covered in Chapter 12. Sometimes, though, we want to present data in a way that doesn't really match any of the classes that are available. In such cases we can create our own view subclass and use it to visualize our models.

Qtableview Python Start
Figure 16.1 Two views of water quality data

Figure 16.1 shows the same dataset presented by two different views. The left-hand view is a standard QTableView, and the right-hand view is a custom Water-QualityView. Both show the timestamps of water quality readings textually, but the WaterQualityView shows colored circles for three key indicators, and it uses Unicode arrow symbols to signify special flow situations. Obviously, the table view presents the facts in a clear and accurate way, but the water quality view makes it easier to see what the situation is at any particular time, and it makes it easier to get an impression of any important trends, just by looking at the colors.

The water quality dataset covers a six-month period at one small water treatment plant—but with readings taken every 15 minutes, this adds up to slightly more than 17 500 readings. This implies that our view is going to need a vertical scrollbar. PyQt offers three ways to get scrollbars. One way is to create a widget that inherits QAbstractScrollArea; this approach is used by the QGraphicsView and QTextEdit widgets. Another way is to create a composite widget that includes a couple of QScrollBars. But PyQt's documentation recommends the third way—using the much simpler QScrollArea instead. The one disadvantage of using QScrollArea is that it is one of the few PyQt classes not designed to be subclassed. Instead, we must create an instance and add to it the widget for which we want scrollbars. To put this into perspective here is the Water Quality Data application's initializer:

class MainForm(QDialog):

def_init_(self, parent=None):

super(MainForm, self)._init_(parent)

self.model = WaterQualityModel(os.path.join(

os.path.dirname(_file_), "waterdata.csv.gz"))

self.tableView = QTableView() self.tableView.setAlternatingRowColors(True) self.tableView.setModel(self.model) self.waterView = WaterQualityView() self.waterView.setModel(self.model) scrollArea = QScrollArea() scrollArea.setBackgroundRole(QPalette.Light) scrollArea.setWidget(self.waterView) self.waterView.scrollarea = scrollArea splitter = QSplitter(Qt.Horizontal)

splitter.addWidget(self.tableView)

splitter.addWidget(scrollArea)

splitter.setSizes([600, 250])

layout = QHBoxLayout()

layout.addWidget(splitter)

self.setLayout(layout)

self.setWindowTitle("Water Quality Data") QTimer.singleShot(0, self.initialLoad)

The preceding code is the whole thing. The WaterQualityModel is a QAbstract-TableModel subclass that provides read-only access to a water quality data file. The WaterQualityView is the class we will develop in this section. One special thing that we have done here is to create a QScrollArea widget and add the water quality view to it—this basically means that the water quality view can be as wide and as tall as we like and the scroll area will take care of scrolling issues.

We will see shortly that keyboard users can navigate in the water quality view using the up and down arrow keys, and to ensure that the selected row is always visible we must pass the scroll area to the water quality view so that our key press handler can interact with it. Another thing that is special is that we have given initial sizes to the two parts of the horizontal splitter so that at start-up, they are roughly in the right proportions for the widgets they are holding.

We will now review the WaterQualityView, beginning with some static data and the initializer.

class WaterQualityView(QWidget):

FLOWCHARS = (unichr(0x21DC), unichr(0x21DD), unichr(0x21C9))

def_init_(self, parent=None):

super(WaterQualityView, self)._init_(parent)

self.scrollarea = None self.model = None self.setFocusPolicy(Qt.StrongFocus) self.selectedRow = -1 self.flowfont = self.font() size = self.font().pointSize() if platform.system() == "Windows": fontDb = QFontDatabase()

for face in [face.toLower() for face in fontDb.families()]: if face.contains("unicode"):

self.flowfont = QFont(face, size) break else:

self.flowfont = QFont("symbol", size) WaterQualityView.FLOWCHARS = (

Setting the focus policy to anything (except Qt.NoFocus) means that the widget can accept keyboard focus. We will discuss why we have done this, and the selectedRow instance variable, at the end of this section.

When water flow is going the wrong way, or too slowly, or too quickly, we want to indicate the situation with a suitable character—for example, , , and These characters are available in Unicode, but most of the default fonts supplied with Windows don't appear to include the whole Unicode character set, so all the arrows are shown as □ characters. (On Linux, if a Unicode character is not available in the current font, PyQt can usually find the character in another font, in which case it uses the found font just for that character.)

To solve this problem on Windows we iterate over the list of available fonts until we find one with "Unicode" in its name (e.g., "Lucida Sans Unicode"). If we find such a font, we store it as the flow characters' font; otherwise, we fall back to the standard (but non-Unicode) Symbol font and use the nearest equivalent characters in that font.

def setModel(self, model): self.model = model self.connect(self.model,

SIGNAL("dataChanged(QModelIndex,QModelIndex)"), self.setNewSize) self.connect(self.model, SIGNAL("modelReset()"), self.setNewSize) self.setNewSize()

Once a model is set on the view we connect to its data-changed and reset signals so that the view can be resized to match the available data.

def setNewSize(self):

self.resize(self.sizeHint())

self.update()

self.updateGeometry()

This method resizes the view to its preferred size and calls update() to schedule a repaint and updateGeometry() to tell any layout manager that is responsible for the view that its size has changed. Because we put the view in a QScrollArea, the scroll area will respond to changes in size by adjusting the scrollbars it provides.

def minimumSizeHint(self): size = self.sizeHint() fm = QFontMetrics(self.font()) size.setHeight(fm.height() * 3) return size

We calculate the view's minimum size to be its preferred size's width and three characters in height. In a layout this makes sense, but since a QScrollArea is used, the minimum size will, in practice, be whatever the scroll area decides.

def sizeHint(self):

return QSize(fm.width("9999-99-99 99:99 ") + (size * 4), (size / 4) + (size * self.model.rowCount()))

We use the height of one character (including its interline spacing) as our unit of size for both vertical and horizontal measurements. The view's preferred size is wide enough to show a timestamp plus four units of size to allow for the colored circles and the flow character, and it's tall enough for all the rows in the model plus one-quarter of the unit of size to allow a tiny bit of margin.

The paint event isn't too difficult, but we will look at it in three parts, and show the code for only one colored circle since the code for all three is almost identical.

def paintEvent(self, event): if self.model is None: return fm = QFontMetrics(self.font())

timestampWidth = fm.width("9999-99-99 99:99 ")

indicatorSize = int(size * 0.8)

maxY = minY + event.rect().height() + size minY -= size painter = QPainter(self)

painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.TextAntialiasing)

If there is no model we do nothing and return. Otherwise, we need to calculate some sizes. Just like the sizeHint(), we use the height of one character as our unit of size, setting the indicatorSize (the diameter of the colored circles) to 80% of this amount. The offset is a tiny amount of vertical spacing designed to make the circles align vertically with the timestamp text.

Given the large size of the datasets that the view might be asked to show, it seems sensible to paint only those items that are wholly or partially visible to the user. For this reason, we set the minimum y coordinate to the paint event rectangle's y coordinate (but minus one size unit), and the maximum y coordinate to be the minimum plus the paint event's height plus one size unit. This means that we will paint from the item above the topmost item that is wholly in the view (i.e., the one with the lowest y coordinate in range, since point (0, 0) is the top-left corner), down to the item below the bottommost item that is wholly in the view (i.e., the one with the highesty coordinate in range).

A paint event's event parameter contains the size of the region that needs repainting. Very often we can disregard this information and simply paint the entire widget, but sometimes, as here, we use the information to make our painting more efficient.

painter.setPen(self.palette().color(QPalette.Text)) if row == self.selectedRow:

painter.fillRect(x, y + (offset * 0.8), self.width(), size, self.palette().highlight()) painter.setPen(self.palette().color( QPalette.HighlightedText)) timestamp = self.model.data(

self.model.index(row, TIMESTAMP)).toDateTime() painter.drawText(x, y + size, timestamp.toString(TIMESTAMPFORMAT)) x += timestampWidth temperature = self.model.data(

self.model.index(row, TEMPERATURE)) temperature = temperature.toDouble()[0] if temperature < 20: color = QColor(0, 0, int(255 * (20 - temperature) / 20)) elif temperature > 25:

color = QColor(int(255 * temperature / 100), 0, 0) else:

color = QColor(0, int(255 * temperature / 100), 0) painter.setPen(Qt.NoPen) painter.setBrush(color)

painter.drawEllipse(x, y + offset, indicatorSize, indicatorSize)

We iterate over every row in the model, but paint only those with ay coordinate that is in range. Once we have a row to paint, we set the pen (used for drawing text) to the palette's text color. If the row is selected (something we will explain after covering the paint event), we paint the background in the palette's highlight color and set the pen to the palette's highlighted text color.

Having set up the text color, and possibly painted the background, we then retrieve and draw the row's timestamp. For each row we keep an x coordinate that tells us how far across we are, and that we increment by the font metrics timestamp width we calculated earlier.

The first colored circle is used to indicate the water's temperature in °C. If the water is too cool we use a color with a blue tint; if it is too warm we use a color with a red tint; otherwise, we use a green tint. Then we switch off the pen and set the brush to the color we have set up and paint an ellipse to the right of the timestamp. The drawEllipse() method will draw a circle because the width and height of the rectangle in which the ellipse is drawn are the same.

We then increment the x coordinate. Now we repeat the process for the other two colored circle indicators, using the same tinting approach we used for temperature. We have omitted the code for these, since it is structurally identical to the code used for the temperature circle.

flow = self.model.data(

self.model.index(row, INLETFLOW)) flow = flow.toDouble()[0] char = None if flow <= 0:

char = WaterQualityView.FLOWCHARS[0] elif flow < 3:

char = WaterQualityView.FLOWCHARS[1] elif flow >5.5:

char = WaterQualityView.FLOWCHARS[2] if char is not None:

painter.setFont(self.flowfont) painter.drawText(x, y + size, char) painter.restore() y += size if y > maxY: break

If the water flow is in the wrong direction, or if it is too slow or too fast, we draw a suitable character, using the font and characters that were set in the initializer.

At the end we increment the y coordinate ready for the next row of data, but if we have gone past the last row that is in view, we stop.

The code we have written so far is sufficient to provide a read-only view of the dataset. But users often want to highlight an item. The easiest way to do this is to add a mouse press event handler.

def mousePressEvent(self, event): fm = QFontMetrics(self.font()) self.selectedRow = event.y() // fm.height() self.update()

self.emit(SIGNAL("clicked(QModelIndex)"), self.model.index(self.selectedRow, 0))

The unit of size used for all our calculations is the height of a character. We divide the mouse position'sy coordinate (which is relative to the top-left corner of the widget) by the unit of size, to find which row the user clicked. We use integer division because row numbers are whole numbers. Then we call update() to schedule a paint event. In the paintEvent() we saw that the selected row is drawn using highlighted text and background colors. We also emit a clicked() signal, with the model index of the first column of the row that was clicked. The signal is not used by this application, but providing it is a good practice when implementing custom views.

Keyboard users are catered for already by the scroll area: They can scroll using the Page Up and Page Down keys. But we ought to provide a means for keyboard users to select an item. To do this we must make sure that the widget has a suitable focus policy—we did this in the initializer—and we must provide a key press event handler.

def keyPressEvent(self, event): if self.model is None:

row = max(0, self.selectedRow - 1) elif event.key() == Qt.Key_Down:

row = min(self.selectedRow + 1, self.model.rowCount() - 1) if row != -1 and row != self.selectedRow: self.selectedRow = row if self.scrollarea is not None: fm = QFontMetrics(self.font()) y = fm.height() * self.selectedRow self.scrollarea.ensureVisible(0, y) self.update()

self.emit(SIGNAL("clicked(QModelIndex)"), self.model.index(self.selectedRow, 0))

else:

QWidget.keyPressEvent(self, event)

We have chosen to support just two key presses: Up Arrow and Down Arrow. If the user presses either of these, we increment or decrement the selected row, make sure that the selected row is in range, and then schedule a paint event. If the user navigates to the row above the topmost visible row or below the bottommost visible row, we tell the scroll area to make sure that the row that has been scrolled to is visible—if necessary, the scroll area will scroll to achieve this. We also emit a clicked() signal with the newly selected row's model index. It is quite conventional to use a clicked() signal in this circumstance, since in effect, the user is "clicking" using the keyboard—after all, the signals and slots mechanism is concerned with what the user wants rather than how they asked for it, and here they just want to select a row.

If we do not handle the key press ourselves, that is, for all other key presses, we pass the event on to the base class.

The water quality view widget is visually very different from the table view shown beside it, yet it did not require that much code to implement and was not too difficult to program. We made the widget fairly efficient by reducing the amount of unnecessary painting. We also made the painting code as simple as possible by ensuring that the widget was always exactly the size necessary to display the entire dataset. The disadvantage of this approach is that it pushes responsibility on to the programmer using our widget to use a QScrollArea, although this saves us from having to implement scrolling ourselves.

The water quality view visualizes the data in one-to-one correspondence with the data in the model, but we are not constrained to doing this. It is also possible to create custom views that show aggregated data. In this case, for example, we could have shown one entry per day, or per hour, perhaps by averaging each day or hour's readings.

Was this article helpful?

0 -2
Tuberminator

Tuberminator

The main focus of this report is to show how to get involved in video marketing on the run, how to rank quickly on YouTube and Google using FREE semi-automatic tools and services. QUICKLY AND FREE. I will show methods and techniques I use to rank my videos, as well as free resources and tools to make video clips, to get backlinks and free traffic.

Get My Free Ebook


Post a comment