|
| 1 | +from qtpy.QtCore import Qt, QRect, Signal, QPointF, Property, QPropertyAnimation, QEasingCurve |
| 2 | +from qtpy.QtGui import QPainter, QPaintEvent, QMouseEvent, QColor, QBrush, QPen, QFont, QFontMetrics, QPolygonF |
| 3 | +from qtpy.QtWidgets import QWidget |
| 4 | +from typing import List |
| 5 | + |
| 6 | +class QFlowProgressBar(QWidget): |
| 7 | + onStepClicked = Signal(int) # Define the signal for step clicks |
| 8 | + |
| 9 | + class Styles: |
| 10 | + Circular = 0 |
| 11 | + Flat = 1 |
| 12 | + Square = 2 |
| 13 | + Breadcrumb = 3 |
| 14 | + |
| 15 | + class Direction: |
| 16 | + Up = 0 |
| 17 | + Down = 1 |
| 18 | + |
| 19 | + def __init__(self, strDetailList: List[str] = None, style: int = Styles.Circular, parent: QWidget = None, |
| 20 | + finishedNumberColor: QColor = QColor(255, 255, 255), finishedBackgroundColor: QColor = QColor(0, 136, 254), |
| 21 | + unfinishedBackgroundColor: QColor = QColor(228, 231, 237), numberFontSize: int = 9, textFontSize: int = 10, |
| 22 | + currentStep: int = 0, pointerDirection: Direction = Direction.Up, |
| 23 | + animationDuration: int = 1000, easingCurve: QEasingCurve.Type = QEasingCurve.OutQuad, stepsClickable: bool = True): |
| 24 | + """ |
| 25 | + Construct a QFlowProgressBar. |
| 26 | +
|
| 27 | + :param strDetailList: List of detail strings for each step. |
| 28 | + :param style: Style of the progress bar (Circular, Flat, Square, Breadcrumb). |
| 29 | + :param parent: Parent widget. |
| 30 | + :param finishedNumberColor: Color for finished step numbers. |
| 31 | + :param finishedBackgroundColor: Background color for finished steps. |
| 32 | + :param unfinishedBackgroundColor: Background color for unfinished steps. |
| 33 | + :param numberFontSize: Font size for step numbers. |
| 34 | + :param textFontSize: Font size for step descriptions. |
| 35 | + :param currentStep: The current step in the progress bar. |
| 36 | + :param pointerDirection: Pointer direction for flat progressbar. |
| 37 | + :param animationDuration: Duration of the animation in milliseconds. |
| 38 | + :param easingCurve: Easing curve for the animation. |
| 39 | + """ |
| 40 | + super().__init__(parent) |
| 41 | + self.barStyle = style |
| 42 | + self.finishedNumberColor = finishedNumberColor |
| 43 | + self.finishedBackgroundColor = finishedBackgroundColor |
| 44 | + self.unfinishedBackgroundColor = unfinishedBackgroundColor |
| 45 | + self.stepIconRects = [] |
| 46 | + self.stepMessages = strDetailList if strDetailList else [] |
| 47 | + self._currentStep = currentStep |
| 48 | + |
| 49 | + self.numberFontSize = numberFontSize |
| 50 | + self.textFontSize = textFontSize |
| 51 | + |
| 52 | + self.pointerDirection = pointerDirection |
| 53 | + |
| 54 | + self._finishedProgressLength = 0 |
| 55 | + self.animationDuration = animationDuration |
| 56 | + self.easingCurve = easingCurve |
| 57 | + |
| 58 | + self.adjustSize() |
| 59 | + |
| 60 | + self.animation = QPropertyAnimation(self, b"finishedProgressLength") |
| 61 | + self.animation.setDuration(self.animationDuration) |
| 62 | + self.animation.setEasingCurve(self.easingCurve) |
| 63 | + |
| 64 | + self.stepsClickable = stepsClickable |
| 65 | + |
| 66 | + def mouseReleaseEvent(self, event: QMouseEvent): |
| 67 | + """ |
| 68 | + Mouse release event handler. |
| 69 | +
|
| 70 | + :param event: QMouseEvent object. |
| 71 | + """ |
| 72 | + if not self.stepsClickable: |
| 73 | + return |
| 74 | + point = event.pos() |
| 75 | + # print("Mouse click coordinates:", point) # Debug print |
| 76 | + for index, rect in enumerate(self.stepIconRects): |
| 77 | + if rect.contains(point): |
| 78 | + # print("Step", index + 1, "clicked") # Debug print |
| 79 | + self.changeCurrentStep(index + 1) |
| 80 | + self.onStepClicked.emit(index) |
| 81 | + break |
| 82 | + |
| 83 | + def resizeEvent(self, event): |
| 84 | + super().resizeEvent(event) |
| 85 | + self.changeCurrentStep(self.getCurrentStep()) |
| 86 | + self.adjustSize() |
| 87 | + self.update() |
| 88 | + |
| 89 | + |
| 90 | + def paintEvent(self, event: QPaintEvent): |
| 91 | + """ |
| 92 | + Paint event handler to draw the progress bar. |
| 93 | +
|
| 94 | + :param event: QPaintEvent object. |
| 95 | + """ |
| 96 | + painter = QPainter(self) |
| 97 | + painter.setRenderHint(QPainter.Antialiasing) |
| 98 | + |
| 99 | + if self.barStyle == self.Styles.Circular or self.barStyle == self.Styles.Square: |
| 100 | + self.drawStyle(painter) |
| 101 | + elif self.barStyle == self.Styles.Flat: |
| 102 | + self.drawFlatStyle(painter) |
| 103 | + |
| 104 | + def drawStyle(self, painter: QPainter): |
| 105 | + """ |
| 106 | + Draw the progress bar with Circular or Square style. |
| 107 | +
|
| 108 | + :param painter: QPainter object. |
| 109 | + """ |
| 110 | + numberOfSteps = len(self.stepMessages) |
| 111 | + progressBarLength = int(self.size().width() * 0.9) |
| 112 | + progressBarHeight = int(self.size().height() * 0.1) |
| 113 | + totalStepIconLength = int(progressBarLength * 0.88) |
| 114 | + iconSize = int((totalStepIconLength / (numberOfSteps - 1)) * 0.15) |
| 115 | + iconStep = totalStepIconLength // (numberOfSteps - 1) |
| 116 | + iconStartY = 0 |
| 117 | + |
| 118 | + iconBorderPen = QPen(QBrush(self.getBackgroundColor()), iconSize * 0.1) |
| 119 | + whiteBrush = QBrush(Qt.white) |
| 120 | + fontNumber = QFont() |
| 121 | + fontText = QFont() |
| 122 | + fontNumber.setPointSize(self.numberFontSize) |
| 123 | + fontText.setPointSize(self.textFontSize) |
| 124 | + textSize = self.getDrawTextSize("Sample", fontText) |
| 125 | + |
| 126 | + # Ensure icon size does not exceed 2/3 of the total height |
| 127 | + maxIconSize = int(self.size().height() * 2 / 3) |
| 128 | + iconSize = min(iconSize, maxIconSize) |
| 129 | + |
| 130 | + # Ensure icon size plus text height does not exceed total height |
| 131 | + totalHeight = self.size().height() |
| 132 | + maxTotalHeight = totalHeight - textSize.height() |
| 133 | + if iconSize + textSize.height() > totalHeight: |
| 134 | + iconSize = maxTotalHeight |
| 135 | + |
| 136 | + # Draw background progress bar |
| 137 | + startX = int(self.size().width() * 0.05) |
| 138 | + startY = (iconSize / 2) - (progressBarHeight / 2) |
| 139 | + |
| 140 | + backgroundBrush = QBrush(self.getBackgroundColor()) |
| 141 | + finishedBrush = QBrush(self.getFinishedBackgroundColor()) |
| 142 | + emptyPen = QPen(Qt.NoPen) |
| 143 | + |
| 144 | + painter.setPen(emptyPen) |
| 145 | + painter.setBrush(backgroundBrush) |
| 146 | + painter.drawRoundedRect(startX, startY, progressBarLength, progressBarHeight, progressBarHeight, progressBarHeight) |
| 147 | + |
| 148 | + # Draw finished progress |
| 149 | + painter.setBrush(finishedBrush) |
| 150 | + painter.drawRoundedRect(startX, startY, self.finishedProgressLength, progressBarHeight, progressBarHeight, |
| 151 | + progressBarHeight) |
| 152 | + |
| 153 | + painter.save() |
| 154 | + painter.translate(startX + progressBarLength * 0.05, iconStartY) |
| 155 | + |
| 156 | + for i in range(numberOfSteps): |
| 157 | + if i < self._currentStep: |
| 158 | + painter.setBrush(finishedBrush) |
| 159 | + else: |
| 160 | + painter.setBrush(whiteBrush) |
| 161 | + |
| 162 | + currentXOffset = i * iconStep |
| 163 | + painter.setPen(iconBorderPen) |
| 164 | + |
| 165 | + # Store the bounding rectangle of each step icon |
| 166 | + iconRect = QRect(currentXOffset, 1, iconSize, iconSize) |
| 167 | + clickableRect = QRect(currentXOffset + (iconStep - iconSize) // 2 - (iconSize/2), 1, iconSize, iconSize) |
| 168 | + self.stepIconRects.append(clickableRect) |
| 169 | + |
| 170 | + if self.barStyle == self.Styles.Circular: |
| 171 | + painter.drawEllipse(iconRect) |
| 172 | + else: |
| 173 | + painter.drawRect(iconRect) |
| 174 | + |
| 175 | + painter.setFont(fontNumber) |
| 176 | + if i < self._currentStep: |
| 177 | + painter.setPen(QPen(self.getFinishedNumberColor())) |
| 178 | + |
| 179 | + painter.drawText(currentXOffset, 0, iconSize, iconSize, Qt.AlignCenter, str(i + 1)) |
| 180 | + |
| 181 | + displayText = self.stepMessages[i] |
| 182 | + textSize = self.getDrawTextSize(displayText, fontText) |
| 183 | + |
| 184 | + textStartX = int((currentXOffset + iconSize * 0.5) - textSize.width() / 2) |
| 185 | + textStartY = int(iconSize + 3) |
| 186 | + painter.setFont(fontText) |
| 187 | + if i < self._currentStep: |
| 188 | + painter.setPen(QPen(self.getFinishedBackgroundColor())) |
| 189 | + |
| 190 | + painter.drawText(textStartX, textStartY, textSize.width(), textSize.height(), Qt.AlignCenter, displayText) |
| 191 | + |
| 192 | + painter.restore() |
| 193 | + |
| 194 | + def drawFlatStyle(self, painter: QPainter): |
| 195 | + """ |
| 196 | + Draw progress bar with Flat style and triangle pointer tip. |
| 197 | +
|
| 198 | + :param painter: QPainter object. |
| 199 | + """ |
| 200 | + numberOfSteps = len(self.stepMessages) |
| 201 | + progressBarLength = int(self.size().width() * 0.9) |
| 202 | + progressBarHeight = int(self.size().height() * 0.1) |
| 203 | + totalStepIconLength = int(progressBarLength * 0.88) |
| 204 | + iconSize = int((totalStepIconLength / (numberOfSteps - 1)) * 0.15) |
| 205 | + iconStep = totalStepIconLength // (numberOfSteps - 1) |
| 206 | + progressBarGap = progressBarHeight * 1.5 |
| 207 | + |
| 208 | + iconBorderPen = QPen(QBrush(self.getBackgroundColor()), iconSize * 0.1) |
| 209 | + whiteBrush = QBrush(Qt.white) |
| 210 | + fontNumber = QFont() |
| 211 | + fontNumber.setPointSize(self.numberFontSize) |
| 212 | + fontText = QFont() |
| 213 | + fontText.setPointSize(self.textFontSize) |
| 214 | + textSize = self.getDrawTextSize("Sample", fontText) |
| 215 | + |
| 216 | + # Ensure icon size does not exceed 2/3 of the total height |
| 217 | + maxIconSize = int(self.size().height() * 2 / 3) |
| 218 | + iconSize = min(iconSize, maxIconSize) |
| 219 | + |
| 220 | + # Ensure icon size plus text height does not exceed total height |
| 221 | + totalHeight = self.size().height() |
| 222 | + maxTotalHeight = totalHeight - (textSize.height() + progressBarHeight + (progressBarGap * 2)) |
| 223 | + if (iconSize + textSize.height() + progressBarHeight + (progressBarGap * 2)) > totalHeight: |
| 224 | + iconSize = maxTotalHeight |
| 225 | + |
| 226 | + textStartY = int(iconSize + progressBarHeight + progressBarGap) |
| 227 | + |
| 228 | + # Background progress bar |
| 229 | + startX = int(self.size().width() * 0.05) |
| 230 | + startY = int(self.size().height() * 0.15) |
| 231 | + progressBarStartY = startY + iconSize + progressBarGap |
| 232 | + |
| 233 | + backgroundBrush = QBrush(self.getBackgroundColor()) |
| 234 | + finishedBrush = QBrush(self.getFinishedBackgroundColor()) |
| 235 | + emptyPen = QPen(Qt.NoPen) |
| 236 | + painter.setPen(emptyPen) |
| 237 | + painter.setBrush(backgroundBrush) |
| 238 | + painter.drawRoundedRect(startX, progressBarStartY, progressBarLength, progressBarHeight, progressBarHeight, |
| 239 | + progressBarHeight) |
| 240 | + |
| 241 | + # Draw completed progress |
| 242 | + painter.setBrush(finishedBrush) |
| 243 | + painter.drawRoundedRect(startX, progressBarStartY, self.finishedProgressLength, progressBarHeight, |
| 244 | + progressBarHeight, progressBarHeight) |
| 245 | + |
| 246 | + painter.save() |
| 247 | + painter.translate(startX + progressBarLength * 0.05, startY) |
| 248 | + |
| 249 | + for i in range(numberOfSteps): |
| 250 | + if i < self._currentStep: |
| 251 | + painter.setBrush(finishedBrush) |
| 252 | + else: |
| 253 | + painter.setBrush(whiteBrush) |
| 254 | + |
| 255 | + currentXOffset = i * iconStep |
| 256 | + painter.setPen(iconBorderPen) |
| 257 | + |
| 258 | + iconRect = QRect(currentXOffset, 1, iconSize, iconSize) |
| 259 | + clickableRect = QRect(currentXOffset + (iconStep - iconSize) // 2 - (iconSize/2), 1, iconSize, iconSize) |
| 260 | + self.stepIconRects.append(clickableRect) |
| 261 | + |
| 262 | + painter.drawEllipse(iconRect) |
| 263 | + |
| 264 | + # Draw triangle pointer tip |
| 265 | + pointerTipWidth = progressBarGap |
| 266 | + pointerTipHeight = progressBarGap |
| 267 | + painter.setPen(Qt.NoPen) |
| 268 | + if i < self._currentStep: |
| 269 | + painter.setBrush(finishedBrush) |
| 270 | + else: |
| 271 | + painter.setBrush(backgroundBrush) |
| 272 | + |
| 273 | + if self.pointerDirection == self.Direction.Up: |
| 274 | + pointer = QPolygonF([ |
| 275 | + QPointF(currentXOffset + iconSize / 2 - pointerTipWidth / 2, progressBarStartY - progressBarHeight), |
| 276 | + QPointF(currentXOffset + iconSize / 2 + pointerTipWidth / 2, progressBarStartY - progressBarHeight), |
| 277 | + QPointF(currentXOffset + iconSize / 2, 1 - pointerTipHeight + progressBarStartY - progressBarHeight) |
| 278 | + ]) |
| 279 | + else: |
| 280 | + pointer = QPolygonF([QPointF(currentXOffset + iconSize / 2 - pointerTipWidth / 2, 1 + iconSize), |
| 281 | + QPointF(currentXOffset + iconSize / 2 + pointerTipWidth / 2, 1 + iconSize), |
| 282 | + QPointF(currentXOffset + iconSize / 2, 1 + iconSize + pointerTipHeight)]) |
| 283 | + |
| 284 | + painter.drawPolygon(pointer) |
| 285 | + |
| 286 | + painter.setFont(fontNumber) |
| 287 | + if i < self._currentStep: |
| 288 | + painter.setPen(QPen(self.getFinishedNumberColor())) |
| 289 | + |
| 290 | + painter.drawText(currentXOffset, 0, iconSize, iconSize, Qt.AlignCenter, str(i + 1)) |
| 291 | + |
| 292 | + displayText = self.stepMessages[i] |
| 293 | + textSize = self.getDrawTextSize(displayText, fontText) |
| 294 | + |
| 295 | + textStartX = int((currentXOffset + iconSize * 0.5) - textSize.width() / 2) |
| 296 | + |
| 297 | + painter.setFont(fontText) |
| 298 | + if i < self._currentStep: |
| 299 | + painter.setPen(QPen(self.getFinishedBackgroundColor())) |
| 300 | + else: |
| 301 | + painter.setPen(QPen(Qt.black)) |
| 302 | + |
| 303 | + painter.drawText(textStartX, textStartY, textSize.width(), textSize.height(), Qt.AlignCenter, displayText) |
| 304 | + |
| 305 | + painter.restore() |
| 306 | + |
| 307 | + def getBackgroundColor(self) -> QColor: |
| 308 | + """ |
| 309 | + Get the background color of the progress bar. |
| 310 | +
|
| 311 | + :return: QColor object representing the background color. |
| 312 | + """ |
| 313 | + return self.unfinishedBackgroundColor |
| 314 | + |
| 315 | + def getFinishedBackgroundColor(self) -> QColor: |
| 316 | + """ |
| 317 | + Get the color for finished progress bar segments. |
| 318 | +
|
| 319 | + :return: QColor object representing the finished segment color. |
| 320 | + """ |
| 321 | + return self.finishedBackgroundColor |
| 322 | + |
| 323 | + def getFinishedNumberColor(self) -> QColor: |
| 324 | + """ |
| 325 | + Get the color for finished numbers in the progress bar. |
| 326 | +
|
| 327 | + :return: QColor object representing the finished number color. |
| 328 | + """ |
| 329 | + return self.finishedNumberColor |
| 330 | + |
| 331 | + def getCurrentStep(self): |
| 332 | + """ |
| 333 | + CReturn current step |
| 334 | +
|
| 335 | + """ |
| 336 | + return self._currentStep |
| 337 | + |
| 338 | + def changeCurrentStep(self, step: int): |
| 339 | + """ |
| 340 | + Change the current step of the progress bar. |
| 341 | +
|
| 342 | + :param step: New step to set. |
| 343 | + """ |
| 344 | + if step > len(self.stepMessages) or step < 0: |
| 345 | + return |
| 346 | + |
| 347 | + self._currentStep = step |
| 348 | + self.animation.setStartValue(self.finishedProgressLength) |
| 349 | + |
| 350 | + if step <= 0: |
| 351 | + finishedProgressLength = 0 |
| 352 | + else: |
| 353 | + progressBarLength = int(self.size().width() * 0.9) |
| 354 | + iconStep = int((progressBarLength * 0.88) / (len(self.stepMessages) - 1)) |
| 355 | + finishedProgressLength = int(progressBarLength * 0.05 + (step - 1) * iconStep + iconStep / 2) |
| 356 | + finishedProgressLength = min(finishedProgressLength, progressBarLength) |
| 357 | + |
| 358 | + self.animation.setEndValue(finishedProgressLength) |
| 359 | + self.animation.start() |
| 360 | + |
| 361 | + |
| 362 | + def getDrawTextSize(self, text: str, font: QFont) -> QRect: |
| 363 | + """ |
| 364 | + Get the size of the text to be drawn. |
| 365 | +
|
| 366 | + :param text: Text string. |
| 367 | + :param font: QFont object. |
| 368 | + :return: QRect object representing the size of the text. |
| 369 | + """ |
| 370 | + metrics = QFontMetrics(font) |
| 371 | + return metrics.boundingRect(text).adjusted(-2, -2, 2, 2) |
| 372 | + |
| 373 | + def getFinishedProgressLength(self) -> int: |
| 374 | + return self._finishedProgressLength |
| 375 | + |
| 376 | + def setFinishedProgressLength(self, length: int): |
| 377 | + self._finishedProgressLength = length |
| 378 | + self.update() |
| 379 | + |
| 380 | + finishedProgressLength = Property(int, getFinishedProgressLength, setFinishedProgressLength) |
0 commit comments