mirror of
https://github.com/283375/arcaea-offline-pyside-ui.git
synced 2025-07-01 04:16:26 +00:00
init
This commit is contained in:
0
ui/extends/shared/delegates/__init__.py
Normal file
0
ui/extends/shared/delegates/__init__.py
Normal file
152
ui/extends/shared/delegates/base.py
Normal file
152
ui/extends/shared/delegates/base.py
Normal file
@ -0,0 +1,152 @@
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import QEvent, QModelIndex, QObject, QPoint, QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QLinearGradient, QPainter
|
||||
from PySide6.QtWidgets import QApplication, QStyledItemDelegate, QStyleOptionViewItem
|
||||
|
||||
|
||||
class TextSegmentDelegate(QStyledItemDelegate):
|
||||
VerticalPadding = 3
|
||||
HorizontalPadding = 5
|
||||
|
||||
TextRole = 3375
|
||||
ColorRole = TextRole + 1
|
||||
BrushRole = TextRole + 2
|
||||
GradientWrapperRole = TextRole + 3
|
||||
FontRole = TextRole + 20
|
||||
|
||||
def getTextSegments(
|
||||
self, index: QModelIndex, option
|
||||
) -> list[
|
||||
list[
|
||||
dict[
|
||||
int,
|
||||
str
|
||||
| QColor
|
||||
| QBrush
|
||||
| Callable[[float, float, float, float], QLinearGradient]
|
||||
| QFont,
|
||||
]
|
||||
]
|
||||
]:
|
||||
return []
|
||||
|
||||
def sizeHint(self, option, index) -> QSize:
|
||||
width = 0
|
||||
height = self.VerticalPadding
|
||||
fm: QFontMetrics = option.fontMetrics
|
||||
for line in self.getTextSegments(index, option):
|
||||
lineWidth = 4 * 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 + self.VerticalPadding
|
||||
return QSize(width, height)
|
||||
|
||||
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)
|
||||
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)
|
||||
font = textFrag.get(self.FontRole)
|
||||
if font:
|
||||
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)
|
||||
|
||||
|
||||
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
|
176
ui/extends/shared/delegates/chartDelegate.py
Normal file
176
ui/extends/shared/delegates/chartDelegate.py
Normal file
@ -0,0 +1,176 @@
|
||||
from typing import Union
|
||||
|
||||
from arcaea_offline.models import Chart
|
||||
from arcaea_offline.utils import rating_class_to_short_text, rating_class_to_text
|
||||
from PySide6.QtCore import QDateTime, QModelIndex, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStyleOptionViewItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ui.implements.components.chartSelector import ChartSelector
|
||||
|
||||
from .base import TextSegmentDelegate
|
||||
|
||||
|
||||
def chartToRichText(chart: Chart):
|
||||
if isinstance(chart, Chart):
|
||||
text = f"{chart.name_en} [{rating_class_to_short_text(chart.rating_class)}]"
|
||||
text += "<br>"
|
||||
text += f'<font color="gray">({chart.song_id}, {chart.package_id})</font>'
|
||||
else:
|
||||
text = "(unknown chart)"
|
||||
return text
|
||||
|
||||
|
||||
class ChartSelectorDelegateWrapper(ChartSelector):
|
||||
accepted = Signal()
|
||||
rejected = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.delegateHLine = QFrame(self)
|
||||
self.delegateHLine.setFrameShape(QFrame.Shape.HLine)
|
||||
self.delegateHLine.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.delegateHLine.setFixedHeight(5)
|
||||
self.mainVerticalLayout.insertWidget(0, self.delegateHLine)
|
||||
|
||||
self.delegateHeader = QWidget(self)
|
||||
self.delegateHeaderHBoxLayout = QHBoxLayout(self.delegateHeader)
|
||||
self.delegateHeaderHBoxLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.mainVerticalLayout.insertWidget(0, self.delegateHeader)
|
||||
|
||||
self.editorLabel = QLabel(self)
|
||||
self.editorLabel.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
self.delegateHeaderHBoxLayout.addWidget(self.editorLabel)
|
||||
|
||||
self.editorCommitButton = QPushButton("Commit", self.delegateHeader)
|
||||
self.editorCommitButton.clicked.connect(self.accepted)
|
||||
self.delegateHeaderHBoxLayout.addWidget(self.editorCommitButton)
|
||||
|
||||
self.editorDiscardButton = QPushButton("Discard", self.delegateHeader)
|
||||
self.editorDiscardButton.clicked.connect(self.rejected)
|
||||
self.delegateHeaderHBoxLayout.addWidget(self.editorDiscardButton)
|
||||
|
||||
def setText(self, chart: Chart, _extra: str = None):
|
||||
text = "Editing "
|
||||
text += _extra or ""
|
||||
text += "<br>"
|
||||
text += (
|
||||
chartToRichText(chart) if isinstance(chart, Chart) else "(unknown chart)"
|
||||
)
|
||||
self.editorLabel.setText(text)
|
||||
|
||||
def validate(self):
|
||||
return isinstance(self.value(), Chart)
|
||||
|
||||
|
||||
class ChartDelegate(TextSegmentDelegate):
|
||||
RatingClassColors = [
|
||||
QColor("#399bb2"),
|
||||
QColor("#809955"),
|
||||
QColor("#702d60"),
|
||||
QColor("#710f25"),
|
||||
]
|
||||
ChartInvalidBackgroundColor = QColor("#e6a23c")
|
||||
|
||||
def getChart(self, index: QModelIndex) -> Chart | 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")}]
|
||||
]
|
||||
|
||||
return [
|
||||
[
|
||||
{self.TextRole: f"{chart.name_en}"},
|
||||
],
|
||||
[
|
||||
{
|
||||
self.TextRole: f"{rating_class_to_text(chart.rating_class)} {chart.rating / 10:.1f}",
|
||||
self.ColorRole: self.RatingClassColors[chart.rating_class],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
self.TextRole: f"({chart.song_id}, {chart.package_id})",
|
||||
self.ColorRole: option.widget.palette().placeholderText().color(),
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
def paintWarningBackground(self, index: QModelIndex) -> bool:
|
||||
return True
|
||||
|
||||
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 = ""
|
||||
super().paint(painter, option, index)
|
||||
|
||||
def checkIsEditor(self, val):
|
||||
return isinstance(val, ChartSelectorDelegateWrapper)
|
||||
|
||||
def _closeEditor(self):
|
||||
editor = self.sender()
|
||||
self.closeEditor.emit(editor)
|
||||
|
||||
def _commitEditor(self):
|
||||
editor = self.sender()
|
||||
if editor.validate():
|
||||
confirm = QMessageBox.question(
|
||||
editor,
|
||||
"Confirm",
|
||||
f"Are you sure to change chart to<br><br>{chartToRichText(editor.value())}",
|
||||
)
|
||||
if confirm == QMessageBox.StandardButton.Yes:
|
||||
self.commitData.emit(editor)
|
||||
self.closeEditor.emit(editor)
|
||||
else:
|
||||
QMessageBox.critical(editor, "Invalid chart", "Cannot commit")
|
||||
|
||||
def createEditor(
|
||||
self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex
|
||||
) -> ChartSelectorDelegateWrapper:
|
||||
if isinstance(self.getChart(index), Chart):
|
||||
editor = ChartSelectorDelegateWrapper(parent)
|
||||
editor.setWindowFlag(Qt.WindowType.Sheet, True)
|
||||
editor.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
editor.setText(self.getChart(index))
|
||||
editor.move(parent.mapToGlobal(parent.pos()))
|
||||
editor.accepted.connect(self._commitEditor)
|
||||
editor.rejected.connect(self._closeEditor)
|
||||
return editor
|
||||
|
||||
def updateEditorGeometry(self, editor: QWidget, option, index: QModelIndex) -> None:
|
||||
editor.move(editor.pos() + option.rect.topLeft())
|
||||
editor.setMaximumWidth(option.rect.width())
|
||||
|
||||
def setEditorData(self, editor: ChartSelectorDelegateWrapper, index: QModelIndex):
|
||||
if self.checkIsEditor(editor) and isinstance(self.getChart(index), Chart):
|
||||
editor.selectChart(self.getChart(index))
|
||||
return super().setEditorData(editor, index)
|
||||
|
||||
def setModelData(self, editor: ChartSelectorDelegateWrapper, model, index):
|
||||
...
|
35
ui/extends/shared/delegates/descriptionDelegate.py
Normal file
35
ui/extends/shared/delegates/descriptionDelegate.py
Normal file
@ -0,0 +1,35 @@
|
||||
from PySide6.QtCore import QModelIndex, Qt
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
|
||||
|
||||
from .base import TextSegmentDelegate
|
||||
|
||||
|
||||
class DescriptionDelegate(TextSegmentDelegate):
|
||||
MainTextRole = Qt.ItemDataRole.UserRole + 75
|
||||
DescriptionTextRole = Qt.ItemDataRole.UserRole + 76
|
||||
|
||||
def getMainText(self, index: QModelIndex) -> str | None:
|
||||
return index.data(self.MainTextRole)
|
||||
|
||||
def getDescriptionText(self, index: QModelIndex) -> str | None:
|
||||
return index.data(self.DescriptionTextRole)
|
||||
|
||||
def getTextSegments(self, index: QModelIndex, option):
|
||||
return [
|
||||
[
|
||||
{self.TextRole: self.getMainText(index) or ""},
|
||||
{self.TextRole: " "},
|
||||
{
|
||||
self.TextRole: self.getDescriptionText(index) or "",
|
||||
self.ColorRole: option.widget.palette().placeholderText().color(),
|
||||
},
|
||||
]
|
||||
]
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
super().paint(painter, option, index)
|
||||
|
||||
optionNoText = QStyleOptionViewItem(option)
|
||||
optionNoText.text = ""
|
||||
style = option.widget.style() # type: QStyle
|
||||
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, optionNoText, painter)
|
247
ui/extends/shared/delegates/scoreDelegate.py
Normal file
247
ui/extends/shared/delegates/scoreDelegate.py
Normal file
@ -0,0 +1,247 @@
|
||||
from typing import Union
|
||||
|
||||
from arcaea_offline.calculate import calculate_score_range
|
||||
from arcaea_offline.models import Chart, Score, ScoreInsert
|
||||
from arcaea_offline.utils import (
|
||||
rating_class_to_text,
|
||||
score_to_grade_text,
|
||||
zip_score_grade,
|
||||
)
|
||||
from PySide6.QtCore import QAbstractItemModel, QDateTime, QModelIndex, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QLinearGradient
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemDelegate,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ui.implements.components.scoreEditor import ScoreEditor
|
||||
|
||||
from ..utils import keepWidgetInScreen
|
||||
from .base import TextSegmentDelegate
|
||||
|
||||
|
||||
class ScoreEditorDelegateWrapper(ScoreEditor):
|
||||
rejected = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.hLine = QFrame(self)
|
||||
self.hLine.setFrameShape(QFrame.Shape.HLine)
|
||||
self.hLine.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.hLine.setFixedHeight(5)
|
||||
self.formLayout.insertRow(0, self.hLine)
|
||||
|
||||
self.delegateHeader = QWidget(self)
|
||||
self.delegateHeaderHBoxLayout = QHBoxLayout(self.delegateHeader)
|
||||
self.delegateHeaderHBoxLayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.editorLabel = QLabel(self.delegateHeader)
|
||||
self.editorLabel.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
self.delegateHeaderHBoxLayout.addWidget(self.editorLabel)
|
||||
|
||||
self.editorDiscardButton = QPushButton("Discard", self.delegateHeader)
|
||||
self.editorDiscardButton.clicked.connect(self.rejected)
|
||||
self.delegateHeaderHBoxLayout.addWidget(self.editorDiscardButton)
|
||||
|
||||
self.formLayout.insertRow(0, self.delegateHeader)
|
||||
|
||||
def setText(self, score: Score | ScoreInsert, _extra: str = None):
|
||||
text = "Editing "
|
||||
text += _extra or ""
|
||||
text += f"score {score.score}"
|
||||
text += f"<br>(P{score.pure} F{score.far} L{score.lost} | MR{score.max_recall})"
|
||||
self.editorLabel.setText(text)
|
||||
|
||||
|
||||
class ScoreDelegate(TextSegmentDelegate):
|
||||
@staticmethod
|
||||
def createGradeGradientWrapper(topColor: QColor, bottomColor: QColor):
|
||||
def wrapper(x, y, width, height):
|
||||
gradient = QLinearGradient(x + (width / 2), y, x + (width / 2), y + height)
|
||||
gradient.setColorAt(0.1, topColor)
|
||||
gradient.setColorAt(0.9, bottomColor)
|
||||
return gradient
|
||||
|
||||
return wrapper
|
||||
|
||||
ScoreMismatchBackgroundColor = QColor("#e6a23c")
|
||||
PureFarLostColors = [
|
||||
QColor("#f22ec6"),
|
||||
QColor("#ff9028"),
|
||||
QColor("#ff0c43"),
|
||||
]
|
||||
GradeGradientsWrappers = [ # EX+, EX, AA, A. B, C, D
|
||||
createGradeGradientWrapper(QColor("#83238c"), QColor("#2c72ae")),
|
||||
createGradeGradientWrapper(QColor("#721b6b"), QColor("#295b8d")),
|
||||
createGradeGradientWrapper(QColor("#5a3463"), QColor("#9b4b8d")),
|
||||
createGradeGradientWrapper(QColor("#46324d"), QColor("#92588a")),
|
||||
createGradeGradientWrapper(QColor("#43334a"), QColor("#755b7c")),
|
||||
createGradeGradientWrapper(QColor("#3b2b27"), QColor("#80566b")),
|
||||
createGradeGradientWrapper(QColor("#5d1d35"), QColor("#9f3c55")),
|
||||
]
|
||||
|
||||
def getScore(self, index: QModelIndex) -> Score | None:
|
||||
return None
|
||||
|
||||
def getScoreInsert(self, index: QModelIndex) -> ScoreInsert | None:
|
||||
return None
|
||||
|
||||
def _getScore(self, index: QModelIndex):
|
||||
score = self.getScore(index)
|
||||
scoreInsert = self.getScoreInsert(index)
|
||||
return scoreInsert if score is None else score
|
||||
|
||||
def getChart(self, index: QModelIndex) -> Chart | None:
|
||||
return None
|
||||
|
||||
def getScoreValidateOk(self, index: QModelIndex) -> bool | None:
|
||||
score = self._getScore(index)
|
||||
chart = self.getChart(index)
|
||||
|
||||
if isinstance(score, (Score, ScoreInsert)) and isinstance(chart, Chart):
|
||||
scoreRange = calculate_score_range(chart, score.pure, score.far)
|
||||
return scoreRange[0] <= score.score <= scoreRange[1]
|
||||
|
||||
def getScoreGradeGradientWrapper(self, score: int):
|
||||
return zip_score_grade(score, self.GradeGradientsWrappers)
|
||||
|
||||
def getTextSegments(self, index, option):
|
||||
score = self._getScore(index)
|
||||
chart = self.getChart(index)
|
||||
if not (isinstance(score, (Score, ScoreInsert)) and isinstance(chart, Chart)):
|
||||
return [
|
||||
[
|
||||
{
|
||||
self.TextRole: "Chart/Score Invalid",
|
||||
self.ColorRole: QColor("#ff0000"),
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
score_font = QFont(option.font)
|
||||
score_font.setPointSize(12)
|
||||
score_grade_font = QFont(score_font)
|
||||
score_grade_font.setBold(True)
|
||||
return [
|
||||
[
|
||||
{
|
||||
self.TextRole: score_to_grade_text(score.score),
|
||||
self.GradientWrapperRole: self.getScoreGradeGradientWrapper(
|
||||
score.score
|
||||
),
|
||||
self.FontRole: score_grade_font,
|
||||
},
|
||||
{self.TextRole: " | "},
|
||||
{self.TextRole: str(score.score), self.FontRole: score_font},
|
||||
],
|
||||
[
|
||||
{
|
||||
self.TextRole: f"PURE {score.pure}",
|
||||
self.ColorRole: self.PureFarLostColors[0],
|
||||
},
|
||||
{self.TextRole: " "},
|
||||
{
|
||||
self.TextRole: f"FAR {score.far}",
|
||||
self.ColorRole: self.PureFarLostColors[1],
|
||||
},
|
||||
{self.TextRole: " "},
|
||||
{
|
||||
self.TextRole: f"LOST {score.lost}",
|
||||
self.ColorRole: self.PureFarLostColors[2],
|
||||
},
|
||||
{self.TextRole: " | "},
|
||||
{self.TextRole: f"MAX RECALL {score.max_recall}"},
|
||||
],
|
||||
[
|
||||
{
|
||||
self.TextRole: QDateTime.fromSecsSinceEpoch(score.time).toString(
|
||||
"yyyy-MM-dd hh:mm:ss"
|
||||
)
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
def paintWarningBackground(self, index: QModelIndex) -> bool:
|
||||
return True
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
# draw scoreMismatch warning background
|
||||
score = self._getScore(index)
|
||||
chart = self.getChart(index)
|
||||
if (
|
||||
isinstance(score, (Score, ScoreInsert))
|
||||
and isinstance(chart, Chart)
|
||||
and self.paintWarningBackground(index)
|
||||
):
|
||||
scoreValidateOk = self.getScoreValidateOk(index)
|
||||
if not scoreValidateOk:
|
||||
painter.save()
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
bgColor = QColor(self.ScoreMismatchBackgroundColor)
|
||||
bgColor.setAlpha(50)
|
||||
painter.setBrush(bgColor)
|
||||
painter.drawRect(option.rect)
|
||||
painter.restore()
|
||||
|
||||
option.text = ""
|
||||
super().paint(painter, option, index)
|
||||
|
||||
def _closeEditor(self):
|
||||
editor = self.sender()
|
||||
self.closeEditor.emit(editor)
|
||||
|
||||
def _commitEditor(self):
|
||||
editor = self.sender()
|
||||
self.commitData.emit(editor)
|
||||
self.closeEditor.emit(editor)
|
||||
|
||||
def createEditor(self, parent, option, index) -> ScoreEditorDelegateWrapper:
|
||||
score = self._getScore(index)
|
||||
chart = self.getChart(index)
|
||||
if isinstance(score, (Score, ScoreInsert)) and isinstance(chart, Chart):
|
||||
editor = ScoreEditorDelegateWrapper(parent)
|
||||
editor.setWindowFlag(Qt.WindowType.Sheet, True)
|
||||
editor.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
editor.setWindowTitle(
|
||||
f"{chart.name_en}({chart.song_id}) | {rating_class_to_text(chart.rating_class)} | {chart.package_id}"
|
||||
)
|
||||
editor.setText(self._getScore(index))
|
||||
editor.setValidateBeforeAccept(False)
|
||||
editor.move(parent.mapToGlobal(parent.pos()))
|
||||
editor.accepted.connect(self._commitEditor)
|
||||
editor.rejected.connect(self._closeEditor)
|
||||
editor.show()
|
||||
return editor
|
||||
return super().createEditor(parent, option, index)
|
||||
|
||||
def updateEditorGeometry(self, editor, option, index):
|
||||
editor.setMaximumWidth(option.rect.width())
|
||||
editor.move(editor.pos() + option.rect.topLeft())
|
||||
|
||||
keepWidgetInScreen(editor)
|
||||
|
||||
def setEditorData(self, editor: ScoreEditorDelegateWrapper, index) -> None:
|
||||
score = self._getScore(index)
|
||||
chart = self.getChart(index)
|
||||
if isinstance(score, (Score, ScoreInsert)) and isinstance(chart, Chart):
|
||||
editor.setChart(chart)
|
||||
editor.setValue(score)
|
||||
|
||||
def confirmSetModelData(self, editor: ScoreEditorDelegateWrapper):
|
||||
return editor.triggerValidateMessageBox()
|
||||
|
||||
def setModelData(
|
||||
self,
|
||||
editor: ScoreEditorDelegateWrapper,
|
||||
model: QAbstractItemModel,
|
||||
index: QModelIndex,
|
||||
):
|
||||
...
|
35
ui/extends/shared/models/tables/base.py
Normal file
35
ui/extends/shared/models/tables/base.py
Normal file
@ -0,0 +1,35 @@
|
||||
from typing import Union
|
||||
|
||||
from arcaea_offline.database import Database
|
||||
from PySide6.QtCore import QAbstractTableModel, Qt
|
||||
|
||||
|
||||
class DbTableModel(QAbstractTableModel):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._horizontalHeaders = []
|
||||
self.retranslateHeaders()
|
||||
|
||||
self._db = Database()
|
||||
|
||||
def retranslateHeaders(self):
|
||||
...
|
||||
|
||||
def syncDb(self):
|
||||
...
|
||||
|
||||
def headerData(self, section: int, orientation: Qt.Orientation, role: int):
|
||||
if (
|
||||
orientation == Qt.Orientation.Horizontal
|
||||
and self._horizontalHeaders
|
||||
and 0 <= section < len(self._horizontalHeaders)
|
||||
and role == Qt.ItemDataRole.DisplayRole
|
||||
):
|
||||
return self._horizontalHeaders[section]
|
||||
return super().headerData(section, orientation, role)
|
||||
|
||||
def columnCount(self, parent=None):
|
||||
if self._horizontalHeaders:
|
||||
return len(self._horizontalHeaders)
|
||||
return super().columnCount(parent)
|
197
ui/extends/shared/models/tables/score.py
Normal file
197
ui/extends/shared/models/tables/score.py
Normal file
@ -0,0 +1,197 @@
|
||||
from arcaea_offline.calculate import calculate_score
|
||||
from arcaea_offline.models import Chart, Score, ScoreInsert
|
||||
from PySide6.QtCore import QCoreApplication, QModelIndex, QSortFilterProxyModel, Qt
|
||||
|
||||
from .base import DbTableModel
|
||||
|
||||
|
||||
class DbScoreTableModel(DbTableModel):
|
||||
IdRole = Qt.ItemDataRole.UserRole + 10
|
||||
ChartRole = Qt.ItemDataRole.UserRole + 11
|
||||
ScoreRole = Qt.ItemDataRole.UserRole + 12
|
||||
PttRole = Qt.ItemDataRole.UserRole + 13
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.__items = []
|
||||
|
||||
def retranslateHeaders(self):
|
||||
self._horizontalHeaders = [
|
||||
# fmt: off
|
||||
QCoreApplication.translate("DbScoreTableModel", "horizontalHeader.id"),
|
||||
QCoreApplication.translate("DbScoreTableModel", "horizontalHeader.chart"),
|
||||
QCoreApplication.translate("DbScoreTableModel", "horizontalHeader.score"),
|
||||
QCoreApplication.translate("DbScoreTableModel", "horizontalHeader.potential"),
|
||||
# fmt: on
|
||||
]
|
||||
|
||||
def syncDb(self):
|
||||
newScores = [Score.from_db_row(dbRow) for dbRow in self._db.get_scores()]
|
||||
newScores = sorted(newScores, key=lambda x: x.id)
|
||||
newCharts = [
|
||||
Chart.from_db_row(dbRow)
|
||||
for dbRow in [
|
||||
self._db.get_chart(score.song_id, score.rating_class)
|
||||
for score in newScores
|
||||
]
|
||||
]
|
||||
newPtts = []
|
||||
for chart, score in zip(newCharts, newScores):
|
||||
if isinstance(chart, Chart) and isinstance(score, Score):
|
||||
newPtts.append(calculate_score(chart, score).potential)
|
||||
else:
|
||||
newPtts.append(None)
|
||||
|
||||
newScoreIds = [score.id for score in newScores]
|
||||
oldScoreIds = [item[self.ScoreRole].id for item in self.__items]
|
||||
|
||||
deleteIds = list(set(oldScoreIds) - set(newScoreIds))
|
||||
newIds = list(set(newScoreIds) - set(oldScoreIds))
|
||||
deleteRowIndexes = [oldScoreIds.index(deleteId) for deleteId in deleteIds]
|
||||
|
||||
# first delete rows
|
||||
for deleteRowIndex in sorted(deleteRowIndexes, reverse=True):
|
||||
self.beginRemoveRows(QModelIndex(), deleteRowIndex, deleteRowIndex)
|
||||
self.__items.pop(deleteRowIndex)
|
||||
self.endRemoveRows()
|
||||
|
||||
# now update existing datas
|
||||
for oldItem, newChart, newScore, newPtt in zip(
|
||||
self.__items, newCharts, newScores, newPtts
|
||||
):
|
||||
oldItem[self.IdRole] = newScore.id
|
||||
oldItem[self.ChartRole] = newChart
|
||||
oldItem[self.ScoreRole] = newScore
|
||||
oldItem[self.PttRole] = newPtt
|
||||
|
||||
# finally insert new rows
|
||||
for newId in newIds:
|
||||
insertRowIndex = self.rowCount()
|
||||
itemListIndex = newScoreIds.index(newId)
|
||||
score = newScores[itemListIndex]
|
||||
chart = newCharts[itemListIndex]
|
||||
ptt = newPtts[itemListIndex]
|
||||
self.beginInsertRows(QModelIndex(), insertRowIndex, insertRowIndex)
|
||||
self.__items.append(
|
||||
{
|
||||
self.IdRole: score.id,
|
||||
self.ChartRole: chart,
|
||||
self.ScoreRole: score,
|
||||
self.PttRole: ptt,
|
||||
}
|
||||
)
|
||||
self.endInsertRows()
|
||||
|
||||
# trigger view update
|
||||
topLeft = self.index(0, 0)
|
||||
bottomRight = self.index(self.rowCount() - 1, self.columnCount() - 1)
|
||||
self.dataChanged.emit(
|
||||
topLeft,
|
||||
bottomRight,
|
||||
[Qt.ItemDataRole.DisplayRole, self.IdRole, self.ChartRole, self.ScoreRole],
|
||||
)
|
||||
|
||||
def rowCount(self, *args):
|
||||
return len(self.__items)
|
||||
|
||||
def data(self, index, role):
|
||||
if index.isValid() and self.checkIndex(index):
|
||||
if index.column() == 0 and role in [
|
||||
Qt.ItemDataRole.DisplayRole,
|
||||
self.IdRole,
|
||||
]:
|
||||
return self.__items[index.row()][self.IdRole]
|
||||
elif index.column() == 1 and role == self.ChartRole:
|
||||
return self.__items[index.row()][self.ChartRole]
|
||||
elif index.column() == 2 and role in [self.ChartRole, self.ScoreRole]:
|
||||
return self.__items[index.row()][role]
|
||||
elif index.column() == 3:
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
return f"{self.__items[index.row()][self.PttRole]:.3f}"
|
||||
elif role == self.PttRole:
|
||||
return self.__items[index.row()][self.PttRole]
|
||||
return None
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if not (index.isValid() and self.checkIndex(index)):
|
||||
return False
|
||||
|
||||
if (
|
||||
index.column() == 2
|
||||
and isinstance(value, ScoreInsert)
|
||||
and role == self.ScoreRole
|
||||
):
|
||||
self._db.update_score(self.__items[index.row()][self.IdRole], value)
|
||||
self.syncDb()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def flags(self, index) -> Qt.ItemFlag:
|
||||
flags = super().flags(index)
|
||||
flags |= Qt.ItemFlag.ItemIsSelectable
|
||||
if index.column() in [1, 2]:
|
||||
flags |= Qt.ItemFlag.ItemIsEditable
|
||||
return flags
|
||||
|
||||
def _removeRow(self, row: int, syncDb: bool = True):
|
||||
if not 0 <= row < self.rowCount():
|
||||
return False
|
||||
|
||||
try:
|
||||
self._db.delete_score(self.__items[row][self.IdRole])
|
||||
if syncDb:
|
||||
self.syncDb()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def removeRow(self, row: int, parent=...):
|
||||
return self._removeRow(row)
|
||||
|
||||
def removeRows(self, row: int, count: int, parent=...):
|
||||
maxRow = min(self.rowCount() - 1, row + count - 1)
|
||||
if row > maxRow:
|
||||
return False
|
||||
|
||||
result = all(
|
||||
self._removeRow(row, syncDb=False) for row in range(row, row + count)
|
||||
)
|
||||
self.syncDb()
|
||||
return result
|
||||
|
||||
def removeRowList(self, rowList: list[int]):
|
||||
result = all(
|
||||
self._removeRow(row, syncDb=False) for row in sorted(rowList, reverse=True)
|
||||
)
|
||||
self.syncDb()
|
||||
return result
|
||||
|
||||
|
||||
class DbScoreTableSortFilterProxyModel(QSortFilterProxyModel):
|
||||
Sort_C2_ScoreRole = Qt.ItemDataRole.UserRole + 75
|
||||
Sort_C2_TimeRole = Qt.ItemDataRole.UserRole + 76
|
||||
|
||||
def lessThan(self, source_left, source_right) -> bool:
|
||||
if source_left.column() != source_right.column():
|
||||
return
|
||||
|
||||
column = source_left.column()
|
||||
if column == 0:
|
||||
return source_left.data(DbScoreTableModel.IdRole) < source_right.data(
|
||||
DbScoreTableModel.IdRole
|
||||
)
|
||||
elif column == 2:
|
||||
score_left = source_left.data(DbScoreTableModel.ScoreRole)
|
||||
score_right = source_right.data(DbScoreTableModel.ScoreRole)
|
||||
if isinstance(score_left, Score) and isinstance(score_right, Score):
|
||||
if self.sortRole() == self.Sort_C2_ScoreRole:
|
||||
return score_left.score < score_right.score
|
||||
elif self.sortRole() == self.Sort_C2_TimeRole:
|
||||
return score_left.time < score_right.time
|
||||
elif column == 3:
|
||||
return source_left.data(DbScoreTableModel.PttRole) < source_right.data(
|
||||
DbScoreTableModel.PttRole
|
||||
)
|
||||
return super().lessThan(source_left, source_right)
|
54
ui/extends/shared/utils.py
Normal file
54
ui/extends/shared/utils.py
Normal file
@ -0,0 +1,54 @@
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QGuiApplication, QScreen
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
def keepWidgetInScreen(widget: QWidget, screen: QScreen = None):
|
||||
"""ensure your widget is visible"""
|
||||
|
||||
# see https://doc.qt.io/qt-6/application-windows.html
|
||||
# for why using frameGeometry.width() / frameGeometry.height()
|
||||
# instead of width() / height().
|
||||
|
||||
screen = screen or QGuiApplication.primaryScreen()
|
||||
screenAvailableGeometry = screen.availableGeometry()
|
||||
|
||||
# X boundary
|
||||
if widget.pos().x() < screenAvailableGeometry.x():
|
||||
pos = QPoint(widget.pos())
|
||||
pos.setX(screenAvailableGeometry.x())
|
||||
widget.move(pos)
|
||||
elif (
|
||||
widget.pos().x() + widget.frameGeometry().width()
|
||||
> screenAvailableGeometry.width()
|
||||
):
|
||||
pos = QPoint(widget.pos())
|
||||
pos.setX(
|
||||
pos.x()
|
||||
- (
|
||||
pos.x()
|
||||
+ widget.frameGeometry().width()
|
||||
- screenAvailableGeometry.width()
|
||||
)
|
||||
)
|
||||
widget.move(pos)
|
||||
|
||||
# Y boundary
|
||||
if widget.pos().y() < screenAvailableGeometry.y():
|
||||
pos = QPoint(widget.pos())
|
||||
pos.setY(screenAvailableGeometry.y())
|
||||
widget.move(pos)
|
||||
elif (
|
||||
widget.pos().y() + widget.frameGeometry().height()
|
||||
> screenAvailableGeometry.height()
|
||||
):
|
||||
pos = QPoint(widget.pos())
|
||||
pos.setY(
|
||||
pos.y()
|
||||
- (
|
||||
pos.y()
|
||||
+ widget.frameGeometry().height()
|
||||
- screenAvailableGeometry.height()
|
||||
)
|
||||
)
|
||||
widget.move(pos)
|
Reference in New Issue
Block a user