203 lines
7.2 KiB
Python

from enum import IntEnum
from typing import Callable, Literal
from PySide6.QtCore import QModelIndex, QPoint, QSize, Qt
from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QLinearGradient, QPainter
from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem
class TextSegmentDelegateVerticalAlign(IntEnum):
Top = 0
Middle = 1
Bottom = 2
class TextSegmentDelegate(QStyledItemDelegate):
VerticalPadding = 3
HorizontalPadding = 5
TextRole = 3375
ColorRole = TextRole + 1
BrushRole = TextRole + 2
GradientWrapperRole = TextRole + 3
FontRole = TextRole + 20
def __init__(self, parent=None):
super().__init__(parent)
self.baseXOffsets: dict[str, int] = {}
self.baseYOffsets: dict[str, int] = {}
self.verticalAlign = TextSegmentDelegateVerticalAlign.Middle
def indexOffsetKey(self, index: QModelIndex):
return f"{index.row()},{index.column()}"
def setBaseXOffset(self, index: QModelIndex, offset: int):
key = self.indexOffsetKey(index)
if not offset:
self.baseXOffsets.pop(key, None)
else:
self.baseXOffsets[key] = offset
def setBaseYOffset(self, index: QModelIndex, offset: int):
key = self.indexOffsetKey(index)
if not offset:
self.baseYOffsets.pop(key, None)
else:
self.baseYOffsets[key] = offset
def setVerticalAlign(self, align: Literal["top", "middle", "bottom"]):
if not isinstance(align, str) and align not in ["top", "middle", "bottom"]:
raise ValueError(
"TextSegment only supports top/middle/bottom vertical aligning."
)
if align == "top":
self.verticalAlign = TextSegmentDelegateVerticalAlign.Top
elif align == "middle":
self.verticalAlign = TextSegmentDelegateVerticalAlign.Middle
elif align == "bottom":
self.verticalAlign = TextSegmentDelegateVerticalAlign.Bottom
def getTextSegments(
self, index: QModelIndex, option
) -> list[
list[
dict[
int,
str
| QColor
| QBrush
| Callable[[float, float, float, float], QLinearGradient]
| QFont,
]
]
]:
return []
def textsSizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
width = 0
height = 0
fm: QFontMetrics = option.fontMetrics
segments = self.getTextSegments(index, option)
for i in range(len(segments)):
line = segments[i]
lineWidth = 2 * self.HorizontalPadding
lineHeight = 0
for textFrag in line:
font = textFrag.get(self.FontRole)
_fm = QFontMetrics(font) if font else fm
text = textFrag[self.TextRole]
textWidth = _fm.horizontalAdvance(text)
textHeight = _fm.height()
lineWidth += textWidth
lineHeight = max(lineHeight, textHeight)
width = max(lineWidth, width)
height += lineHeight
if i != len(segments) - 1:
height += self.VerticalPadding
return QSize(width, height)
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
width = self.HorizontalPadding * 2
height = self.VerticalPadding * 2
textsSizeHint = self.textsSizeHint(option, index)
return QSize(textsSizeHint.width() + width, textsSizeHint.height() + height)
def baseX(self, option: QStyleOptionViewItem, index: QModelIndex):
return (
option.rect.x()
+ self.HorizontalPadding
+ self.baseXOffsets.get(self.indexOffsetKey(index), 0)
)
def baseY(self, option: QStyleOptionViewItem, index: QModelIndex):
baseY = (
option.rect.y()
+ self.VerticalPadding
+ self.baseYOffsets.get(self.indexOffsetKey(index), 0)
)
if self.verticalAlign != TextSegmentDelegateVerticalAlign.Top:
paintAreaSize: QSize = option.rect.size()
delegateSize = self.sizeHint(option, index)
if self.verticalAlign == TextSegmentDelegateVerticalAlign.Middle:
baseY += round((paintAreaSize.height() - delegateSize.height()) / 2)
elif self.verticalAlign == TextSegmentDelegateVerticalAlign.Bottom:
baseY += paintAreaSize.height() - delegateSize.height()
return baseY
def textMaxWidth(self, option: QStyleOptionViewItem, index: QModelIndex):
return (
option.rect.width()
- (2 * self.HorizontalPadding)
- self.baseXOffsets.get(self.indexOffsetKey(index), 0)
)
def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
):
self.initStyleOption(option, index)
baseX = self.baseX(option, index)
baseY = self.baseY(option, index)
maxWidth = self.textMaxWidth(option, index)
fm: QFontMetrics = option.fontMetrics
painter.save()
for line in self.getTextSegments(index, option):
lineBaseX = baseX
lineBaseY = baseY
lineHeight = 0
for textFrag in line:
painter.save()
# elide text, get font values
text = textFrag[self.TextRole]
fragMaxWidth = maxWidth - (lineBaseX - baseX)
if font := textFrag.get(self.FontRole):
painter.setFont(font)
_fm = QFontMetrics(font)
else:
_fm = fm
lineHeight = max(lineHeight, _fm.height())
elidedText = _fm.elidedText(
text, Qt.TextElideMode.ElideRight, fragMaxWidth
)
# confirm proper color
brush = textFrag.get(self.BrushRole)
gradientWrapper = textFrag.get(self.GradientWrapperRole)
color = textFrag.get(self.ColorRole)
pen = painter.pen()
if brush:
pen.setBrush(brush)
elif gradientWrapper:
gradient = gradientWrapper(
lineBaseX,
lineBaseY + lineHeight - _fm.height(),
fragMaxWidth,
_fm.height(),
)
pen.setBrush(gradient)
elif color:
pen.setColor(color)
painter.setPen(pen)
painter.drawText(
QPoint(lineBaseX, lineBaseY + lineHeight - _fm.descent()),
elidedText,
)
painter.restore()
# if text elided, skip to next line
# remember to add height before skipping
if _fm.boundingRect(text).width() >= fragMaxWidth:
break
lineBaseX += _fm.horizontalAdvance(elidedText)
baseY += lineHeight + self.VerticalPadding
painter.restore()
def super_styledItemDelegate_paint(self, painter, option, index):
return super().paint(painter, option, index)