wip: show song jacket in ChartDelegate

This commit is contained in:
283375 2023-10-14 00:55:01 +08:00
parent 858abe3415
commit 1060590e03
Signed by: 283375
SSH Key Fingerprint: SHA256:UcX0qg6ZOSDOeieKPGokA5h7soykG61nz2uxuQgVLSk
5 changed files with 210 additions and 68 deletions

View File

@ -2,8 +2,9 @@ import json
import sys import sys
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal, Optional, overload
from arcaea_offline.models import Chart, Difficulty, Song
from PySide6.QtCore import QFile from PySide6.QtCore import QFile
from .singleton import Singleton from .singleton import Singleton
@ -45,3 +46,41 @@ class Data(metaclass=Singleton):
@property @property
def arcaeaPath(self): def arcaeaPath(self):
return self.dataPath / "Arcaea" return self.dataPath / "Arcaea"
@overload
def getJacketPath(self, chart: Chart, /) -> Path | None:
...
@overload
def getJacketPath(
self, song: Song, difficulty: Optional[Difficulty] = None, /
) -> Path | None:
...
def getJacketPath(self, *args) -> Path | None:
if isinstance(args[0], Chart):
chart = args[0]
ratingSpecified = f"{chart.song_id}_{chart.rating_class}"
base = chart.song_id
elif isinstance(args[0], Song):
song = args[0]
difficulty = args[1]
ratingSpecified = (
f"{song.id}_{difficulty.rating_class}"
if isinstance(difficulty, Difficulty)
else song.id
)
base = song.id
else:
raise ValueError()
ratingSpecified += ".jpg"
base += ".jpg"
jacketsPath = self.arcaeaPath / "Song"
if (jacketsPath / ratingSpecified).exists():
return jacketsPath / ratingSpecified
elif (jacketsPath / base).exists():
return jacketsPath / base
else:
return None

View File

@ -1,8 +1,15 @@
from typing import Callable from enum import IntEnum
from typing import Callable, Literal
from PySide6.QtCore import QEvent, QModelIndex, QObject, QPoint, QSize, Qt from PySide6.QtCore import QModelIndex, QPoint, QSize, Qt
from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QLinearGradient, QPainter from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QLinearGradient, QPainter
from PySide6.QtWidgets import QApplication, QStyledItemDelegate, QStyleOptionViewItem from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem
class TextSegmentDelegateVerticalAlign(IntEnum):
Top = 0
Middle = 1
Bottom = 2
class TextSegmentDelegate(QStyledItemDelegate): class TextSegmentDelegate(QStyledItemDelegate):
@ -15,6 +22,28 @@ class TextSegmentDelegate(QStyledItemDelegate):
GradientWrapperRole = TextRole + 3 GradientWrapperRole = TextRole + 3
FontRole = TextRole + 20 FontRole = TextRole + 20
def __init__(self, parent=None):
super().__init__(parent)
# TODO: make this differ by index
self.baseXOffset = 0
self.baseYOffset = 0
self.verticalAlign = TextSegmentDelegateVerticalAlign.Middle
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( def getTextSegments(
self, index: QModelIndex, option self, index: QModelIndex, option
) -> list[ ) -> list[
@ -50,14 +79,32 @@ class TextSegmentDelegate(QStyledItemDelegate):
height += lineHeight + self.VerticalPadding height += lineHeight + self.VerticalPadding
return QSize(width, height) return QSize(width, height)
def baseX(self, option: QStyleOptionViewItem, index: QModelIndex):
return option.rect.x() + self.HorizontalPadding + self.baseXOffset
def baseY(self, option: QStyleOptionViewItem, index: QModelIndex):
baseY = option.rect.y() + self.VerticalPadding + self.baseYOffset
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.baseXOffset
def paint( def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
): ):
self.initStyleOption(option, index) self.initStyleOption(option, index)
# draw text only
baseX = option.rect.x() + self.HorizontalPadding baseX = self.baseX(option, index)
baseY = option.rect.y() + self.VerticalPadding baseY = self.baseY(option, index)
maxWidth = option.rect.width() - (2 * self.HorizontalPadding) maxWidth = self.textMaxWidth(option, index)
fm: QFontMetrics = option.fontMetrics fm: QFontMetrics = option.fontMetrics
painter.save() painter.save()
for line in self.getTextSegments(index, option): for line in self.getTextSegments(index, option):
@ -69,8 +116,7 @@ class TextSegmentDelegate(QStyledItemDelegate):
# elide text, get font values # elide text, get font values
text = textFrag[self.TextRole] text = textFrag[self.TextRole]
fragMaxWidth = maxWidth - (lineBaseX - baseX) fragMaxWidth = maxWidth - (lineBaseX - baseX)
font = textFrag.get(self.FontRole) if font := textFrag.get(self.FontRole):
if font:
painter.setFont(font) painter.setFont(font)
_fm = QFontMetrics(font) _fm = QFontMetrics(font)
else: else:
@ -116,37 +162,3 @@ class TextSegmentDelegate(QStyledItemDelegate):
def super_styledItemDelegate_paint(self, painter, option, index): def super_styledItemDelegate_paint(self, painter, option, index):
return super().paint(painter, option, index) return super().paint(painter, option, index)
class NoCommitWhenFocusOutEventFilter(QObject):
"""
--DEPRECATED--
The default QAbstractItemDelegate implementation has a private function
`editorEventFilter()`, when editor sends focusOut/hide event, it emits the
`commitData(editor)` signal. We don't want this since we need to validate
the input, so we filter the event out and handle it by ourselves.
Reimplement `checkIsEditor(self, val) -> bool` to ensure this filter is
working. The default implementation always return `False`.
"""
def checkIsEditor(self, val) -> bool:
return False
def eventFilter(self, object: QObject, event: QEvent) -> bool:
if self.checkIsEditor(object) and event.type() in [
QEvent.Type.FocusOut,
QEvent.Type.Hide,
]:
widget = QApplication.focusWidget()
while widget:
# check if focus changed into editor's child
if self.checkIsEditor(widget):
return False
widget = widget.parentWidget()
object.hide()
object.deleteLater()
return True
return False

View File

@ -1,7 +1,8 @@
from arcaea_offline.models import Chart from arcaea_offline.models import Chart, Difficulty, Song
from arcaea_offline.utils.rating import rating_class_to_short_text, rating_class_to_text from arcaea_offline.utils.rating import rating_class_to_short_text, rating_class_to_text
from PIL import Image
from PySide6.QtCore import QModelIndex, Qt, Signal from PySide6.QtCore import QModelIndex, Qt, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor, QPainter, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
@ -13,6 +14,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ui.extends.shared.data import Data
from ui.implements.components.chartSelector import ChartSelector from ui.implements.components.chartSelector import ChartSelector
from ..utils import keepWidgetInScreen from ..utils import keepWidgetInScreen
@ -86,51 +88,139 @@ class ChartDelegate(TextSegmentDelegate):
def getChart(self, index: QModelIndex) -> Chart | None: def getChart(self, index: QModelIndex) -> Chart | None:
return None return None
def getSong(self, index: QModelIndex) -> Song | None:
return None
def getDifficulty(self, index: QModelIndex) -> Difficulty | None:
return None
def getTextSegments(self, index: QModelIndex, option): def getTextSegments(self, index: QModelIndex, option):
chart = self.getChart(index) chart = self.getChart(index)
if not isinstance(chart, Chart): song = self.getSong(index)
return [ difficulty = self.getDifficulty(index)
[{self.TextRole: "Chart Invalid", self.ColorRole: QColor("#ff0000")}]
]
chartConstantString = ( chartValid = isinstance(chart, Chart)
f"{chart.constant / 10:.1f}" songValid = isinstance(song, Song)
if chart.constant is not None and chart.constant > 0 difficultyValid = isinstance(difficulty, Difficulty)
else "?"
) if not chartValid and not songValid:
return [ return [
[ [
{self.TextRole: f"{chart.title}"}, {
self.TextRole: "Chart/Song not set",
self.ColorRole: QColor("#ff0000"),
}
]
]
# get texts
if chartValid:
title = chart.title
else:
title = (
difficulty.title if difficultyValid and difficulty.title else song.title
)
if chartValid and chart.constant is not None:
chartConstantString = f"{chart.constant / 10:.1f}"
elif difficultyValid:
chartConstantString = str(difficulty.rating)
if difficulty.rating_plus:
chartConstantString += "+"
else:
chartConstantString = "?"
if chartValid:
ratingClass = chart.rating_class
elif difficultyValid:
ratingClass = difficulty.rating_class
else:
ratingClass = None
ratingText = (
f"{rating_class_to_text(chart.rating_class)} {chartConstantString}"
if ratingClass is not None
else "Unknown ?"
)
if chartValid:
descText = f"({chart.song_id}, {chart.set})"
else:
descText = f"({song.id}, {song.set})"
# get attributes
ratingClassColor = (
self.RatingClassColors[ratingClass] if ratingClass is not None else None
)
return [
[
{self.TextRole: str(title)},
], ],
[ [
{ {
self.TextRole: f"{rating_class_to_text(chart.rating_class)} {chartConstantString}", self.TextRole: ratingText,
self.ColorRole: self.RatingClassColors[chart.rating_class], self.ColorRole: ratingClassColor,
}, },
], ],
[ [
{ {
self.TextRole: f"({chart.song_id}, {chart.set})", self.TextRole: descText,
self.ColorRole: option.widget.palette().placeholderText().color(), self.ColorRole: option.widget.palette().placeholderText().color(),
}, },
], ],
] ]
def paintWarningBackground(self, index: QModelIndex) -> bool: def sizeHint(self, option, index):
return True size = super().sizeHint(option, index)
size.setWidth(size.width() + self.HorizontalPadding + size.height())
return size
def paint(self, painter, option, index): def paint(self, painter, option, index):
# draw chartInvalid warning background
chart = self.getChart(index)
if not isinstance(chart, Chart) and self.paintWarningBackground(index):
painter.save()
painter.setPen(Qt.PenStyle.NoPen)
bgColor = QColor(self.ChartInvalidBackgroundColor)
bgColor.setAlpha(50)
painter.setBrush(bgColor)
painter.drawRect(option.rect)
painter.restore()
option.text = "" option.text = ""
data = Data()
chart = self.getChart(index)
song = self.getSong(index)
difficulty = self.getDifficulty(index)
if isinstance(chart, Chart):
jacketPath = data.getJacketPath(chart)
elif isinstance(song, Song):
jacketPath = data.getJacketPath(song, difficulty)
else:
jacketPath = "__TEXT_ONLY__"
if jacketPath == "__TEXT_ONLY__":
super().paint(painter, option, index)
return
textSizeHint = super().sizeHint(option, index)
jacketSize = textSizeHint.height()
self.baseXOffset = self.HorizontalPadding + jacketSize
jacketSizeTuple = (jacketSize, jacketSize)
if jacketPath:
pixmap = (
Image.open(str(jacketPath.resolve()))
.resize(jacketSizeTuple, Image.BICUBIC)
.toqpixmap()
)
else:
pixmap = (
Image.fromqpixmap(QPixmap(":/images/jacket-placeholder.png"))
.resize(jacketSizeTuple, Image.BICUBIC)
.toqpixmap()
)
painter.save()
painter.setRenderHint(QPainter.RenderHint.LosslessImageRendering, True)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.drawPixmap(
option.rect.x() + self.HorizontalPadding, self.baseY(option, index), pixmap
)
painter.restore()
super().paint(painter, option, index) super().paint(painter, option, index)
def checkIsEditor(self, val): def checkIsEditor(self, val):

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@ -8,6 +8,7 @@
<file>images/icon.png</file> <file>images/icon.png</file>
<file>images/logo.png</file> <file>images/logo.png</file>
<file>images/jacket-placeholder.png</file>
<file>images/stepCalculator/stamina.png</file> <file>images/stepCalculator/stamina.png</file>
<file>images/stepCalculator/play.png</file> <file>images/stepCalculator/play.png</file>
<file>images/stepCalculator/memory-boost.png</file> <file>images/stepCalculator/memory-boost.png</file>