1#!/usr/bin/env python
2
3
4#############################################################################
5##
6## Copyright (C) 2017 Riverbank Computing Limited.
7## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies).
8## All rights reserved.
9##
10## This file is part of the examples of PyQt.
11##
12## $QT_BEGIN_LICENSE:BSD$
13## You may use this file under the terms of the BSD license as follows:
14##
15## "Redistribution and use in source and binary forms, with or without
16## modification, are permitted provided that the following conditions are
17## met:
18##   * Redistributions of source code must retain the above copyright
19##     notice, this list of conditions and the following disclaimer.
20##   * Redistributions in binary form must reproduce the above copyright
21##     notice, this list of conditions and the following disclaimer in
22##     the documentation and/or other materials provided with the
23##     distribution.
24##   * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor
25##     the names of its contributors may be used to endorse or promote
26##     products derived from this software without specific prior written
27##     permission.
28##
29## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
30## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
31## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
32## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
33## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
34## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
35## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
36## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
37## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
38## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
39## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
40## $QT_END_LICENSE$
41##
42#############################################################################
43
44
45import math
46
47from PyQt5.QtCore import (QByteArray, QFile, QItemSelection,
48        QItemSelectionModel, QModelIndex, QPoint, QRect, QSize, Qt,
49        QTextStream)
50from PyQt5.QtGui import (QBrush, QColor, QFontMetrics, QPainter, QPainterPath,
51        QPalette, QPen, QRegion, QStandardItemModel)
52from PyQt5.QtWidgets import (QAbstractItemView, QApplication, QFileDialog,
53        QMainWindow, QMenu, QRubberBand, QSplitter, QStyle, QTableView)
54
55import chart_rc
56
57
58class PieView(QAbstractItemView):
59    def __init__(self, parent=None):
60        super(PieView, self).__init__(parent)
61
62        self.horizontalScrollBar().setRange(0, 0)
63        self.verticalScrollBar().setRange(0, 0)
64
65        self.margin = 8
66        self.totalSize = 300
67        self.pieSize = self.totalSize - 2*self.margin
68        self.validItems = 0
69        self.totalValue = 0.0
70        self.origin = QPoint()
71        self.rubberBand = None
72
73    def dataChanged(self, topLeft, bottomRight, roles):
74        super(PieView, self).dataChanged(topLeft, bottomRight, roles)
75
76        self.validItems = 0
77        self.totalValue = 0.0
78
79        for row in range(self.model().rowCount(self.rootIndex())):
80
81            index = self.model().index(row, 1, self.rootIndex())
82            value = self.model().data(index)
83
84            if value is not None and value > 0.0:
85                self.totalValue += value
86                self.validItems += 1
87
88        self.viewport().update()
89
90    def edit(self, index, trigger, event):
91        if index.column() == 0:
92            return super(PieView, self).edit(index, trigger, event)
93        else:
94            return False
95
96    def indexAt(self, point):
97        if self.validItems == 0:
98            return QModelIndex()
99
100        # Transform the view coordinates into contents widget coordinates.
101        wx = point.x() + self.horizontalScrollBar().value()
102        wy = point.y() + self.verticalScrollBar().value()
103
104        if wx < self.totalSize:
105            cx = wx - self.totalSize/2
106            cy = self.totalSize/2 - wy; # positive cy for items above the center
107
108            # Determine the distance from the center point of the pie chart.
109            d = (cx**2 + cy**2)**0.5
110
111            if d == 0 or d > self.pieSize/2:
112                return QModelIndex()
113
114            # Determine the angle of the point.
115            angle = (180 / math.pi) * math.acos(cx/d)
116            if cy < 0:
117                angle = 360 - angle
118
119            # Find the relevant slice of the pie.
120            startAngle = 0.0
121
122            for row in range(self.model().rowCount(self.rootIndex())):
123
124                index = self.model().index(row, 1, self.rootIndex())
125                value = self.model().data(index)
126
127                if value > 0.0:
128                    sliceAngle = 360*value/self.totalValue
129
130                    if angle >= startAngle and angle < (startAngle + sliceAngle):
131                        return self.model().index(row, 1, self.rootIndex())
132
133                    startAngle += sliceAngle
134
135        else:
136            itemHeight = QFontMetrics(self.viewOptions().font).height()
137            listItem = int((wy - self.margin) / itemHeight)
138            validRow = 0
139
140            for row in range(self.model().rowCount(self.rootIndex())):
141
142                index = self.model().index(row, 1, self.rootIndex())
143                if self.model().data(index) > 0.0:
144
145                    if listItem == validRow:
146                        return self.model().index(row, 0, self.rootIndex())
147
148                    # Update the list index that corresponds to the next valid
149                    # row.
150                    validRow += 1
151
152        return QModelIndex()
153
154    def isIndexHidden(self, index):
155        return False
156
157    def itemRect(self, index):
158        if not index.isValid():
159            return QRect()
160
161        # Check whether the index's row is in the list of rows represented
162        # by slices.
163
164        if index.column() != 1:
165            valueIndex = self.model().index(index.row(), 1, self.rootIndex())
166        else:
167            valueIndex = index
168
169        value = self.model().data(valueIndex)
170        if value is not None and value > 0.0:
171
172            listItem = 0
173            for row in range(index.row()-1, -1, -1):
174                if self.model().data(self.model().index(row, 1, self.rootIndex())) > 0.0:
175                    listItem += 1
176
177            if index.column() == 0:
178
179                itemHeight = QFontMetrics(self.viewOptions().font).height()
180                return QRect(self.totalSize,
181                             int(self.margin + listItem*itemHeight),
182                             self.totalSize - self.margin, int(itemHeight))
183            elif index.column() == 1:
184                return self.viewport().rect()
185
186        return QRect()
187
188    def itemRegion(self, index):
189        if not index.isValid():
190            return QRegion()
191
192        if index.column() != 1:
193            return QRegion(self.itemRect(index))
194
195        if self.model().data(index) <= 0.0:
196            return QRegion()
197
198        startAngle = 0.0
199        for row in range(self.model().rowCount(self.rootIndex())):
200
201            sliceIndex = self.model().index(row, 1, self.rootIndex())
202            value = self.model().data(sliceIndex)
203
204            if value > 0.0:
205                angle = 360*value/self.totalValue
206
207                if sliceIndex == index:
208                    slicePath = QPainterPath()
209                    slicePath.moveTo(self.totalSize/2, self.totalSize/2)
210                    slicePath.arcTo(self.margin, self.margin,
211                            self.margin+self.pieSize, self.margin+self.pieSize,
212                            startAngle, angle)
213                    slicePath.closeSubpath()
214
215                    return QRegion(slicePath.toFillPolygon().toPolygon())
216
217                startAngle += angle
218
219        return QRegion()
220
221    def horizontalOffset(self):
222        return self.horizontalScrollBar().value()
223
224    def mousePressEvent(self, event):
225        super(PieView, self).mousePressEvent(event)
226
227        self.origin = event.pos()
228        if not self.rubberBand:
229            self.rubberBand = QRubberBand(QRubberBand.Rectangle, self)
230        self.rubberBand.setGeometry(QRect(self.origin, QSize()))
231        self.rubberBand.show()
232
233    def mouseMoveEvent(self, event):
234        if self.rubberBand:
235            self.rubberBand.setGeometry(QRect(self.origin, event.pos()).normalized())
236
237        super(PieView, self).mouseMoveEvent(event)
238
239    def mouseReleaseEvent(self, event):
240        super(PieView, self).mouseReleaseEvent(event)
241
242        if self.rubberBand:
243            self.rubberBand.hide()
244
245        self.viewport().update()
246
247    def moveCursor(self, cursorAction, modifiers):
248        current = self.currentIndex()
249
250        if cursorAction in (QAbstractItemView.MoveLeft, QAbstractItemView.MoveUp):
251
252            if current.row() > 0:
253                current = self.model().index(current.row() - 1,
254                        current.column(), self.rootIndex())
255            else:
256                current = self.model().index(0, current.column(),
257                        self.rootIndex())
258
259        elif cursorAction in (QAbstractItemView.MoveRight, QAbstractItemView.MoveDown):
260
261            if current.row() < self.rows(current) - 1:
262                current = self.model().index(current.row() + 1,
263                        current.column(), self.rootIndex())
264            else:
265                current = self.model().index(self.rows(current) - 1,
266                        current.column(), self.rootIndex())
267
268        self.viewport().update()
269        return current
270
271    def paintEvent(self, event):
272        selections = self.selectionModel()
273        option = self.viewOptions()
274        state = option.state
275
276        background = option.palette.base()
277        foreground = QPen(option.palette.color(QPalette.WindowText))
278        textPen = QPen(option.palette.color(QPalette.Text))
279        highlightedPen = QPen(option.palette.color(QPalette.HighlightedText))
280
281        painter = QPainter(self.viewport())
282        painter.setRenderHint(QPainter.Antialiasing)
283
284        painter.fillRect(event.rect(), background)
285        painter.setPen(foreground)
286
287        # Viewport rectangles
288        pieRect = QRect(self.margin, self.margin, self.pieSize,
289                self.pieSize)
290        keyPoint = QPoint(self.totalSize - self.horizontalScrollBar().value(),
291                self.margin - self.verticalScrollBar().value())
292
293        if self.validItems > 0:
294            painter.save()
295            painter.translate(pieRect.x() - self.horizontalScrollBar().value(),
296                    pieRect.y() - self.verticalScrollBar().value())
297            painter.drawEllipse(0, 0, self.pieSize, self.pieSize)
298            startAngle = 0.0
299
300            for row in range(self.model().rowCount(self.rootIndex())):
301
302                index = self.model().index(row, 1, self.rootIndex())
303                value = self.model().data(index)
304
305                if value > 0.0:
306                    angle = 360*value/self.totalValue
307
308                    colorIndex = self.model().index(row, 0, self.rootIndex())
309                    color = self.model().data(colorIndex, Qt.DecorationRole)
310
311                    if self.currentIndex() == index:
312                        painter.setBrush(QBrush(color, Qt.Dense4Pattern))
313                    elif selections.isSelected(index):
314                        painter.setBrush(QBrush(color, Qt.Dense3Pattern))
315                    else:
316                        painter.setBrush(QBrush(color))
317
318                    painter.drawPie(0, 0, self.pieSize, self.pieSize,
319                            int(startAngle*16), int(angle*16))
320
321                    startAngle += angle
322
323            painter.restore()
324
325            keyNumber = 0
326
327            for row in range(self.model().rowCount(self.rootIndex())):
328                index = self.model().index(row, 1, self.rootIndex())
329                value = self.model().data(index)
330
331                if value > 0.0:
332                    labelIndex = self.model().index(row, 0, self.rootIndex())
333
334                    option = self.viewOptions()
335                    option.rect = self.visualRect(labelIndex)
336                    if selections.isSelected(labelIndex):
337                        option.state |= QStyle.State_Selected
338                    if self.currentIndex() == labelIndex:
339                        option.state |= QStyle.State_HasFocus
340                    self.itemDelegate().paint(painter, option, labelIndex)
341
342                    keyNumber += 1
343
344    def resizeEvent(self, event):
345        self.updateGeometries()
346
347    def rows(self, index):
348        return self.model().rowCount(self.model().parent(index))
349
350    def rowsInserted(self, parent, start, end):
351        for row in range(start, end + 1):
352            index = self.model().index(row, 1, self.rootIndex())
353            value = self.model().data(index)
354
355            if value is not None and value > 0.0:
356                self.totalValue += value
357                self.validItems += 1
358
359        super(PieView, self).rowsInserted(parent, start, end)
360
361    def rowsAboutToBeRemoved(self, parent, start, end):
362        for row in range(start, end + 1):
363            index = self.model().index(row, 1, self.rootIndex())
364            value = self.model().data(index)
365
366            if value is not None and value > 0.0:
367                self.totalValue -= value
368                self.validItems -= 1
369
370        super(PieView, self).rowsAboutToBeRemoved(parent, start, end)
371
372    def scrollContentsBy(self, dx, dy):
373        self.viewport().scroll(dx, dy)
374
375    def scrollTo(self, index, ScrollHint):
376        area = self.viewport().rect()
377        rect = self.visualRect(index)
378
379        if rect.left() < area.left():
380            self.horizontalScrollBar().setValue(
381                self.horizontalScrollBar().value() + rect.left() - area.left())
382        elif rect.right() > area.right():
383            self.horizontalScrollBar().setValue(
384                self.horizontalScrollBar().value() + min(
385                    rect.right() - area.right(), rect.left() - area.left()))
386
387        if rect.top() < area.top():
388            self.verticalScrollBar().setValue(
389                self.verticalScrollBar().value() + rect.top() - area.top())
390        elif rect.bottom() > area.bottom():
391            self.verticalScrollBar().setValue(
392                self.verticalScrollBar().value() + min(
393                    rect.bottom() - area.bottom(), rect.top() - area.top()))
394
395    def setSelection(self, rect, command):
396        # Use content widget coordinates because we will use the itemRegion()
397        # function to check for intersections.
398
399        contentsRect = rect.translated(self.horizontalScrollBar().value(),
400                self.verticalScrollBar().value()).normalized()
401
402        rows = self.model().rowCount(self.rootIndex())
403        columns = self.model().columnCount(self.rootIndex())
404        indexes = []
405
406        for row in range(rows):
407            for column in range(columns):
408                index = self.model().index(row, column, self.rootIndex())
409                region = self.itemRegion(index)
410                if region.intersects(QRegion(contentsRect)):
411                    indexes.append(index)
412
413        if len(indexes) > 0:
414            firstRow = indexes[0].row()
415            lastRow = indexes[0].row()
416            firstColumn = indexes[0].column()
417            lastColumn = indexes[0].column()
418
419            for i in range(1, len(indexes)):
420                firstRow = min(firstRow, indexes[i].row())
421                lastRow = max(lastRow, indexes[i].row())
422                firstColumn = min(firstColumn, indexes[i].column())
423                lastColumn = max(lastColumn, indexes[i].column())
424
425            selection = QItemSelection(
426                self.model().index(firstRow, firstColumn, self.rootIndex()),
427                self.model().index(lastRow, lastColumn, self.rootIndex()))
428            self.selectionModel().select(selection, command)
429        else:
430            noIndex = QModelIndex()
431            selection = QItemSelection(noIndex, noIndex)
432            self.selectionModel().select(selection, command)
433
434        self.update()
435
436    def updateGeometries(self):
437        self.horizontalScrollBar().setPageStep(self.viewport().width())
438        self.horizontalScrollBar().setRange(0, max(0, 2*self.totalSize - self.viewport().width()))
439        self.verticalScrollBar().setPageStep(self.viewport().height())
440        self.verticalScrollBar().setRange(0, max(0, self.totalSize - self.viewport().height()))
441
442    def verticalOffset(self):
443        return self.verticalScrollBar().value()
444
445    def visualRect(self, index):
446        rect = self.itemRect(index)
447        if rect.isValid():
448            return QRect(rect.left() - self.horizontalScrollBar().value(),
449                         rect.top() - self.verticalScrollBar().value(),
450                         rect.width(), rect.height())
451        else:
452            return rect
453
454    def visualRegionForSelection(self, selection):
455        region = QRegion()
456
457        for span in selection:
458            for row in range(span.top(), span.bottom() + 1):
459                for col in range(span.left(), span.right() + 1):
460                    index = self.model().index(row, col, self.rootIndex())
461                    region += self.visualRect(index)
462
463        return region
464
465
466class MainWindow(QMainWindow):
467    def __init__(self):
468        super(MainWindow, self).__init__()
469
470        fileMenu = QMenu("&File", self)
471        openAction = fileMenu.addAction("&Open...")
472        openAction.setShortcut("Ctrl+O")
473        saveAction = fileMenu.addAction("&Save As...")
474        saveAction.setShortcut("Ctrl+S")
475        quitAction = fileMenu.addAction("E&xit")
476        quitAction.setShortcut("Ctrl+Q")
477
478        self.setupModel()
479        self.setupViews()
480
481        openAction.triggered.connect(self.openFile)
482        saveAction.triggered.connect(self.saveFile)
483        quitAction.triggered.connect(QApplication.instance().quit)
484
485        self.menuBar().addMenu(fileMenu)
486        self.statusBar()
487
488        self.openFile(':/Charts/qtdata.cht')
489
490        self.setWindowTitle("Chart")
491        self.resize(870, 550)
492
493    def setupModel(self):
494        self.model = QStandardItemModel(8, 2, self)
495        self.model.setHeaderData(0, Qt.Horizontal, "Label")
496        self.model.setHeaderData(1, Qt.Horizontal, "Quantity")
497
498    def setupViews(self):
499        splitter = QSplitter()
500        table = QTableView()
501        self.pieChart = PieView()
502        splitter.addWidget(table)
503        splitter.addWidget(self.pieChart)
504        splitter.setStretchFactor(0, 0)
505        splitter.setStretchFactor(1, 1)
506
507        table.setModel(self.model)
508        self.pieChart.setModel(self.model)
509
510        self.selectionModel = QItemSelectionModel(self.model)
511        table.setSelectionModel(self.selectionModel)
512        self.pieChart.setSelectionModel(self.selectionModel)
513
514        table.horizontalHeader().setStretchLastSection(True)
515
516        self.setCentralWidget(splitter)
517
518    def openFile(self, path=None):
519        if not path:
520            path, _ = QFileDialog.getOpenFileName(self, "Choose a data file",
521                    '', '*.cht')
522
523        if path:
524            f = QFile(path)
525
526            if f.open(QFile.ReadOnly | QFile.Text):
527                stream = QTextStream(f)
528
529                self.model.removeRows(0, self.model.rowCount(QModelIndex()),
530                        QModelIndex())
531
532                row = 0
533                line = stream.readLine()
534                while line:
535                    self.model.insertRows(row, 1, QModelIndex())
536
537                    pieces = line.split(',')
538                    self.model.setData(self.model.index(row, 0, QModelIndex()),
539                                pieces[0])
540                    self.model.setData(self.model.index(row, 1, QModelIndex()),
541                                float(pieces[1]))
542                    self.model.setData(self.model.index(row, 0, QModelIndex()),
543                                QColor(pieces[2]), Qt.DecorationRole)
544
545                    row += 1
546                    line = stream.readLine()
547
548                f.close()
549                self.statusBar().showMessage("Loaded %s" % path, 2000)
550
551    def saveFile(self):
552        fileName, _ = QFileDialog.getSaveFileName(self, "Save file as", '',
553                '*.cht')
554
555        if fileName:
556            f = QFile(fileName)
557
558            if f.open(QFile.WriteOnly | QFile.Text):
559                for row in range(self.model.rowCount(QModelIndex())):
560                    pieces = []
561
562                    pieces.append(
563                            self.model.data(
564                                    self.model.index(row, 0, QModelIndex()),
565                                    Qt.DisplayRole))
566                    pieces.append(
567                            '%g' % self.model.data(
568                                    self.model.index(row, 1, QModelIndex()),
569                                    Qt.DisplayRole))
570                    pieces.append(
571                            self.model.data(
572                                    self.model.index(row, 0, QModelIndex()),
573                                    Qt.DecorationRole).name())
574
575                    f.write(b','.join([p.encode('utf-8') for p in pieces]))
576                    f.write(b'\n')
577
578            f.close()
579            self.statusBar().showMessage("Saved %s" % fileName, 2000)
580
581
582if __name__ == '__main__':
583
584    import sys
585
586    app = QApplication(sys.argv)
587    window = MainWindow()
588    window.show()
589    sys.exit(app.exec_())
590