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