mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 18:23:07 +02:00
- C/lichess_random_engine, vocabulary_curve, misc/split, 1dvelocitysimulator, opening_learner: test suites added - CPP/miscelanious: tests added - TS/battery-status, champions_leauge_scores, two-inputs: tests added - python_pkg/fm24_searcher, wake_alarm: new packages added - Fix ruff/cppcheck/eslint/clang-format failures - Update .gitignore for C/C++ build artifacts
1165 lines
35 KiB
Python
1165 lines
35 KiB
Python
"""PyQt6-based GUI for FM24 Database Searcher.
|
|
|
|
Provides:
|
|
- Player search by name (debounced)
|
|
- Attribute filtering (min/max sliders)
|
|
- Weighted scouting score
|
|
- Player comparison
|
|
- Import from binary DB and HTML exports
|
|
- Threaded loading with progress overlay
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import TYPE_CHECKING, ClassVar
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from PyQt6.QtCore import (
|
|
QAbstractTableModel,
|
|
QModelIndex,
|
|
QObject,
|
|
Qt,
|
|
QTimer,
|
|
pyqtSignal,
|
|
)
|
|
from PyQt6.QtGui import QAction, QBrush, QColor, QFont, QPainter
|
|
from PyQt6.QtWidgets import (
|
|
QApplication,
|
|
QDialog,
|
|
QFileDialog,
|
|
QGridLayout,
|
|
QGroupBox,
|
|
QHBoxLayout,
|
|
QHeaderView,
|
|
QLabel,
|
|
QLineEdit,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QProgressBar,
|
|
QPushButton,
|
|
QSlider,
|
|
QSplitter,
|
|
QStatusBar,
|
|
QTableView,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QTabWidget,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
import zstandard
|
|
|
|
from python_pkg.fm24_searcher.binary_parser import parse_people_db
|
|
from python_pkg.fm24_searcher.html_parser import (
|
|
merge_players,
|
|
parse_html_export,
|
|
)
|
|
from python_pkg.fm24_searcher.models import (
|
|
ALL_VISIBLE_ATTRS,
|
|
MENTAL_ATTRS,
|
|
PHYSICAL_ATTRS,
|
|
TECHNICAL_ATTRS,
|
|
Player,
|
|
)
|
|
|
|
# Default path to FM24 people database.
|
|
DEFAULT_PEOPLE_DB = (
|
|
Path.home()
|
|
/ ".local/share/Steam/steamapps/common"
|
|
/ "Football Manager 2024/data/database/db"
|
|
/ "2400/2400_fm/people_db.dat"
|
|
)
|
|
|
|
# Reference date for age calculation.
|
|
_TODAY = datetime.datetime.now(tz=datetime.UTC).date()
|
|
|
|
# Column layout.
|
|
_FIXED_COLS = ["Name", "Age", "CA", "PA"]
|
|
_COL_NAME = 0
|
|
_COL_AGE = 1
|
|
_COL_CA = 2
|
|
_COL_PA = 3
|
|
|
|
_FIXED_TOOLTIPS = {
|
|
"CA": "Current Ability (1-200)",
|
|
"PA": "Potential Ability (1-200)",
|
|
}
|
|
|
|
# Attribute color thresholds.
|
|
_ATTR_EXCELLENT = 18
|
|
_ATTR_GOOD = 15
|
|
_ATTR_AVERAGE = 12
|
|
_ATTR_BELOW = 8
|
|
|
|
_MIN_COMPARE_PLAYERS = 2
|
|
_MIN_ETA_PCT = 5
|
|
_COMPARE_HEADER_ROWS = 4
|
|
|
|
_IMPORT_GUIDE = """\
|
|
<h3>How to Import Player Attributes</h3>
|
|
<p>The FM24 binary database only contains player names and dates of birth.
|
|
To see <b>CA, PA</b>, and <b>attribute values</b>, you need to import an
|
|
HTML export from Football Manager.</p>
|
|
<h4>Steps:</h4>
|
|
<ol>
|
|
<li>Open <b>Football Manager 2024</b></li>
|
|
<li>Go to <b>Scouting > Players</b> (or any player search screen)</li>
|
|
<li>Set up a <b>custom view</b> with columns: Name, Club, Nat, Pos, CA, PA,
|
|
and all attributes (Cor, Cro, Dri, Fin, etc.)</li>
|
|
<li>Select all players with <b>Ctrl+A</b></li>
|
|
<li>Press <b>Ctrl+P</b> to print</li>
|
|
<li>Choose <b>"Web Page"</b> as the format and save the file</li>
|
|
<li>Use <b>File > Import HTML Export</b> in this app to load it</li>
|
|
</ol>
|
|
<p><i>Tip: Export multiple pages (e.g. u21 players, each league)
|
|
and import them all — the app merges data automatically.</i></p>
|
|
"""
|
|
|
|
|
|
def _player_age(p: Player) -> int:
|
|
"""Calculate player age from DOB string."""
|
|
if not p.date_of_birth:
|
|
return 0
|
|
try:
|
|
dob = datetime.date.fromisoformat(p.date_of_birth)
|
|
except ValueError:
|
|
return 0
|
|
else:
|
|
age = _TODAY.year - dob.year
|
|
if (_TODAY.month, _TODAY.day) < (dob.month, dob.day):
|
|
age -= 1
|
|
return age
|
|
|
|
|
|
def _attr_color(val: int) -> QColor:
|
|
"""Color-code an attribute value (1-20 scale)."""
|
|
if val >= _ATTR_EXCELLENT:
|
|
return QColor(0, 150, 50)
|
|
if val >= _ATTR_GOOD:
|
|
return QColor(80, 180, 80)
|
|
if val >= _ATTR_AVERAGE:
|
|
return QColor(180, 180, 50)
|
|
if val >= _ATTR_BELOW:
|
|
return QColor(220, 150, 50)
|
|
return QColor(200, 60, 60)
|
|
|
|
|
|
def _build_tooltip(p: Player) -> str:
|
|
"""Build a multi-line tooltip for a player."""
|
|
parts = [p.name]
|
|
if p.club:
|
|
parts.append(f"Club: {p.club}")
|
|
if p.nationality:
|
|
parts.append(f"Nationality: {p.nationality}")
|
|
if p.position:
|
|
parts.append(f"Position: {p.position}")
|
|
if p.date_of_birth:
|
|
parts.append(f"DOB: {p.date_of_birth}")
|
|
if p.value:
|
|
parts.append(f"Value: {p.value}")
|
|
if p.wage:
|
|
parts.append(f"Wage: {p.wage}")
|
|
if p.personality:
|
|
parts.append(f"Personality: {p.personality}")
|
|
return "\n".join(parts)
|
|
|
|
|
|
class PlayerTableModel(QAbstractTableModel):
|
|
"""Virtual model for displaying players efficiently.
|
|
|
|
Only renders visible rows, unlike QTableWidget which
|
|
creates widget items for every cell in every row.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
parent: QObject | None = None,
|
|
) -> None:
|
|
"""Initialize the player table model."""
|
|
super().__init__(parent)
|
|
self._players: list[Player] = []
|
|
self._ages: list[int] = []
|
|
self._columns = list(_FIXED_COLS) + list(
|
|
ALL_VISIBLE_ATTRS,
|
|
)
|
|
|
|
def set_players(self, players: list[Player]) -> None:
|
|
"""Replace all player data."""
|
|
self.beginResetModel()
|
|
self._players = players
|
|
self._ages = [_player_age(p) for p in players]
|
|
self.endResetModel()
|
|
|
|
def rowCount(
|
|
self,
|
|
_parent: QModelIndex = QModelIndex(),
|
|
) -> int:
|
|
"""Return number of players."""
|
|
return len(self._players)
|
|
|
|
def columnCount(
|
|
self,
|
|
_parent: QModelIndex = QModelIndex(),
|
|
) -> int:
|
|
"""Return number of columns."""
|
|
return len(self._columns)
|
|
|
|
def data(
|
|
self,
|
|
index: QModelIndex,
|
|
role: int = Qt.ItemDataRole.DisplayRole,
|
|
) -> object:
|
|
"""Return data for the given index and role."""
|
|
if not index.isValid():
|
|
return None
|
|
row, col = index.row(), index.column()
|
|
if row >= len(self._players):
|
|
return None
|
|
p = self._players[row]
|
|
handlers = {
|
|
Qt.ItemDataRole.DisplayRole: lambda: self._display_data(p, row, col),
|
|
Qt.ItemDataRole.BackgroundRole: lambda: self._bg_data(p, col),
|
|
Qt.ItemDataRole.ToolTipRole: lambda: self._tooltip_data(p, col),
|
|
}
|
|
if role in handlers:
|
|
return handlers[role]()
|
|
if role == Qt.ItemDataRole.TextAlignmentRole and col >= 1:
|
|
return Qt.AlignmentFlag.AlignCenter
|
|
return None
|
|
|
|
def _display_data(
|
|
self,
|
|
p: Player,
|
|
row: int,
|
|
col: int,
|
|
) -> object:
|
|
"""Return display text for a cell."""
|
|
if col == _COL_NAME:
|
|
return p.name
|
|
if col == _COL_AGE:
|
|
age = self._ages[row]
|
|
return age or ""
|
|
if col == _COL_CA:
|
|
return p.current_ability or ""
|
|
if col == _COL_PA:
|
|
return p.potential_ability or ""
|
|
fixed_count = len(_FIXED_COLS)
|
|
if col >= fixed_count:
|
|
attr = self._columns[col]
|
|
val = p.get_attr(attr)
|
|
return val if val > 0 else ""
|
|
return None
|
|
|
|
def _bg_data(
|
|
self,
|
|
p: Player,
|
|
col: int,
|
|
) -> QBrush | None:
|
|
"""Return background brush for attribute cells."""
|
|
fixed_count = len(_FIXED_COLS)
|
|
if col >= fixed_count:
|
|
attr = self._columns[col]
|
|
val = p.get_attr(attr)
|
|
if val > 0:
|
|
return QBrush(_attr_color(val))
|
|
return None
|
|
|
|
def _tooltip_data(
|
|
self,
|
|
p: Player,
|
|
col: int,
|
|
) -> str | None:
|
|
"""Return tooltip text for a cell."""
|
|
if col == _COL_NAME:
|
|
return _build_tooltip(p)
|
|
if col == _COL_CA:
|
|
return "Current Ability (1-200 scale)"
|
|
if col == _COL_PA:
|
|
return "Potential Ability (1-200 scale)"
|
|
return None
|
|
|
|
def headerData(
|
|
self,
|
|
section: int,
|
|
orientation: Qt.Orientation,
|
|
role: int = Qt.ItemDataRole.DisplayRole,
|
|
) -> object:
|
|
"""Return header label or tooltip."""
|
|
if orientation == Qt.Orientation.Horizontal:
|
|
if role == Qt.ItemDataRole.DisplayRole:
|
|
return self._columns[section]
|
|
if role == Qt.ItemDataRole.ToolTipRole:
|
|
col_name = self._columns[section]
|
|
return _FIXED_TOOLTIPS.get(
|
|
col_name,
|
|
col_name,
|
|
)
|
|
return None
|
|
|
|
def _sort_key(
|
|
self,
|
|
column: int,
|
|
) -> Callable[[int], object] | None:
|
|
"""Return a sort key function for the column."""
|
|
fixed_count = len(_FIXED_COLS)
|
|
if column == _COL_NAME:
|
|
return lambda i: self._players[i].name.lower()
|
|
if column == _COL_AGE:
|
|
return lambda i: self._ages[i]
|
|
if column == _COL_CA:
|
|
return lambda i: self._players[i].current_ability
|
|
if column == _COL_PA:
|
|
return lambda i: self._players[i].potential_ability
|
|
if column >= fixed_count:
|
|
attr = self._columns[column]
|
|
return lambda i: self._players[i].get_attr(attr)
|
|
return None
|
|
|
|
def sort(
|
|
self,
|
|
column: int,
|
|
order: Qt.SortOrder = Qt.SortOrder.AscendingOrder,
|
|
) -> None:
|
|
"""Sort by column."""
|
|
key_fn = self._sort_key(column)
|
|
if key_fn is None:
|
|
return
|
|
self.beginResetModel()
|
|
reverse = order == Qt.SortOrder.DescendingOrder
|
|
indices = sorted(
|
|
range(len(self._players)),
|
|
key=key_fn,
|
|
reverse=reverse,
|
|
)
|
|
self._players = [self._players[i] for i in indices]
|
|
self._ages = [self._ages[i] for i in indices]
|
|
self.endResetModel()
|
|
|
|
def get_player(self, row: int) -> Player | None:
|
|
"""Get player at row index."""
|
|
if 0 <= row < len(self._players):
|
|
return self._players[row]
|
|
return None
|
|
|
|
|
|
class LoadingOverlay(QWidget):
|
|
"""Full-window overlay shown during database loading."""
|
|
|
|
def __init__(
|
|
self,
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
"""Initialize the loading overlay."""
|
|
super().__init__(parent)
|
|
self.setAttribute(
|
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
|
on=False,
|
|
)
|
|
layout = QVBoxLayout()
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
self._title = QLabel("LOADING DATABASE")
|
|
title_font = QFont()
|
|
title_font.setPointSize(28)
|
|
title_font.setBold(True)
|
|
self._title.setFont(title_font)
|
|
self._title.setAlignment(
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
)
|
|
self._title.setStyleSheet("color: #333;")
|
|
|
|
self._stage = QLabel("Initializing...")
|
|
stage_font = QFont()
|
|
stage_font.setPointSize(14)
|
|
self._stage.setFont(stage_font)
|
|
self._stage.setAlignment(
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
)
|
|
self._stage.setStyleSheet("color: #666;")
|
|
|
|
self._progress = QProgressBar()
|
|
self._progress.setRange(0, 100)
|
|
self._progress.setFixedWidth(500)
|
|
self._progress.setFixedHeight(30)
|
|
|
|
self._eta = QLabel("")
|
|
self._eta.setAlignment(
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
)
|
|
self._eta.setStyleSheet("color: #888;")
|
|
|
|
layout.addWidget(self._title)
|
|
layout.addSpacing(20)
|
|
layout.addWidget(self._stage)
|
|
layout.addSpacing(10)
|
|
layout.addWidget(
|
|
self._progress,
|
|
alignment=Qt.AlignmentFlag.AlignCenter,
|
|
)
|
|
layout.addSpacing(5)
|
|
layout.addWidget(self._eta)
|
|
self.setLayout(layout)
|
|
|
|
def update_progress(
|
|
self,
|
|
stage: str,
|
|
percent: int,
|
|
eta: str = "",
|
|
) -> None:
|
|
"""Update displayed loading status."""
|
|
self._stage.setText(stage)
|
|
self._progress.setValue(percent)
|
|
self._eta.setText(eta)
|
|
|
|
def paintEvent(self, event: object) -> None:
|
|
"""Draw semi-transparent background."""
|
|
painter = QPainter(self)
|
|
painter.fillRect(
|
|
self.rect(),
|
|
QColor(255, 255, 255, 220),
|
|
)
|
|
painter.end()
|
|
super().paintEvent(event)
|
|
|
|
|
|
class FilterPanel(QGroupBox):
|
|
"""Attribute filter panel with min-value sliders."""
|
|
|
|
def __init__(
|
|
self,
|
|
title: str,
|
|
attrs: list[str],
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
"""Initialize filter sliders for given attrs."""
|
|
super().__init__(title, parent)
|
|
self.sliders: dict[str, QSlider] = {}
|
|
self.labels: dict[str, QLabel] = {}
|
|
|
|
layout = QGridLayout()
|
|
layout.setSpacing(2)
|
|
for i, attr in enumerate(attrs):
|
|
lbl = QLabel(attr)
|
|
lbl.setFixedWidth(110)
|
|
slider = QSlider(Qt.Orientation.Horizontal)
|
|
slider.setRange(0, 20)
|
|
slider.setValue(0)
|
|
val_lbl = QLabel("0")
|
|
val_lbl.setFixedWidth(20)
|
|
slider.valueChanged.connect(
|
|
lambda v, lb=val_lbl: lb.setText(str(v)),
|
|
)
|
|
layout.addWidget(lbl, i, 0)
|
|
layout.addWidget(slider, i, 1)
|
|
layout.addWidget(val_lbl, i, 2)
|
|
self.sliders[attr] = slider
|
|
self.labels[attr] = val_lbl
|
|
self.setLayout(layout)
|
|
|
|
def get_filters(self) -> dict[str, int]:
|
|
"""Return dict of attr_name -> min_value (skip 0)."""
|
|
return {
|
|
name: slider.value()
|
|
for name, slider in self.sliders.items()
|
|
if slider.value() > 0
|
|
}
|
|
|
|
def reset(self) -> None:
|
|
"""Reset all sliders to 0."""
|
|
for slider in self.sliders.values():
|
|
slider.setValue(0)
|
|
|
|
|
|
class WeightPanel(QGroupBox):
|
|
"""Weighted scouting formula panel."""
|
|
|
|
def __init__(
|
|
self,
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
"""Initialize weight sliders for all attributes."""
|
|
super().__init__("Scout Weights", parent)
|
|
self.combos: dict[str, QSlider] = {}
|
|
layout = QGridLayout()
|
|
layout.setSpacing(2)
|
|
|
|
for i, attr in enumerate(ALL_VISIBLE_ATTRS):
|
|
lbl = QLabel(attr)
|
|
lbl.setFixedWidth(110)
|
|
slider = QSlider(Qt.Orientation.Horizontal)
|
|
slider.setRange(0, 10)
|
|
slider.setValue(0)
|
|
val_lbl = QLabel("0")
|
|
val_lbl.setFixedWidth(20)
|
|
slider.valueChanged.connect(
|
|
lambda v, lb=val_lbl: lb.setText(str(v)),
|
|
)
|
|
layout.addWidget(lbl, i, 0)
|
|
layout.addWidget(slider, i, 1)
|
|
layout.addWidget(val_lbl, i, 2)
|
|
self.combos[attr] = slider
|
|
self.setLayout(layout)
|
|
|
|
def get_weights(self) -> dict[str, float]:
|
|
"""Return non-zero weights."""
|
|
return {
|
|
name: float(slider.value())
|
|
for name, slider in self.combos.items()
|
|
if slider.value() > 0
|
|
}
|
|
|
|
|
|
class CompareDialog(QDialog):
|
|
"""Side-by-side player comparison dialog."""
|
|
|
|
def __init__(
|
|
self,
|
|
players: list[Player],
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
"""Initialize comparison table for given players."""
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Compare Players")
|
|
self.setMinimumSize(600, 700)
|
|
|
|
layout = QVBoxLayout()
|
|
table = QTableWidget()
|
|
|
|
attrs = ALL_VISIBLE_ATTRS
|
|
table.setRowCount(
|
|
len(attrs) + _COMPARE_HEADER_ROWS,
|
|
)
|
|
table.setColumnCount(len(players) + 1)
|
|
|
|
headers = ["Attribute"] + [p.name for p in players]
|
|
table.setHorizontalHeaderLabels(headers)
|
|
|
|
self._fill_header_rows(table, players)
|
|
self._fill_attr_rows(table, players, attrs)
|
|
|
|
table.resizeColumnsToContents()
|
|
layout.addWidget(table)
|
|
self.setLayout(layout)
|
|
|
|
@staticmethod
|
|
def _fill_header_rows(
|
|
table: QTableWidget,
|
|
players: list[Player],
|
|
) -> None:
|
|
"""Populate Name, Age, CA, PA rows."""
|
|
# Name row.
|
|
table.setItem(0, 0, QTableWidgetItem("Name"))
|
|
for j, p in enumerate(players):
|
|
item = QTableWidgetItem(p.name)
|
|
font = QFont()
|
|
font.setBold(True)
|
|
item.setFont(font)
|
|
table.setItem(0, j + 1, item)
|
|
|
|
# Age row.
|
|
table.setItem(1, 0, QTableWidgetItem("Age"))
|
|
for j, p in enumerate(players):
|
|
item = QTableWidgetItem(
|
|
str(_player_age(p)),
|
|
)
|
|
table.setItem(1, j + 1, item)
|
|
|
|
# CA row.
|
|
table.setItem(
|
|
_COL_CA,
|
|
0,
|
|
QTableWidgetItem("CA"),
|
|
)
|
|
for j, p in enumerate(players):
|
|
item = QTableWidgetItem()
|
|
item.setData(
|
|
Qt.ItemDataRole.DisplayRole,
|
|
p.current_ability,
|
|
)
|
|
table.setItem(_COL_CA, j + 1, item)
|
|
|
|
# PA row.
|
|
table.setItem(
|
|
_COL_PA,
|
|
0,
|
|
QTableWidgetItem("PA"),
|
|
)
|
|
for j, p in enumerate(players):
|
|
item = QTableWidgetItem()
|
|
item.setData(
|
|
Qt.ItemDataRole.DisplayRole,
|
|
p.potential_ability,
|
|
)
|
|
table.setItem(_COL_PA, j + 1, item)
|
|
|
|
@staticmethod
|
|
def _fill_attr_rows(
|
|
table: QTableWidget,
|
|
players: list[Player],
|
|
attrs: tuple[str, ...],
|
|
) -> None:
|
|
"""Populate attribute comparison rows."""
|
|
for i, attr in enumerate(attrs):
|
|
row = i + _COMPARE_HEADER_ROWS
|
|
table.setItem(
|
|
row,
|
|
0,
|
|
QTableWidgetItem(attr),
|
|
)
|
|
vals = [p.get_attr(attr) for p in players]
|
|
max_val = max(vals) if vals else 0
|
|
for j, p in enumerate(players):
|
|
val = p.get_attr(attr)
|
|
item = QTableWidgetItem()
|
|
item.setData(
|
|
Qt.ItemDataRole.DisplayRole,
|
|
val,
|
|
)
|
|
if val > 0:
|
|
item.setBackground(_attr_color(val))
|
|
if val == max_val and len(players) > 1:
|
|
font = QFont()
|
|
font.setBold(True)
|
|
item.setFont(font)
|
|
table.setItem(row, j + 1, item)
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
"""Main application window."""
|
|
|
|
progress_sig: ClassVar[type] = pyqtSignal(str, int)
|
|
done_sig: ClassVar[type] = pyqtSignal(list)
|
|
error_sig: ClassVar[type] = pyqtSignal(str)
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize window, menu, UI, and auto-load."""
|
|
super().__init__()
|
|
self.setWindowTitle("FM24 Database Searcher")
|
|
self.setMinimumSize(1200, 800)
|
|
|
|
self.all_players: list[Player] = []
|
|
self.filtered_players: list[Player] = []
|
|
self._load_thread: threading.Thread | None = None
|
|
self._load_start: float = 0.0
|
|
|
|
self._create_menu()
|
|
self._create_ui()
|
|
self._create_status_bar()
|
|
self._create_overlay()
|
|
|
|
# Cross-thread signals.
|
|
self.progress_sig.connect(self._on_load_progress)
|
|
self.done_sig.connect(self._on_load_finished)
|
|
self.error_sig.connect(self._on_load_error)
|
|
|
|
# Debounce timer for search.
|
|
self._search_timer = QTimer()
|
|
self._search_timer.setSingleShot(True)
|
|
self._search_timer.setInterval(300)
|
|
self._search_timer.timeout.connect(self._do_search)
|
|
|
|
# Auto-load default DB after the window is shown.
|
|
QTimer.singleShot(0, self._auto_load)
|
|
|
|
def _create_menu(self) -> None:
|
|
menubar = self.menuBar()
|
|
if menubar is None:
|
|
return
|
|
|
|
file_menu = menubar.addMenu("&File")
|
|
if file_menu is None:
|
|
return
|
|
|
|
load_db = QAction("Load &Binary DB...", self)
|
|
load_db.triggered.connect(self._load_binary_db)
|
|
file_menu.addAction(load_db)
|
|
|
|
load_html = QAction(
|
|
"Import &HTML Export...",
|
|
self,
|
|
)
|
|
load_html.triggered.connect(self._load_html)
|
|
file_menu.addAction(load_html)
|
|
|
|
file_menu.addSeparator()
|
|
|
|
quit_action = QAction("&Quit", self)
|
|
quit_action.triggered.connect(self.close)
|
|
file_menu.addAction(quit_action)
|
|
|
|
help_menu = menubar.addMenu("&Help")
|
|
if help_menu is None:
|
|
return
|
|
guide = QAction("How to &Import Attributes...", self)
|
|
guide.triggered.connect(self._show_import_guide)
|
|
help_menu.addAction(guide)
|
|
|
|
def _create_ui(self) -> None:
|
|
central = QWidget()
|
|
main_layout = QVBoxLayout()
|
|
|
|
# Info banner (shown when no attribute data loaded).
|
|
self._info_banner = QLabel(
|
|
"\u26a0 No attribute data loaded \u2014 "
|
|
"use File > Import HTML Export to add CA, PA, "
|
|
"and attributes. See Help > How to Import "
|
|
"Attributes for instructions.",
|
|
)
|
|
self._info_banner.setWordWrap(True)
|
|
self._info_banner.setStyleSheet(
|
|
"background: #fff3cd; color: #856404; "
|
|
"border: 1px solid #ffc107; border-radius: 4px; "
|
|
"padding: 8px; font-size: 13px;",
|
|
)
|
|
self._info_banner.setVisible(True)
|
|
main_layout.addWidget(self._info_banner)
|
|
|
|
self._build_search_bar(main_layout)
|
|
self._build_meta_filters(main_layout)
|
|
self._build_splitter(main_layout)
|
|
|
|
apply_btn = QPushButton("Apply Filters")
|
|
apply_btn.clicked.connect(self._apply_filters)
|
|
main_layout.addWidget(apply_btn)
|
|
|
|
central.setLayout(main_layout)
|
|
self.setCentralWidget(central)
|
|
|
|
def _build_search_bar(
|
|
self,
|
|
parent_layout: QVBoxLayout,
|
|
) -> None:
|
|
search_layout = QHBoxLayout()
|
|
self.search_input = QLineEdit()
|
|
self.search_input.setPlaceholderText(
|
|
"Search by name...",
|
|
)
|
|
self.search_input.textChanged.connect(
|
|
self._on_search_changed,
|
|
)
|
|
search_btn = QPushButton("Search")
|
|
search_btn.clicked.connect(self._do_search)
|
|
reset_btn = QPushButton("Reset Filters")
|
|
reset_btn.clicked.connect(self._reset_filters)
|
|
compare_btn = QPushButton("Compare Selected")
|
|
compare_btn.clicked.connect(
|
|
self._compare_selected,
|
|
)
|
|
|
|
search_layout.addWidget(QLabel("Name:"))
|
|
search_layout.addWidget(
|
|
self.search_input,
|
|
stretch=1,
|
|
)
|
|
search_layout.addWidget(search_btn)
|
|
search_layout.addWidget(reset_btn)
|
|
search_layout.addWidget(compare_btn)
|
|
parent_layout.addLayout(search_layout)
|
|
|
|
def _build_meta_filters(
|
|
self,
|
|
parent_layout: QVBoxLayout,
|
|
) -> None:
|
|
meta_layout = QHBoxLayout()
|
|
self.pos_filter = QLineEdit()
|
|
self.pos_filter.setPlaceholderText(
|
|
"Position filter...",
|
|
)
|
|
self.pos_filter.setFixedWidth(120)
|
|
self.nat_filter = QLineEdit()
|
|
self.nat_filter.setPlaceholderText("Nationality...")
|
|
self.nat_filter.setFixedWidth(120)
|
|
self.club_filter = QLineEdit()
|
|
self.club_filter.setPlaceholderText("Club...")
|
|
self.club_filter.setFixedWidth(120)
|
|
self.min_ca = QLineEdit()
|
|
self.min_ca.setPlaceholderText("Min CA")
|
|
self.min_ca.setFixedWidth(60)
|
|
|
|
meta_layout.addWidget(QLabel("Pos:"))
|
|
meta_layout.addWidget(self.pos_filter)
|
|
meta_layout.addWidget(QLabel("Nat:"))
|
|
meta_layout.addWidget(self.nat_filter)
|
|
meta_layout.addWidget(QLabel("Club:"))
|
|
meta_layout.addWidget(self.club_filter)
|
|
meta_layout.addWidget(QLabel("Min CA:"))
|
|
meta_layout.addWidget(self.min_ca)
|
|
meta_layout.addStretch()
|
|
parent_layout.addLayout(meta_layout)
|
|
|
|
def _build_splitter(
|
|
self,
|
|
parent_layout: QVBoxLayout,
|
|
) -> None:
|
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
|
|
filter_tabs = QTabWidget()
|
|
self.tech_filter = FilterPanel(
|
|
"Technical",
|
|
TECHNICAL_ATTRS,
|
|
)
|
|
self.mental_filter = FilterPanel(
|
|
"Mental",
|
|
MENTAL_ATTRS,
|
|
)
|
|
self.phys_filter = FilterPanel(
|
|
"Physical",
|
|
PHYSICAL_ATTRS,
|
|
)
|
|
self.weight_panel = WeightPanel()
|
|
|
|
filter_tabs.addTab(
|
|
self.tech_filter,
|
|
"Technical",
|
|
)
|
|
filter_tabs.addTab(self.mental_filter, "Mental")
|
|
filter_tabs.addTab(self.phys_filter, "Physical")
|
|
filter_tabs.addTab(
|
|
self.weight_panel,
|
|
"Scout Weights",
|
|
)
|
|
filter_tabs.setMaximumWidth(350)
|
|
splitter.addWidget(filter_tabs)
|
|
|
|
self._model = PlayerTableModel()
|
|
self.player_table = QTableView()
|
|
self.player_table.setModel(self._model)
|
|
self.player_table.setAlternatingRowColors(True)
|
|
self.player_table.setSelectionBehavior(
|
|
QTableView.SelectionBehavior.SelectRows,
|
|
)
|
|
self.player_table.setSortingEnabled(True)
|
|
hdr = self.player_table.horizontalHeader()
|
|
if hdr is not None:
|
|
hdr.setStretchLastSection(False)
|
|
hdr.setSectionResizeMode(
|
|
QHeaderView.ResizeMode.Interactive,
|
|
)
|
|
hdr.resizeSection(_COL_NAME, 200)
|
|
hdr.resizeSection(_COL_AGE, 45)
|
|
hdr.resizeSection(_COL_CA, 45)
|
|
hdr.resizeSection(_COL_PA, 45)
|
|
fixed_count = len(_FIXED_COLS)
|
|
for ci in range(
|
|
fixed_count,
|
|
len(_FIXED_COLS) + len(ALL_VISIBLE_ATTRS),
|
|
):
|
|
hdr.resizeSection(ci, 40)
|
|
vhdr = self.player_table.verticalHeader()
|
|
if vhdr is not None:
|
|
vhdr.setDefaultSectionSize(22)
|
|
vhdr.setVisible(False)
|
|
splitter.addWidget(self.player_table)
|
|
|
|
splitter.setSizes([300, 900])
|
|
parent_layout.addWidget(splitter, stretch=1)
|
|
|
|
def _create_status_bar(self) -> None:
|
|
self.status = QStatusBar()
|
|
self.setStatusBar(self.status)
|
|
self.status.showMessage("Loading database...")
|
|
|
|
def _create_overlay(self) -> None:
|
|
"""Create the loading overlay widget."""
|
|
self._overlay = LoadingOverlay(self)
|
|
self._overlay.hide()
|
|
|
|
def resizeEvent(self, event: object) -> None:
|
|
"""Keep overlay sized to window."""
|
|
super().resizeEvent(event)
|
|
cw = self.centralWidget()
|
|
if cw is not None:
|
|
self._overlay.setGeometry(cw.geometry())
|
|
|
|
def _show_overlay(self) -> None:
|
|
cw = self.centralWidget()
|
|
if cw is not None:
|
|
self._overlay.setGeometry(cw.geometry())
|
|
self._overlay.update_progress("Starting...", 0)
|
|
self._overlay.show()
|
|
self._overlay.raise_()
|
|
|
|
def _hide_overlay(self) -> None:
|
|
self._overlay.hide()
|
|
|
|
def _auto_load(self) -> None:
|
|
"""Auto-load the default people_db.dat."""
|
|
if DEFAULT_PEOPLE_DB.exists():
|
|
self._load_binary_db_from_path(
|
|
DEFAULT_PEOPLE_DB,
|
|
)
|
|
else:
|
|
self.status.showMessage(
|
|
"DB not found \u2014 use File > Load Binary DB",
|
|
)
|
|
|
|
def _load_binary_db(self) -> None:
|
|
filepath, _ = QFileDialog.getOpenFileName(
|
|
self,
|
|
"Select people_db.dat",
|
|
str(Path.home()),
|
|
"DAT files (*.dat);;All files (*)",
|
|
)
|
|
if not filepath:
|
|
return
|
|
self._load_binary_db_from_path(Path(filepath))
|
|
|
|
def _load_binary_db_from_path(
|
|
self,
|
|
filepath: Path,
|
|
) -> None:
|
|
"""Parse and load a people_db.dat in background."""
|
|
self._show_overlay()
|
|
self._load_start = time.monotonic()
|
|
|
|
win = self
|
|
|
|
def _run() -> None:
|
|
try:
|
|
players = parse_people_db(
|
|
filepath,
|
|
progress_cb=win.progress_sig.emit,
|
|
)
|
|
win.done_sig.emit(players)
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
struct.error,
|
|
zstandard.ZstdError,
|
|
) as e:
|
|
win.error_sig.emit(str(e))
|
|
|
|
self._load_thread = threading.Thread(
|
|
target=_run,
|
|
daemon=True,
|
|
)
|
|
self._load_thread.start()
|
|
|
|
def _on_load_progress(
|
|
self,
|
|
stage: str,
|
|
pct: int,
|
|
) -> None:
|
|
elapsed = time.monotonic() - self._load_start
|
|
if pct > _MIN_ETA_PCT:
|
|
est_total = elapsed / (pct / 100)
|
|
remaining = est_total - elapsed
|
|
eta = f"~{remaining:.0f}s remaining"
|
|
else:
|
|
eta = ""
|
|
self._overlay.update_progress(stage, pct, eta)
|
|
|
|
def _on_load_finished(
|
|
self,
|
|
players: list[Player],
|
|
) -> None:
|
|
if self.all_players:
|
|
self.all_players = merge_players(
|
|
players,
|
|
self.all_players,
|
|
)
|
|
else:
|
|
self.all_players = players
|
|
self.filtered_players = self.all_players
|
|
self._model.set_players(self.all_players)
|
|
self._hide_overlay()
|
|
self._update_data_status(len(players))
|
|
|
|
def _on_load_error(self, msg: str) -> None:
|
|
self._hide_overlay()
|
|
QMessageBox.critical(
|
|
self,
|
|
"Error",
|
|
f"Failed to parse: {msg}",
|
|
)
|
|
|
|
def _update_data_status(
|
|
self,
|
|
loaded_count: int,
|
|
) -> None:
|
|
"""Update status bar and info banner based on data."""
|
|
has_ca = any(p.current_ability > 0 for p in self.all_players)
|
|
has_attrs = any(bool(p.attributes) for p in self.all_players)
|
|
self._info_banner.setVisible(
|
|
not has_ca and not has_attrs,
|
|
)
|
|
total = len(self.all_players)
|
|
parts = [f"Loaded {loaded_count:,} players"]
|
|
parts.append(f"({total:,} total)")
|
|
if has_ca:
|
|
ca_count = sum(1 for p in self.all_players if p.current_ability > 0)
|
|
parts.append(f"CA: {ca_count:,}")
|
|
if has_attrs:
|
|
attr_count = sum(1 for p in self.all_players if p.attributes)
|
|
parts.append(f"Attrs: {attr_count:,}")
|
|
if not has_ca and not has_attrs:
|
|
parts.append(
|
|
"\u2014 import HTML for attributes",
|
|
)
|
|
self.status.showMessage(" | ".join(parts))
|
|
|
|
def _show_import_guide(self) -> None:
|
|
"""Show the HTML import instructions dialog."""
|
|
QMessageBox.information(
|
|
self,
|
|
"How to Import Attributes",
|
|
_IMPORT_GUIDE,
|
|
)
|
|
|
|
def _load_html(self) -> None:
|
|
filepath, _ = QFileDialog.getOpenFileName(
|
|
self,
|
|
"Select FM24 HTML export",
|
|
str(Path.home()),
|
|
"HTML files (*.html *.htm);;All files (*)",
|
|
)
|
|
if not filepath:
|
|
return
|
|
try:
|
|
self.status.showMessage(
|
|
"Parsing HTML export...",
|
|
)
|
|
QApplication.processEvents()
|
|
players = parse_html_export(Path(filepath))
|
|
if self.all_players:
|
|
self.all_players = merge_players(
|
|
self.all_players,
|
|
players,
|
|
)
|
|
else:
|
|
self.all_players = players
|
|
self.filtered_players = self.all_players
|
|
self._model.set_players(self.all_players)
|
|
self._update_data_status(len(players))
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
UnicodeDecodeError,
|
|
) as e:
|
|
QMessageBox.critical(
|
|
self,
|
|
"Error",
|
|
f"Failed to parse HTML: {e}",
|
|
)
|
|
|
|
def _on_search_changed(self) -> None:
|
|
"""Restart the debounce timer on text change."""
|
|
self._search_timer.start()
|
|
|
|
def _do_search(self) -> None:
|
|
"""Execute name search (debounced)."""
|
|
query = self.search_input.text().strip().lower()
|
|
if not query:
|
|
self.filtered_players = self.all_players
|
|
else:
|
|
self.filtered_players = [
|
|
p for p in self.all_players if query in p.name.lower()
|
|
]
|
|
self._model.set_players(self.filtered_players)
|
|
self.status.showMessage(
|
|
f"Showing {len(self.filtered_players):,} players",
|
|
)
|
|
|
|
def _apply_filters(self) -> None:
|
|
min_attrs: dict[str, int] = {}
|
|
min_attrs.update(self.tech_filter.get_filters())
|
|
min_attrs.update(
|
|
self.mental_filter.get_filters(),
|
|
)
|
|
min_attrs.update(self.phys_filter.get_filters())
|
|
|
|
pos = self.pos_filter.text().strip()
|
|
nat = self.nat_filter.text().strip()
|
|
club = self.club_filter.text().strip()
|
|
ca_text = self.min_ca.text().strip()
|
|
min_ca = int(ca_text) if ca_text.isdigit() else None
|
|
|
|
query = self.search_input.text().strip().lower()
|
|
weights = self.weight_panel.get_weights()
|
|
|
|
results: list[tuple[float, Player]] = []
|
|
for p in self.all_players:
|
|
if query and query not in p.name.lower():
|
|
continue
|
|
if not p.matches_filter(
|
|
min_attrs=min_attrs or None,
|
|
min_ca=min_ca,
|
|
position_filter=pos or None,
|
|
nationality_filter=nat or None,
|
|
club_filter=club or None,
|
|
):
|
|
continue
|
|
score = p.weighted_score(weights) if weights else 0.0
|
|
results.append((score, p))
|
|
|
|
if weights:
|
|
results.sort(
|
|
key=lambda x: x[0],
|
|
reverse=True,
|
|
)
|
|
else:
|
|
results.sort(
|
|
key=lambda x: x[1].current_ability,
|
|
reverse=True,
|
|
)
|
|
|
|
self.filtered_players = [p for _, p in results]
|
|
self._model.set_players(self.filtered_players)
|
|
self.status.showMessage(
|
|
f"Filtered: {len(self.filtered_players):,} players",
|
|
)
|
|
|
|
def _reset_filters(self) -> None:
|
|
self.tech_filter.reset()
|
|
self.mental_filter.reset()
|
|
self.phys_filter.reset()
|
|
self.search_input.clear()
|
|
self.pos_filter.clear()
|
|
self.nat_filter.clear()
|
|
self.club_filter.clear()
|
|
self.min_ca.clear()
|
|
self.filtered_players = self.all_players
|
|
self._model.set_players(self.all_players)
|
|
self.status.showMessage("Filters reset")
|
|
|
|
def _compare_selected(self) -> None:
|
|
sel = self.player_table.selectionModel()
|
|
if sel is None:
|
|
return
|
|
indexes = sel.selectedRows()
|
|
if len(indexes) < _MIN_COMPARE_PLAYERS:
|
|
QMessageBox.information(
|
|
self,
|
|
"Compare",
|
|
"Select at least 2 rows to compare.",
|
|
)
|
|
return
|
|
players = []
|
|
for idx in sorted(
|
|
indexes,
|
|
key=lambda i: i.row(),
|
|
):
|
|
p = self._model.get_player(idx.row())
|
|
if p:
|
|
players.append(p)
|
|
if len(players) >= _MIN_COMPARE_PLAYERS:
|
|
dlg = CompareDialog(players, self)
|
|
dlg.exec()
|
|
|
|
|
|
def main() -> None:
|
|
"""Launch the FM24 Database Searcher GUI."""
|
|
app = QApplication(sys.argv)
|
|
app.setApplicationName("FM24 Database Searcher")
|
|
window = MainWindow()
|
|
window.show()
|
|
sys.exit(app.exec())
|