"""Столбчатый график на QPainter. QtCharts исключён из сборки: он тянет за собой десятки мегабайт ради одной диаграммы. Здесь нужен один тип графика, и нарисовать его вручную дешевле, чем удвоить размер exe. Умеет то, без чего график врал бы: отрицательные значения (убыток — это нормально) и нулевую линию на своём месте. """ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPen from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget from .. import money as m from . import theme PADDING_LEFT = 8 PADDING_RIGHT = 8 PADDING_TOP = 18 LABEL_HEIGHT = 34 MIN_BAR_WIDTH = 14 BAR_GAP = 6 @dataclass class Bar: label: str value: Decimal tooltip: str = "" class BarChart(QWidget): """Столбики со знаком. Подпись под каждым, значение — во всплывающей подсказке.""" def __init__(self, parent=None): super().__init__(parent) self.bars: list[Bar] = [] self.currency = "₽" self.positive = QColor(theme.ACCENT) self.negative = QColor(theme.DANGER) self._rects: list[tuple[QRectF, Bar]] = [] self.setMinimumHeight(190) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setMouseTracking(True) def set_bars(self, bars: list[Bar], currency: str = "₽", positive: str = "") -> None: self.bars = list(bars) self.currency = currency if positive: self.positive = QColor(positive) self.update() # --- отрисовка --- def paintEvent(self, event) -> None: painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) self._rects.clear() area = QRectF(self.rect()).adjusted(PADDING_LEFT, PADDING_TOP, -PADDING_RIGHT, -LABEL_HEIGHT) if not self.bars or area.height() <= 0: painter.setPen(QColor(theme.TEXT_DIM)) painter.drawText(self.rect(), Qt.AlignCenter, "Пока нечего показывать") return values = [float(bar.value) for bar in self.bars] top = max(max(values), 0.0) bottom = min(min(values), 0.0) span = top - bottom if span <= 0: # Все значения нулевые: рисуем только базовую линию. span, top = 1.0, 1.0 zero_y = area.bottom() - (0.0 - bottom) / span * area.height() slot = area.width() / len(self.bars) width = max(MIN_BAR_WIDTH, min(slot - BAR_GAP, 64.0)) self._draw_zero_line(painter, area, zero_y, bottom < 0) metrics = QFontMetrics(painter.font()) for index, bar in enumerate(self.bars): center = area.left() + slot * (index + 0.5) height = abs(float(bar.value)) / span * area.height() rect = QRectF( center - width / 2, zero_y - height if bar.value >= 0 else zero_y, width, max(height, 1.0), ) color = self.positive if bar.value >= 0 else self.negative painter.setPen(Qt.NoPen) painter.setBrush(color) painter.drawRoundedRect(rect, 3, 3) self._rects.append((rect, bar)) self._draw_label(painter, metrics, bar, center, slot) painter.end() def _draw_zero_line(self, painter: QPainter, area: QRectF, zero_y: float, visible: bool) -> None: pen = QPen(QColor(theme.BORDER)) pen.setStyle(Qt.SolidLine if visible else Qt.DotLine) painter.setPen(pen) painter.drawLine(QPointF(area.left(), zero_y), QPointF(area.right(), zero_y)) def _draw_label(self, painter: QPainter, metrics: QFontMetrics, bar: Bar, center: float, slot: float) -> None: painter.setPen(QColor(theme.TEXT_DIM)) text = metrics.elidedText(bar.label, Qt.ElideRight, int(slot) - 2) painter.drawText( QRectF(center - slot / 2, self.height() - LABEL_HEIGHT + 4, slot, LABEL_HEIGHT - 6), Qt.AlignHCenter | Qt.AlignTop, text, ) # --- подсказка --- def mouseMoveEvent(self, event) -> None: position = event.position() for rect, bar in self._rects: # Ловим по всей высоте столбца: попасть мышью в тонкую полоску # у нуля невозможно. if rect.left() <= position.x() <= rect.right(): QToolTip.showText( event.globalPosition().toPoint(), bar.tooltip or f"{bar.label}: {m.fmt_money(bar.value, self.currency)}", self, ) return QToolTip.hideText()