This commit is contained in:
2023-07-07 01:41:19 +08:00
commit 95da43261e
83 changed files with 7529 additions and 0 deletions

0
ui/extends/__init__.py Normal file
View File

16
ui/extends/color.py Normal file
View File

@ -0,0 +1,16 @@
from PySide6.QtGui import QColor
def mix_color(source_color: QColor, mix_color: QColor, mix_ratio: float = 0.5):
r = round((mix_color.red() - source_color.red()) * mix_ratio + source_color.red())
g = round(
(mix_color.green() - source_color.green()) * mix_ratio + source_color.green()
)
b = round(
(mix_color.blue() - source_color.blue()) * mix_ratio + source_color.blue()
)
a = round(
(mix_color.alpha() - source_color.alpha()) * mix_ratio + source_color.alpha()
)
return QColor(r, g, b, a)

View File

@ -0,0 +1,33 @@
from arcaea_offline.database import Database
from arcaea_offline.models import Chart
from arcaea_offline.utils import rating_class_to_short_text
from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItem, QStandardItemModel
class FuzzySearchCompleterModel(QStandardItemModel):
def fillDbFuzzySearchResults(self, db: Database, kw: str):
self.clear()
results = db.fuzzy_search_song_id(kw, limit=10)
results = sorted(results, key=lambda r: r.confidence, reverse=True)
songIds = [r.song_id for r in results]
charts: list[Chart] = []
for songId in songIds:
dbChartRows = db.get_charts_by_song_id(songId)
_charts = [Chart.from_db_row(dbRow) for dbRow in dbChartRows]
_charts = sorted(_charts, key=lambda c: c.rating_class, reverse=True)
charts += _charts
for chart in charts:
displayText = (
f"{chart.name_en} [{rating_class_to_short_text(chart.rating_class)}]"
)
item = QStandardItem(kw)
item.setData(kw)
item.setData(displayText, Qt.ItemDataRole.UserRole + 75)
item.setData(
f"{chart.song_id}, {chart.package_id}", Qt.ItemDataRole.UserRole + 76
)
item.setData(chart, Qt.ItemDataRole.UserRole + 10)
self.appendRow(item)

View File

@ -0,0 +1,5 @@
from PySide6.QtCore import QAbstractTableModel
class DbTableModel(QAbstractTableModel):
pass

18
ui/extends/ocr.py Normal file
View File

@ -0,0 +1,18 @@
try:
import json
from arcaea_offline_ocr.device import Device
def load_devices_json(filepath: str) -> list[Device]:
with open(filepath, "r", encoding="utf-8") as f:
file_content = f.read()
if len(file_content) == 0:
return []
content = json.loads(file_content)
assert isinstance(content, list)
return [Device.from_json_object(item) for item in content]
except Exception:
def load_devices_json(*args, **kwargs):
pass

0
ui/extends/score.py Normal file
View File

57
ui/extends/settings.py Normal file
View File

@ -0,0 +1,57 @@
from PySide6.QtCore import QDir, QSettings
__all__ = [
"DATABASE_PATH",
"DEVICES_JSON_FILE",
"DEVICE_UUID",
"TESSERACT_FILE",
"Settings",
]
DATABASE_PATH = "General/DatabasePath"
DEVICES_JSON_FILE = "Ocr/DevicesJsonFile"
DEVICE_UUID = "Ocr/DeviceUuid"
TESSERACT_FILE = "Ocr/TesseractFile"
class Settings(QSettings):
def __init__(self, parent=None):
super().__init__(
QDir.current().absoluteFilePath("arcaea_offline.ini"),
QSettings.Format.IniFormat,
parent,
)
def devicesJsonFile(self) -> str | None:
return self.value(DEVICES_JSON_FILE, None, str)
def setDevicesJsonFile(self, path: str):
self.setValue(DEVICES_JSON_FILE, path)
self.sync()
def resetDevicesJsonFile(self):
self.setValue(DEVICES_JSON_FILE, None)
self.sync()
def deviceUuid(self) -> str | None:
return self.value(DEVICE_UUID, None, str)
def setDeviceUuid(self, uuid: str):
self.setValue(DEVICE_UUID, uuid)
self.sync()
def resetDeviceUuid(self):
self.setValue(DEVICE_UUID, None)
self.sync()
def tesseractPath(self):
return self.value(TESSERACT_FILE, None, str)
def setTesseractPath(self, path: str):
self.setValue(TESSERACT_FILE, path)
self.sync()
def resetTesseractPath(self):
self.setValue(TESSERACT_FILE, None)
self.sync()

View File

View 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

View 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):
...

View 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)

View 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,
):
...

View 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)

View 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)

View 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)

476
ui/extends/tabs/tabOcr.py Normal file
View File

@ -0,0 +1,476 @@
import contextlib
import logging
from typing import Any
import exif
from arcaea_offline.calculate import calculate_score_range
from arcaea_offline.database import Database
from arcaea_offline.models import Chart, ScoreInsert
from arcaea_offline_ocr.device import Device
from arcaea_offline_ocr.recognize import RecognizeResult, recognize
from PySide6.QtCore import (
QAbstractListModel,
QAbstractTableModel,
QCoreApplication,
QDateTime,
QFileInfo,
QModelIndex,
QObject,
QRect,
QRunnable,
QSize,
Qt,
QThreadPool,
Signal,
Slot,
)
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QLabel, QStyledItemDelegate, QWidget
from ui.extends.shared.delegates.chartDelegate import ChartDelegate
from ui.extends.shared.delegates.scoreDelegate import ScoreDelegate
from ui.implements.components.scoreEditor import ScoreEditor
logger = logging.getLogger(__name__)
class OcrTaskSignals(QObject):
resultReady = Signal(int, RecognizeResult)
finished = Signal(int)
class OcrTask(QRunnable):
def __init__(self, index: int, device: Device, imagePath: str):
super().__init__()
self.index = index
self.device = device
self.imagePath = imagePath
self.signals = OcrTaskSignals()
def run(self):
try:
result = recognize(self.imagePath, self.device)
self.signals.resultReady.emit(self.index, result)
logger.info(
f"OcrTask {self.imagePath} with {repr(self.device)} got result {repr(result)}"
)
except Exception as e:
logger.exception(
f"OcrTask {self.imagePath} with {repr(self.device)} failed"
)
finally:
self.signals.finished.emit(self.index)
class OcrQueueModel(QAbstractListModel):
ImagePathRole = Qt.ItemDataRole.UserRole + 1
ImagePixmapRole = Qt.ItemDataRole.UserRole + 2
RecognizeResultRole = Qt.ItemDataRole.UserRole + 10
ScoreInsertRole = Qt.ItemDataRole.UserRole + 11
ChartRole = Qt.ItemDataRole.UserRole + 12
ScoreValidateOkRole = Qt.ItemDataRole.UserRole + 13
started = Signal()
finished = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.__db = Database()
self.__items: list[dict[int, Any]] = []
@property
def imagePaths(self):
return [item.get(self.ImagePathRole) for item in self.__items]
def clear(self):
self.beginResetModel()
self.beginRemoveRows(QModelIndex(), 0, self.rowCount() - 1)
self.__items.clear()
self.endRemoveRows()
self.endResetModel()
def rowCount(self, *args):
return len(self.__items)
def data(self, index, role):
if (
index.isValid()
and 0 <= index.row() < self.rowCount()
and index.column() == 0
):
return self.__items[index.row()].get(role)
return None
def setData(self, *args):
return False
def addItem(self, imagePath: str):
if imagePath in self.imagePaths or not QFileInfo(imagePath).exists():
return
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self.__items.append(
{
self.ImagePathRole: imagePath,
self.ImagePixmapRole: QPixmap(imagePath),
self.RecognizeResultRole: None,
self.ScoreInsertRole: None,
self.ChartRole: None,
self.ScoreValidateOkRole: False,
}
)
self.endInsertRows()
def updateOcrResult(self, row: int, result: RecognizeResult) -> bool:
if not 0 <= row < self.rowCount() or not isinstance(result, RecognizeResult):
return False
item = self.__items[row]
imagePath: str = item[self.ImagePathRole]
datetime = None
with contextlib.suppress(Exception):
with open(imagePath, "rb") as imgf:
exifImage = exif.Image(imgf.read())
if exifImage.has_exif and exifImage.get("datetime_original"):
datetimeStr = exifImage.get("datetime_original")
datetime = QDateTime.fromString(datetimeStr, "yyyy:MM:dd hh:mm:ss")
if not isinstance(datetime, QDateTime):
datetime = QFileInfo(imagePath).birthTime()
score = ScoreInsert(
song_id=self.__db.fuzzy_search_song_id(result.title)[0][0],
rating_class=result.rating_class,
score=result.score,
pure=result.pure,
far=result.far,
lost=result.lost,
time=datetime.toSecsSinceEpoch(),
max_recall=result.max_recall,
clear_type=None,
)
chart = Chart.from_db_row(
self.__db.get_chart(score.song_id, score.rating_class)
)
item[self.RecognizeResultRole] = result
self.setItemChart(row, chart)
self.setItemScore(row, score)
modelIndex = self.index(row, 0)
self.dataChanged.emit(
modelIndex,
modelIndex,
[self.RecognizeResultRole, self.ScoreInsertRole, self.ChartRole],
)
return True
@Slot(int, RecognizeResult)
def ocrTaskReady(self, row: int, result: RecognizeResult):
self.updateOcrResult(row, result)
@Slot(int)
def ocrTaskFinished(self, row: int):
self.__taskFinishedNum += 1
if self.__taskFinishedNum == self.__taskNum:
self.finished.emit()
def startQueue(self, device: Device):
self.__taskNum = self.rowCount()
self.__taskFinishedNum = 0
self.started.emit()
for row in range(self.rowCount()):
modelIndex = self.index(row, 0)
imagePath: str = modelIndex.data(self.ImagePathRole)
task = OcrTask(row, device, imagePath)
task.signals.resultReady.connect(self.ocrTaskReady)
task.signals.finished.connect(self.ocrTaskFinished)
QThreadPool.globalInstance().start(task)
def updateScoreValidateOk(self, row: int):
if not 0 <= row < self.rowCount():
return
item = self.__items[row]
chart = item[self.ChartRole]
score = item[self.ScoreInsertRole]
if isinstance(chart, Chart) and isinstance(score, ScoreInsert):
scoreRange = calculate_score_range(chart, score.pure, score.far)
scoreValidateOk = scoreRange[0] <= score.score <= scoreRange[1]
item[self.ScoreValidateOkRole] = scoreValidateOk
else:
item[self.ScoreValidateOkRole] = False
modelIndex = self.index(row, 0)
self.dataChanged.emit(modelIndex, modelIndex, [self.ScoreValidateOkRole])
def setItemChart(self, row: int, chart: Chart):
if not 0 <= row < self.rowCount() or not isinstance(chart, Chart):
return False
item = self.__items[row]
item[self.ChartRole] = chart
updatedRoles = [self.ChartRole]
self.updateScoreValidateOk(row)
modelIndex = self.index(row, 0)
self.dataChanged.emit(modelIndex, modelIndex, updatedRoles)
return True
def setItemScore(self, row: int, score: ScoreInsert) -> bool:
if not 0 <= row < self.rowCount() or not isinstance(score, ScoreInsert):
return False
item = self.__items[row]
item[self.ScoreInsertRole] = score
updatedRoles = [self.ScoreInsertRole]
self.updateScoreValidateOk(row)
modelIndex = self.index(row, 0)
self.dataChanged.emit(modelIndex, modelIndex, updatedRoles)
return True
def acceptItem(self, row: int, ignoreValidate: bool = False):
if not 0 <= row < self.rowCount():
return
item = self.__items[row]
score = item[self.ScoreInsertRole]
if not isinstance(score, ScoreInsert) or (
not item[self.ScoreValidateOkRole] and not ignoreValidate
):
return
try:
self.__db.insert_score(score)
self.beginRemoveRows(QModelIndex(), row, row)
self.__items.pop(row)
self.endRemoveRows()
return
except Exception as e:
logger.exception(f"Error accepting {repr(item)}")
return
def acceptItems(self, __rows: list[int], ignoreValidate: bool = False):
items = sorted(__rows, reverse=True)
[self.acceptItem(item, ignoreValidate) for item in items]
def acceptAllItems(self, ignoreValidate: bool = False):
self.acceptItems([*range(self.rowCount())], ignoreValidate)
def removeItem(self, row: int):
if not 0 <= row < self.rowCount():
return
self.beginRemoveRows(QModelIndex(), row, row)
self.__items.pop(row)
self.endRemoveRows()
def removeItems(self, __rows: list[int]):
rows = sorted(__rows, reverse=True)
[self.removeItem(row) for row in rows]
class OcrQueueTableProxyModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self.retranslateHeaders()
self.__sourceModel = None
self.__columnRoleMapping = [
[Qt.ItemDataRole.CheckStateRole],
[OcrQueueModel.ImagePathRole, OcrQueueModel.ImagePixmapRole],
[
OcrQueueModel.RecognizeResultRole,
OcrQueueModel.ChartRole,
],
[
OcrQueueModel.RecognizeResultRole,
OcrQueueModel.ScoreInsertRole,
OcrQueueModel.ChartRole,
OcrQueueModel.ScoreValidateOkRole,
],
]
def retranslateHeaders(self):
self.__horizontalHeaders = [
# fmt: off
QCoreApplication.translate("OcrTableModel", "horizontalHeader.title.select"),
QCoreApplication.translate("OcrTableModel", "horizontalHeader.title.imagePreview"),
QCoreApplication.translate("OcrTableModel", "horizontalHeader.title.chart"),
QCoreApplication.translate("OcrTableModel", "horizontalHeader.title.score"),
# fmt: on
]
def sourceModel(self) -> OcrQueueModel:
return self.__sourceModel
def setSourceModel(self, sourceModel):
if not isinstance(sourceModel, OcrQueueModel):
return False
# connect signals
sourceModel.rowsAboutToBeInserted.connect(self.rowsAboutToBeInserted)
sourceModel.rowsInserted.connect(self.rowsInserted)
sourceModel.rowsAboutToBeRemoved.connect(self.rowsAboutToBeRemoved)
sourceModel.rowsRemoved.connect(self.rowsRemoved)
sourceModel.dataChanged.connect(self.dataChanged)
sourceModel.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged)
sourceModel.layoutChanged.connect(self.layoutChanged)
self.__sourceModel = sourceModel
return True
def rowCount(self, *args):
return self.sourceModel().rowCount()
def columnCount(self, *args):
return len(self.__horizontalHeaders)
def headerData(self, section: int, orientation: Qt.Orientation, role: int):
if (
orientation == Qt.Orientation.Horizontal
and 0 <= section < len(self.__horizontalHeaders)
and role == Qt.ItemDataRole.DisplayRole
):
return self.__horizontalHeaders[section]
return None
def data(self, index, role):
if (
0 <= index.row() < self.rowCount()
and 0 <= index.column() < self.columnCount()
and role in self.__columnRoleMapping[index.column()]
):
srcIndex = self.sourceModel().index(index.row(), 0)
return srcIndex.data(role)
return None
def setData(self, index, value, role):
if index.column() == 2 and role == OcrQueueModel.ChartRole:
return self.sourceModel().setItemChart(index.row(), value)
if index.column() == 3 and role == OcrQueueModel.ScoreInsertRole:
return self.sourceModel().setItemScore(index.row(), value)
return False
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
flags = (
self.sourceModel().flags(index)
if isinstance(self.sourceModel(), OcrQueueModel)
else super().flags(index)
)
flags = flags | Qt.ItemFlag.ItemIsEnabled
flags = flags | Qt.ItemFlag.ItemIsEditable
flags = flags | Qt.ItemFlag.ItemIsSelectable
if index.column() == 0:
flags = flags & ~Qt.ItemFlag.ItemIsEnabled & ~Qt.ItemFlag.ItemIsEditable
return flags
class ImageDelegate(QStyledItemDelegate):
def getPixmap(self, index: QModelIndex):
return index.data(OcrQueueModel.ImagePixmapRole)
def getImagePath(self, index: QModelIndex):
return index.data(OcrQueueModel.ImagePathRole)
def scalePixmap(self, pixmap: QPixmap):
return pixmap.scaled(
100,
100,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
def paint(self, painter, option, index):
pixmap = self.getPixmap(index)
if not isinstance(pixmap, QPixmap):
imagePath = self.getImagePath(index)
option.text = imagePath
super().paint(painter, option, index)
else:
pixmap = self.scalePixmap(pixmap)
# https://stackoverflow.com/a/32047499/16484891
# CC BY-SA 3.0
x = option.rect.center().x() - pixmap.rect().width() / 2
y = option.rect.center().y() - pixmap.rect().height() / 2
painter.drawPixmap(
QRect(x, y, pixmap.rect().width(), pixmap.rect().height()), pixmap
)
def sizeHint(self, option, index) -> QSize:
pixmap = self.getPixmap(index)
if isinstance(pixmap, QPixmap):
pixmap = self.scalePixmap(pixmap)
return pixmap.size()
else:
return QSize(100, 75)
def createEditor(self, parent, option, index) -> QWidget:
pixmap = self.getPixmap(index)
if isinstance(pixmap, QPixmap):
label = QLabel(parent)
label.setWindowFlags(Qt.WindowType.Window)
label.setWindowFlag(Qt.WindowType.WindowMinimizeButtonHint, False)
label.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, False)
label.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, True)
label.setWindowTitle(QFileInfo(self.getImagePath(index)).fileName())
pixmap = pixmap.scaled(
800,
800,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
label.setMinimumSize(pixmap.size())
label.setPixmap(pixmap)
label.move(parent.mapToGlobal(parent.pos()))
return label
def setModelData(self, *args):
...
def updateEditorGeometry(self, *args):
...
class TableChartDelegate(ChartDelegate):
def getChart(self, index: QModelIndex) -> Chart | None:
return index.data(OcrQueueModel.ChartRole)
def paintWarningBackground(self, index: QModelIndex) -> bool:
return isinstance(
index.data(OcrQueueModel.RecognizeResultRole), RecognizeResult
)
def setModelData(self, editor, model: OcrQueueTableProxyModel, index):
if editor.validate():
model.setData(index, editor.value(), OcrQueueModel.ChartRole)
class TableScoreDelegate(ScoreDelegate):
def getScoreInsert(self, index: QModelIndex):
return index.data(OcrQueueModel.ScoreInsertRole)
def getChart(self, index: QModelIndex):
return index.data(OcrQueueModel.ChartRole)
def getScoreValidateOk(self, index: QModelIndex):
return index.data(OcrQueueModel.ScoreValidateOkRole)
def paintWarningBackground(self, index: QModelIndex) -> bool:
return isinstance(
index.data(OcrQueueModel.RecognizeResultRole), RecognizeResult
)
# def createEditor(self, parent, option, index):
# editor = super().createEditor(parent, option, index)
# editor.setManualHandleCommit(True)
# return editor
def setModelData(self, editor, model: OcrQueueTableProxyModel, index):
# userAcceptMessageBox = editor.triggerValidateMessageBox()
# if userAcceptMessageBox:
# model.setData(index, editor.value(), OcrQueueModel.ScoreInsertRole)
if super().confirmSetModelData(editor):
model.setData(index, editor.value(), OcrQueueModel.ScoreInsertRole)