"""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 = """\

How to Import Player Attributes

The FM24 binary database only contains player names and dates of birth. To see CA, PA, and attribute values, you need to import an HTML export from Football Manager.

Steps:

  1. Open Football Manager 2024
  2. Go to Scouting > Players (or any player search screen)
  3. Set up a custom view with columns: Name, Club, Nat, Pos, CA, PA, and all attributes (Cor, Cro, Dri, Fin, etc.)
  4. Select all players with Ctrl+A
  5. Press Ctrl+P to print
  6. Choose "Web Page" as the format and save the file
  7. Use File > Import HTML Export in this app to load it

Tip: Export multiple pages (e.g. u21 players, each league) and import them all — the app merges data automatically.

""" 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())