mirror of
https://github.com/283375/arcaea-offline-pyside-ui.git
synced 2025-04-18 08:40:18 +00:00
wip: show song jacket in ChartDelegate
This commit is contained in:
parent
858abe3415
commit
1060590e03
@ -2,8 +2,9 @@ import json
|
||||
import sys
|
||||
from functools import cached_property
|
||||
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 .singleton import Singleton
|
||||
@ -45,3 +46,41 @@ class Data(metaclass=Singleton):
|
||||
@property
|
||||
def arcaeaPath(self):
|
||||
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
|
||||
|
@ -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.QtWidgets import QApplication, QStyledItemDelegate, QStyleOptionViewItem
|
||||
from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem
|
||||
|
||||
|
||||
class TextSegmentDelegateVerticalAlign(IntEnum):
|
||||
Top = 0
|
||||
Middle = 1
|
||||
Bottom = 2
|
||||
|
||||
|
||||
class TextSegmentDelegate(QStyledItemDelegate):
|
||||
@ -15,6 +22,28 @@ class TextSegmentDelegate(QStyledItemDelegate):
|
||||
GradientWrapperRole = TextRole + 3
|
||||
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(
|
||||
self, index: QModelIndex, option
|
||||
) -> list[
|
||||
@ -50,14 +79,32 @@ class TextSegmentDelegate(QStyledItemDelegate):
|
||||
height += lineHeight + self.VerticalPadding
|
||||
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(
|
||||
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
||||
):
|
||||
self.initStyleOption(option, index)
|
||||
# draw text only
|
||||
baseX = option.rect.x() + self.HorizontalPadding
|
||||
baseY = option.rect.y() + self.VerticalPadding
|
||||
maxWidth = option.rect.width() - (2 * self.HorizontalPadding)
|
||||
|
||||
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):
|
||||
@ -69,8 +116,7 @@ class TextSegmentDelegate(QStyledItemDelegate):
|
||||
# elide text, get font values
|
||||
text = textFrag[self.TextRole]
|
||||
fragMaxWidth = maxWidth - (lineBaseX - baseX)
|
||||
font = textFrag.get(self.FontRole)
|
||||
if font:
|
||||
if font := textFrag.get(self.FontRole):
|
||||
painter.setFont(font)
|
||||
_fm = QFontMetrics(font)
|
||||
else:
|
||||
@ -116,37 +162,3 @@ class TextSegmentDelegate(QStyledItemDelegate):
|
||||
|
||||
def super_styledItemDelegate_paint(self, 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
|
||||
|
@ -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 PIL import Image
|
||||
from PySide6.QtCore import QModelIndex, Qt, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@ -13,6 +14,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ui.extends.shared.data import Data
|
||||
from ui.implements.components.chartSelector import ChartSelector
|
||||
|
||||
from ..utils import keepWidgetInScreen
|
||||
@ -86,51 +88,139 @@ class ChartDelegate(TextSegmentDelegate):
|
||||
def getChart(self, index: QModelIndex) -> Chart | 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):
|
||||
chart = self.getChart(index)
|
||||
if not isinstance(chart, Chart):
|
||||
return [
|
||||
[{self.TextRole: "Chart Invalid", self.ColorRole: QColor("#ff0000")}]
|
||||
]
|
||||
song = self.getSong(index)
|
||||
difficulty = self.getDifficulty(index)
|
||||
|
||||
chartConstantString = (
|
||||
f"{chart.constant / 10:.1f}"
|
||||
if chart.constant is not None and chart.constant > 0
|
||||
else "?"
|
||||
)
|
||||
chartValid = isinstance(chart, Chart)
|
||||
songValid = isinstance(song, Song)
|
||||
difficultyValid = isinstance(difficulty, Difficulty)
|
||||
|
||||
if not chartValid and not songValid:
|
||||
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.ColorRole: self.RatingClassColors[chart.rating_class],
|
||||
self.TextRole: ratingText,
|
||||
self.ColorRole: ratingClassColor,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
self.TextRole: f"({chart.song_id}, {chart.set})",
|
||||
self.TextRole: descText,
|
||||
self.ColorRole: option.widget.palette().placeholderText().color(),
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
def paintWarningBackground(self, index: QModelIndex) -> bool:
|
||||
return True
|
||||
def sizeHint(self, option, index):
|
||||
size = super().sizeHint(option, index)
|
||||
size.setWidth(size.width() + self.HorizontalPadding + size.height())
|
||||
return size
|
||||
|
||||
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 = ""
|
||||
|
||||
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)
|
||||
|
||||
def checkIsEditor(self, val):
|
||||
|
BIN
ui/resources/images/jacket-placeholder.png
Normal file
BIN
ui/resources/images/jacket-placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 212 KiB |
@ -8,6 +8,7 @@
|
||||
|
||||
<file>images/icon.png</file>
|
||||
<file>images/logo.png</file>
|
||||
<file>images/jacket-placeholder.png</file>
|
||||
<file>images/stepCalculator/stamina.png</file>
|
||||
<file>images/stepCalculator/play.png</file>
|
||||
<file>images/stepCalculator/memory-boost.png</file>
|
||||
|
Loading…
x
Reference in New Issue
Block a user