diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 836c6f7f6..63bd11fd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ env: ICE_ADAPTER_VERSION: 3.3.12 BUILD_VERSION: ${{ github.event.inputs.version }} PYTHON_VERSION: 3.14 - ZIG_VERSION: 0.15.2 + ZIG_VERSION: 0.16.0 JAVA_DISTRIBUTION: "oracle" JAVA_VERSION: 25 @@ -67,7 +67,7 @@ jobs: Write-Host "LIBPY=$LIBPY" $INCLUDEPY = $(python -c "import sysconfig; print(sysconfig.get_config_var('INCLUDEPY'))") Write-Host "INCLUDEPY=$INCLUDEPY" - zig build-lib -dynamic -lc -lpython3 -I"$INCLUDEPY" -L"$LIBPY" -O ReleaseSafe --name zigfafreplay src/replays/zigparser/zigfafreplay.zig + zig build-lib -dynamic -lc -lpython3 -I"$INCLUDEPY" -L"$LIBPY" -O ReleaseFast --name zigfafreplay src/replays/zigparser/zigfafreplay.zig move zigfafreplay.dll zigfafreplay.pyd @@ -167,7 +167,7 @@ jobs: echo "LIBPY=$LIBPY" INCLUDEPY=$(python3 -c "import sysconfig; print(sysconfig.get_config_var('INCLUDEPY'))") echo "INCLUDEPY=$INCLUDEPY" - zig build-lib -dynamic -lc -lpython$PYTHON_VERSION -I$INCLUDEPY -L$LIBPY -O ReleaseSafe --name zigfafreplay src/replays/zigparser/zigfafreplay.zig + zig build-lib -dynamic -lc -lpython$PYTHON_VERSION -I$INCLUDEPY -L$LIBPY -O ReleaseFast --name zigfafreplay src/replays/zigparser/zigfafreplay.zig mv libzigfafreplay.so zigfafreplay.so - name: Shrink scipy dependency diff --git a/res/client/client.css b/res/client/client.css index a2094d40a..6a044a930 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -392,6 +392,14 @@ QLineEdit#uploaderInput background-color: #353535; } +QLineEdit#mapNameEdit, QLineEdit#cliArgsEdit { + background-color: #18181D; +} + +QLineEdit#mapNameEdit:disabled, QLineEdit#cliArgsEdit:disabled { + background-color:#202025; +} + QFrame#rankedFrame { border-style:solid; @@ -842,6 +850,13 @@ QGroupBox padding-top: 15px; } +QGroupBox::indicator +{ + width: 13px; + height: 13px; + border: 1px solid black; +} + QGroupBox::indicator:unchecked { background: #303035; @@ -921,6 +936,30 @@ QCheckBox::indicator:hover { border-color: gray; } +QAbstractItemView::indicator +{ + width: 13px; + height: 13px; + border: 1px solid black; +} + +QAbstractItemView::indicator:hover { + border-color: gray; +} + +QAbstractItemView::indicator:disabled { + border-color: #202025; +} + +QAbstractItemView::indicator:checked { + image: url('%THEMEPATH%/client/chboxChecked.png'); +} + +QAbstractItemView::indicator:unchecked +{ + background: #303035; +} + /* Used for Ranked Buttons only at the moment*/ QToolButton#rankedPlay { diff --git a/res/dialogs/information.ui b/res/dialogs/information.ui new file mode 100644 index 000000000..88d5d7296 --- /dev/null +++ b/res/dialogs/information.ui @@ -0,0 +1,102 @@ + + + Dialog + + + + 0 + 0 + 416 + 363 + + + + Dialog + + + + + + + + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/res/games/mapgen.ui b/res/games/mapgen.ui index d50d36e3e..741a2258f 100644 --- a/res/games/mapgen.ui +++ b/res/games/mapgen.ui @@ -6,15 +6,83 @@ 0 0 - 662 - 475 + 800 + 561 Map Generator Options - - + + + + + 10 + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + true + + + + Options in UI may be incompatible with old generator versions + + + + + + + Fetch available versions + + + + + + + Version: + + + + + + + + 100 + 0 + + + + + latest + + + + + + + + Switch + + + + + + QFrame::Shape::StyledPanel @@ -52,6 +120,9 @@ Gerenation Type + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + @@ -80,6 +151,9 @@ Map Size (km) + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + @@ -126,6 +200,9 @@ Spawns + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + @@ -166,6 +243,9 @@ Teams + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + @@ -204,7 +284,10 @@ - Count: + Count + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter @@ -277,7 +360,7 @@ - + 0 @@ -346,7 +429,7 @@ - + 0 @@ -408,7 +491,7 @@ - + 10 @@ -510,7 +593,7 @@ - + 10 @@ -532,7 +615,7 @@ - + 10 @@ -580,7 +663,7 @@ - + @@ -676,10 +759,118 @@ - - + + + + + 0 + 0 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 15 + + + + + + 0 + 0 + + + + + 10 + + + + Generate by mapname + + + + + + + + + + neroxis_map_generator_(version)_(seed)_(options) + + + Qt::CursorMoveStyle::LogicalMoveStyle + + + + + + + + + + + + Generate with CLI + + + true + + + false + + + + + + + + Folder Path + + + + + + + + + 15 + + + + + + + + Run Help + + + + + + + - + @@ -786,87 +977,18 @@ - - - - - 0 - 0 - - - - QFrame::Shape::StyledPanel - - - QFrame::Shadow::Raised - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 15 - - - - - - 0 - 0 - - - - - 10 - - - - Generate by mapname - - - - - - - - 0 - 0 - - - - - 16777215 - 30 - - - - - 10 - - - - neroxis_map_generator_(version)_(seed)_(options) - - - - - - - + + + + + CheckableComboBox + QComboBox +
src/qt/widgets/checkablecombobox
+
+
diff --git a/res/themes/Dark Blue/client/client.css b/res/themes/Dark Blue/client/client.css index d8f39f6be..e40f1817e 100644 --- a/res/themes/Dark Blue/client/client.css +++ b/res/themes/Dark Blue/client/client.css @@ -983,6 +983,36 @@ QCheckBox::indicator:checked:hover { background: darkorange; } +QAbstractItemView::indicator +{ + width: 11px; + height: 11px; + border: 1px solid #374151; +} + +QAbstractItemView::indicator:disabled { + border-color: #1A1B2C; +} + +QAbstractItemView::indicator:unchecked +{ + background: #23253C; +} + +QAbstractItemView::indicator:checked +{ + background: orange; + border-color: black; +} + +QAbstractItemView::indicator:hover { + border-color: #6B7280; +} + +QAbstractItemView::indicator:checked:hover { + background: darkorange; +} + /* Used for Ranked Buttons only at the moment*/ QToolButton#rankedPlay { diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index 91ad6014a..7c263228d 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -78,12 +78,20 @@ def get( query_or_path: QueryOptions | str, response_handler: Callable[[dict[str, Any]], None], error_handler: Callable[[QNetworkReply], None] = _do_nothing, + *, + authorize: bool = True, ) -> QNetworkReply: if isinstance(query_or_path, str): url = self._url_from_endpoint(query_or_path) else: url = self.build_query_url(query_or_path) - return self.api.get(url, self._decode_and_handle(response_handler), error_handler) + + return self.api.get( + url, + self._decode_and_handle(response_handler), + error_handler, + authorize=authorize, + ) def post( self, @@ -146,9 +154,16 @@ def get( url: QUrl, response_handler: Callable[[QNetworkReply], None], error_handler: Callable[[QNetworkReply], None] = _do_nothing, + *, + authorize: bool = True, ) -> QNetworkReply: - logger.debug("Sending GET API request with URL: %s", url.toString()) - reply = self.manager.get(self.prepare_request(url)) + auth_status = "" if authorize else "unauthorized" + logger.debug("Sending %s GET API request with URL: %s", auth_status, url.toString()) + if authorize: + reply = self.manager.get(self.prepare_request(url)) + else: + reply = self.manager.get(QNetworkRequest(url)) + if reply is None: logger.error("Error sending GET request to: '%s'", url.toString()) raise RuntimeError("QNetworkAccessManager failed to create a QNetworkReply instance!") diff --git a/src/config/production.py b/src/config/production.py index d5107e34c..992c09560 100644 --- a/src/config/production.py +++ b/src/config/production.py @@ -14,6 +14,7 @@ 'display_name': 'Main Server (recommended)', 'api': 'https://api.{host}', 'user_api': 'https://user.{host}', + 'github_api': 'https://api.github.com', 'chat/host': 'chat.{host}', 'chat/port': 443, 'client/data_path': APPDATA_DIR, diff --git a/src/games/gamepanelwidget.py b/src/games/gamepanelwidget.py index 03e306e83..27d7f029a 100644 --- a/src/games/gamepanelwidget.py +++ b/src/games/gamepanelwidget.py @@ -92,7 +92,8 @@ def set_num_players(self, num: int) -> None: self.ui.numPlayersLabel.setText(f"({num})") def add_player(self, player: Player, color: QColor) -> None: - player_item = QListWidgetItem(f"{player.login} ({player.rating_estimate()})") + clan_tag = f"[{player.clan}]" if player.clan is not None else "" + player_item = QListWidgetItem(f"{clan_tag}{player.login} ({player.rating_estimate()})") if player.country is not None: country_icon = util.THEME.icon(f"chat/countries/{player.country.lower()}.png") player_item.setIcon(country_icon) diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py index 8ccaa199f..d470b8b56 100644 --- a/src/games/hostgamewidget.py +++ b/src/games/hostgamewidget.py @@ -489,7 +489,8 @@ def set_maps(self, mapnames: list[str]) -> None: for name in reversed(mapnames): item = QListWidgetItem(maps.getDisplayName(name)) item.setData(QtCore.Qt.ItemDataRole.UserRole, allmaps[name]) - item.setForeground(self._unseen_mapgen_brush) + if len(mapnames) > 1: + item.setForeground(self._unseen_mapgen_brush) self.ui.mapList.addItem(item) self.ui.mapList.sortItems() self.ui.mapList.setCurrentItem(item) @@ -616,7 +617,7 @@ def deselect_mods(self, *, ui: bool) -> None: def generateMap(self) -> None: dialog = MapGenDialog(self.client, self.client.map_generator) dialog.map_generated.connect(self.set_maps) - dialog.load_cmd_options() + dialog.setup() dialog.exec() dialog.deleteLater() diff --git a/src/games/mapgenoptions.py b/src/games/mapgenoptions.py index e6b7f9e72..0e7d6d2c0 100644 --- a/src/games/mapgenoptions.py +++ b/src/games/mapgenoptions.py @@ -3,9 +3,12 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QComboBox +from PyQt6.QtWidgets import QDoubleSpinBox from PyQt6.QtWidgets import QSpinBox +from PyQt6.QtWidgets import QWidget from src import config +from src.qt.widgets.checkablecombobox import CheckableComboBox class OptionMixin: @@ -31,13 +34,13 @@ def as_cmd_arg(self) -> list[str]: raise NotImplementedError -class MapGenOption(OptionMixin): +class MapGenOption[T: QWidget](OptionMixin): def __init__( - self, - name: str, - ui_elem: QComboBox | QSpinBox, - typ: type = str, - default: Any = None, + self, + name: str, + ui_elem: T, + typ: type = str, + default: Any | None = None, ) -> None: self.name = name self.ui_elem = ui_elem @@ -67,15 +70,15 @@ def as_cmd_arg(self) -> list[str]: return [f"--{self.name}", str(self.value())] -class ComboBoxOption(MapGenOption): +class _ComboBoxOption[T: QComboBox](MapGenOption[T]): def __init__( - self, - name: str, - ui_elem: QComboBox, - default: str | None = None, - opts: list[str] | None = None, + self, + name: str, + ui_elem: T, + default: str | None = None, + opts: list[str] | None = None, ) -> None: - MapGenOption.__init__(self, name, ui_elem, str, default) + super().__init__(name, ui_elem, str, default) self.opts = opts def set_opts(self, opts: list[str] | None) -> None: @@ -93,29 +96,57 @@ def active(self) -> bool: def populate(self) -> None: if self.opts is None: return + self.ui_elem.clear() for index, opt in enumerate(sorted(self.opts)): self.ui_elem.addItem(opt) self.ui_elem.setItemData(index, opt, Qt.ItemDataRole.ToolTipRole) def load(self) -> None: self.populate() - MapGenOption.load(self) + super().load() -class SpinBoxOption(MapGenOption): +class ComboBoxOption(_ComboBoxOption[QComboBox]): + ... + + +class CheckableComboBoxOption(_ComboBoxOption[CheckableComboBox]): def __init__( - self, - name: str, - ui_elem: QSpinBox, - typ: type, - default: int | float | None = None, + self, + name: str, + ui_elem: CheckableComboBox, + default: str, + opts: list[str] | None = None, ) -> None: - MapGenOption.__init__(self, name, ui_elem, typ, default) + ui_elem.setNoChoiceText(default) + super().__init__(name, ui_elem, default, opts) + + def save(self) -> None: + config.Settings.set( + f"mapGenerator/{self.ui_elem.objectName()}", + self.ui_elem.delimiter().join(self.ui_elem.currentData()), + ) - def set_value(self, value: int | float) -> None: + def as_cmd_arg(self) -> list[str]: + return [f"--{self.name}", random.choice(self.ui_elem.currentData())] + + +class SpinBoxOption(MapGenOption[QSpinBox]): + def set_value(self, value: int) -> None: + self.ui_elem.setValue(value) + + def value(self) -> int: + return self.ui_elem.value() + + def active(self) -> bool: + return self.ui_elem.isEnabled() + + +class DoubleSpinBoxOption(MapGenOption[QDoubleSpinBox]): + def set_value(self, value: float) -> None: self.ui_elem.setValue(value) - def value(self) -> int | float: + def value(self) -> float: return self.ui_elem.value() def active(self) -> bool: diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index 9aa7930d9..9d6fe51b3 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -1,22 +1,31 @@ import json import logging import os +import re +import shlex from enum import Enum from enum import auto from typing import TYPE_CHECKING +from typing import Any from typing import ClassVar -from typing import TypedDict from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets +from PyQt6.QtNetwork import QNetworkReply +from PyQt6.QtWidgets import QMessageBox +from semantic_version import Version from src import config from src import fafpath from src import util +from src.api.ApiBase import JsonApiBase from src.api.models.MapVersion import MapSize from src.decorators import with_logger +from src.fa.maps import getUserMapsFolder +from src.games.mapgenoptions import CheckableComboBoxOption from src.games.mapgenoptions import ComboBoxOption +from src.games.mapgenoptions import DoubleSpinBoxOption from src.games.mapgenoptions import RangeOption from src.games.mapgenoptions import SpinBoxOption from src.games.mapgenoptionsvalues import GenerationType @@ -29,16 +38,17 @@ from src.games.mapgenoptionsvalues import TextureStyle from src.mapGenerator.mapgenManager import MapGeneratorManager from src.qt.utils import block_signals +from src.ui.information_dialog import message_dialog if TYPE_CHECKING: from src.client import ClientWindow +GITHUB_NEXT_PAGE = re.compile(r"(?<=<)([\S]*)(?=>; rel=\"next\")") + FormClass, BaseClass = util.THEME.loadUiType("games/mapgen.ui") -class MapGenDynamicConfig(TypedDict): - gen_version: str - options: dict[str, list[str]] +type MapGenDynamicConfig = dict[str, dict[str, list[str]]] @with_logger @@ -130,6 +140,7 @@ def __init__(self, parent: ClientWindow, mapgen_manager: MapGeneratorManager) -> self.setupUi(self) self.mapgen_manager = mapgen_manager + self.mapgen_manager.new_available.connect(self.update_version) self.setWindowTitle(f"Map Generator Options - {self.mapgen_manager.currentVersion}") self.generationType.setMinimumWidth(80) @@ -139,7 +150,7 @@ def __init__(self, parent: ClientWindow, mapgen_manager: MapGeneratorManager) -> self.statusBar.setSizeGripEnabled(False) self.statusBarLayout.addWidget(self.statusBar) - self.mapNamePlainTextEdit.textChanged.connect(self.user_mapname_changed) + self.mapNameEdit.textChanged.connect(self.user_mapname_changed) self.useCustomStyleCheckBox.checkStateChanged.connect(self.on_custom_style) self.generationType.currentTextChanged.connect(self.gen_type_changed) self.mapSize.valueChanged.connect(self.map_size_changed) @@ -158,44 +169,57 @@ def __init__(self, parent: ClientWindow, mapgen_manager: MapGeneratorManager) -> ) self.options_path = os.path.join(util.MAPGEN_DIR, "mapgen_options.json") + self.release_tags = os.path.join(util.MAPGEN_DIR, "release_tags") + + self.api = JsonApiBase() + self.api.host_config_key = "github_api" + self.api_reply: QNetworkReply | None = None + self.releases: set[Version] = set() + + self.buttonFetchVersions.clicked.connect(self.get_all_versions) + self.buttonSwitchVersion.clicked.connect(self.switch_version) + self.buttonHelp.clicked.connect(self.run_help) + self.groupCLI.toggled.connect(self.on_cli_toggled) + self.checkCLIMapFolder.toggled.connect(self.on_cli_map_folder_toggled) + self.comboVersion.currentTextChanged.connect(self.on_version_selection_changed) - def get_dynamic_options(self) -> dict[str, ComboBoxOption]: + def get_dynamic_options(self) -> dict[str, CheckableComboBoxOption]: return { - "symmetries": ComboBoxOption( + "symmetries": CheckableComboBoxOption( "terrain-symmetry", self.terrainSymmetry, Sentinel.RANDOM.value, - Sentinel.values() + TerrainSymmetry.values(), + TerrainSymmetry.values(), ), - "styles": ComboBoxOption( + "styles": CheckableComboBoxOption( "style", self.mapStyle, Sentinel.RANDOM.value, - Sentinel.values() + MapStyle.values(), + MapStyle.values(), ), - "terrain-styles": ComboBoxOption( + "terrain-styles": CheckableComboBoxOption( "terrain-style", self.terrainStyle, Sentinel.RANDOM.value, - Sentinel.values() + TerrainStyle.values(), + TerrainStyle.values(), ), - "texture-styles": ComboBoxOption( + "texture-styles": CheckableComboBoxOption( "texture-style", self.textureStyle, Sentinel.RANDOM.value, - Sentinel.values() + TextureStyle.values(), + TextureStyle.values(), ), - "resource-styles": ComboBoxOption( + "resource-styles": CheckableComboBoxOption( "resource-style", self.resourceGenerator, Sentinel.RANDOM.value, - Sentinel.values() + ResourceStyle.values(), + ResourceStyle.values(), ), - "prop-styles": ComboBoxOption( + "prop-styles": CheckableComboBoxOption( "prop-style", self.propGenerator, Sentinel.RANDOM.value, - Sentinel.values() + PropStyle.values(), + PropStyle.values(), ), } @@ -211,10 +235,14 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: super().closeEvent(event) def on_options_extracted(self, options: dict[str, list[str]]) -> None: - to_save = { - "gen_version": self.mapgen_manager.currentVersion, - "options": options, - } + try: + with open(self.options_path) as f: + to_save = json.load(f) + if "gen_version" in to_save: # XXX: remove backward compatibility + to_save |= {to_save["gen_version"]: to_save["options"]} + except FileNotFoundError: + to_save = {} + to_save |= {self.mapgen_manager.currentVersion: options} with open(self.options_path, "w") as f: json.dump(to_save, f, indent=2) @@ -225,39 +253,154 @@ def on_options_extracted(self, options: dict[str, list[str]]) -> None: def on_options_extraction_error(self) -> None: self.setWindowTitle("Map Generator Options") self.setEnabled(True) - try: - os.unlink(self.options_path) - except FileNotFoundError: - pass self.set_cmd_options({}) def _load_dynamic_options(self) -> MapGenDynamicConfig: if not os.path.exists(self.options_path): - return {"gen_version": "-1", "options": {}} + return {} with open(self.options_path) as f: - return json.load(f) + options = json.load(f) + if "gen_version" in options: # XXX: remove backward compatibility + return options | {options["gen_version"]: options["options"]} + else: + return options + + def switch_version(self) -> None: + version = self.comboVersion.currentText() + if not version or version == self.mapgen_manager.currentVersion: + return + if version == "latest": + version = self.mapgen_manager.latestVersion + if self.mapgen_manager.get_generator(version): + self.mapgen_manager.set_current_version_number(version) + self.load_cmd_options() + self.setWindowTitle(f"Map Generator Options - {self.mapgen_manager.currentVersion}") + self.buttonSwitchVersion.setEnabled(False) + + def on_version_selection_changed(self, text: str) -> None: + if text == "latest": + enabled = self.mapgen_manager.currentVersion != self.mapgen_manager.latestVersion + else: + enabled = text != self.mapgen_manager.currentVersion + self.buttonSwitchVersion.setEnabled(enabled) + + def get_all_versions(self) -> None: + self.buttonFetchVersions.setEnabled(False) + self.api_reply = self.api.get( + "/repos/faforever/neroxis-map-generator/releases?per_page=100", + self.process_releases, # type: ignore[argument] + self.on_api_error, + authorize=False, + ) + + def _get_page(self, full_url: str) -> None: + path = QtCore.QUrl(full_url).adjusted( + QtCore.QUrl.UrlFormattingOption.RemoveScheme + | QtCore.QUrl.UrlFormattingOption.RemoveAuthority, + ).toString() + self.api_reply = self.api.get( + path, + self.process_releases, # type: ignore[argument] + self.on_api_error, + authorize=False, + ) + + def process_releases(self, message: list[dict[str, Any]]) -> None: + for release in message: + for asset in release["assets"]: + if asset["name"].endswith(".jar"): + self.releases.add(Version(release["tag_name"])) + break + assert self.api_reply is not None + link = self.api_reply.rawHeader("link") + if not link or (next_url := GITHUB_NEXT_PAGE.search(link.data().decode())) is None: + self.populate_versions_combo() + self.buttonFetchVersions.setEnabled(True) + self.save_release_tags() + return + self._get_page(next_url[0]) + + def on_api_error(self, reply: QNetworkReply) -> None: + QMessageBox.critical( + self, "Error", f"Could not get releases from GitHub API: {reply.error()}", + ) + self.buttonFetchVersions.setEnabled(True) + + def populate_versions_combo(self) -> None: + self.comboVersion.clear() + self.comboVersion.addItem("latest") + self.comboVersion.addItems(map(str, sorted(self.releases, reverse=True))) + self.comboVersion.setCurrentText(self.mapgen_manager.currentVersion) + + def update_version(self) -> None: + if ( + self.comboVersion.currentText() == "latest" + or QtWidgets.QMessageBox.question( + self, + "New version", + f"A new generator version is available: {self.mapgen_manager.latestVersion}.\n" + "Do you wish to update?", + ) == QtWidgets.QMessageBox.StandardButton.Yes + ): + self.mapgen_manager.get_generator(self.mapgen_manager.latestVersion) + self.mapgen_manager.set_current_version_number(self.mapgen_manager.latestVersion) + self.releases.add(Version(self.mapgen_manager.latestVersion)) + self.comboVersion.insertItem(1, self.mapgen_manager.latestVersion) + self.save_release_tags() + + def load_release_tags(self) -> None: + try: + with open(self.release_tags) as f: + self.releases = {Version(line) for line in f.readlines()} + except FileNotFoundError: + pass + try: + self.releases.add(Version(self.mapgen_manager.currentVersion)) + except ValueError: + pass + + def save_release_tags(self) -> None: + with open(self.release_tags, "w") as f: + f.write("\n".join(map(str, sorted(self.releases, reverse=True)))) + + def setup(self) -> None: + self.load_release_tags() + self.populate_versions_combo() + self.mapgen_manager.check_updates() + self.load_cmd_options() + self.groupCLI.setChecked(config.Settings.get("mapGenerator/cli", False, type=bool)) + self.cliArgsEdit.setText(config.Settings.get("mapGenerator/cliArgs", "")) + self.checkCLIMapFolder.setChecked( + config.Settings.get("mapGenerator/cliMapFolder", False, type=bool), + ) + self.buttonSwitchVersion.setEnabled(False) + if self.mapgen_manager.latestVersion == self.mapgen_manager.currentVersion: + self.comboVersion.setCurrentText("latest") def load_cmd_options(self) -> None: + if Version(self.mapgen_manager.currentVersion) < Version("1.12.0"): + self.set_cmd_options({}) + return dynamic_options = self._load_dynamic_options() - version = dynamic_options["gen_version"] - if not self.mapgen_manager.latestVersion: - self.mapgen_manager.update_version_number() - if version != self.mapgen_manager.currentVersion: + if self.mapgen_manager.currentVersion not in dynamic_options: + self.mapgen_manager.update_current_if_needed() self.setWindowTitle("Loading Mapgen Options...") self.setEnabled(False) - gen_path = self.mapgen_manager.get_generator(self.mapgen_manager.latestVersion) + gen_path = self.mapgen_manager.get_generator(self.mapgen_manager.currentVersion) self.options_extractor.extract_all(gen_path) else: - self.setWindowTitle(f"Map Generator Options - {version}") - self.set_cmd_options(dynamic_options["options"]) + self.setWindowTitle(f"Map Generator Options - {self.mapgen_manager.currentVersion}") + self.set_cmd_options(dynamic_options[self.mapgen_manager.currentVersion]) def set_cmd_options(self, dynamic_options: dict[str, list[str]]) -> None: self.statusBar.showMessage("") for key, mapgen_option in self.dynamic_options.items(): - if key in dynamic_options: - mapgen_option.set_opts(Sentinel.values() + dynamic_options[key]) + try: + mapgen_option.set_opts(dynamic_options[key]) + except KeyError: + pass - self.cmd_options: list[ComboBoxOption | SpinBoxOption | RangeOption] = [ + self.cmd_options = [ ComboBoxOption( "visibility", self.generationType, @@ -268,7 +411,7 @@ def set_cmd_options(self, dynamic_options: dict[str, list[str]]) -> None: SpinBoxOption("spawn-count", self.numberOfSpawns, int, 2), SpinBoxOption("num-teams", self.numberOfTeams, int, 2), SpinBoxOption("num-to-generate", self.numberOfMaps, int, 1), - SpinBoxOption("map-size", self.mapSize, float, 5), + DoubleSpinBoxOption("map-size", self.mapSize, float, 5), RangeOption( "resource-density", SpinBoxOption("", self.minResourceDensity, int, 0), @@ -284,8 +427,7 @@ def set_cmd_options(self, dynamic_options: dict[str, list[str]]) -> None: @QtCore.pyqtSlot() def user_mapname_changed(self) -> None: - mapname = self.mapNamePlainTextEdit.toPlainText() - self.optionsFrame.setEnabled(mapname.strip() == "") + self.optionsFrame.setEnabled(self.mapNameEdit.text().strip() == "") @QtCore.pyqtSlot(QtCore.Qt.CheckState) def on_custom_style(self, state: QtCore.Qt.CheckState) -> None: @@ -346,6 +488,9 @@ def save_preferences(self) -> None: "mapGenerator/useCustomStyle", self.useCustomStyleCheckBox.isChecked(), ) + config.Settings.set("mapGenerator/cli", self.groupCLI.isChecked()) + config.Settings.set("mapGenerator/cliMapFolder", self.checkCLIMapFolder.isChecked()) + config.Settings.set("mapGenerator/cliArgs", self.cliArgsEdit.text().strip()) @QtCore.pyqtSlot() def save_preferences_and_quit(self) -> None: @@ -364,12 +509,15 @@ def generate_map(self) -> None: self.save_preferences_and_quit() else: self.save_preferences() + message_dialog(self, "Error", "Process output:", self.mapgen_manager.process_stdout) def set_arguments(self) -> list[str]: - args: list[str] = [] - if mapname := self.mapNamePlainTextEdit.toPlainText().strip(): - args.extend(["--map-name", mapname]) + if self.groupCLI.isChecked(): + return shlex.split(self.cliArgsEdit.text().strip()) + elif mapname := self.mapNameEdit.text().strip(): + return ["--map-name", mapname] else: + args: list[str] = [] for option in self.cmd_options: if option.name == "map-size": args.append("--map-size") @@ -377,4 +525,23 @@ def set_arguments(self) -> list[str]: args.append(str(size_px)) elif option.active(): args.extend(option.as_cmd_arg()) - return args + return args + + def run_help(self) -> None: + self.mapgen_manager.run_help() + message_dialog(self, "Usage", "", self.mapgen_manager.process_stdout) + + def on_cli_toggled(self, on: bool) -> None: + self.optionsFrame.setEnabled(not on) + self.mapNameEdit.setEnabled(not on) + + def on_cli_map_folder_toggled(self, on: bool) -> None: + cli_args = shlex.split(self.cliArgsEdit.text().strip()) + + if cli_args and cli_args[0] in ["--folder-path", "--out-path"]: + if not on: + cli_args = cli_args[2:] + elif on: + cli_args = ["--folder-path", getUserMapsFolder()] + cli_args + + self.cliArgsEdit.setText(shlex.join(cli_args)) diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py index a1c2c9d7e..9d02b3f9e 100644 --- a/src/mapGenerator/mapgenManager.py +++ b/src/mapGenerator/mapgenManager.py @@ -6,9 +6,11 @@ from PyQt6.QtCore import QObject from PyQt6.QtCore import Qt from PyQt6.QtCore import QUrl +from PyQt6.QtCore import pyqtSignal from PyQt6.QtNetwork import QNetworkAccessManager from PyQt6.QtNetwork import QNetworkReply from PyQt6.QtNetwork import QNetworkRequest +from semantic_version import Version from src import util from src.config import Settings @@ -25,9 +27,11 @@ class MapGeneratorManager(QObject): + new_available = pyqtSignal() + def __init__(self) -> None: super().__init__() - self.latestVersion = "" + self.latestVersion = Settings.get("mapGenerator/latest_version", "") self.currentVersion = Settings.get('mapGenerator/version', "0", str) @@ -35,28 +39,36 @@ def __init__(self) -> None: self.manager.finished.connect(self.on_request_finished) self._update_waiter = QEventLoop() + self._updates_checked = False + self.process_stdout = "" def set_current_version_number(self, version: str) -> None: self.currentVersion = version Settings.set("mapGenerator/version", version) - def update_version_number(self) -> None: + def set_latest_version(self, version: str) -> None: + new = Version(version) > Version(self.latestVersion) + self.latestVersion = version + Settings.set("mapGenerator/latest_version", version) + if new: + self.new_available.emit() + + def update_current_if_needed(self) -> None: + if self.currentVersion != "0": + return self.check_updates() self.set_current_version_number(self.latestVersion) def generateMap(self, mapname: str | None = None, args: list[str] | None = None) -> list[str]: if mapname is None: # Requests latest version once per session - if self.currentVersion == "0" or not self.latestVersion: - self.update_version_number() + self.update_current_if_needed() version = self.currentVersion else: matcher = generatedMapPattern.match(mapname) assert matcher is not None version = matcher[1] args = ['--map-name', mapname] - if version > self.currentVersion: - self.set_current_version_number(version) generator_path = self.get_generator(version) if not generator_path: @@ -93,6 +105,7 @@ def generateMap(self, mapname: str | None = None, args: list[str] | None = None) # Start generator with progress bar process = MapGeneratorProcess(generator_path, maps_folder, args or []) process.run() + self.process_stdout = process.stdout # Check if map exists or generator failed for name in process.mapnames: @@ -125,6 +138,9 @@ def check_updates(self) -> None: Not downloading anything here. Just requesting latest version and return the number ''' + if self._updates_checked: + return + request = QNetworkRequest(QUrl(RELEASE_URL).resolved(QUrl("latest"))) self.manager.get(request) @@ -142,9 +158,17 @@ def check_updates(self) -> None: self._update_waiter.exec() progress.close() + self._updates_checked = True def on_request_finished(self, reply: QNetworkReply) -> None: redirect_url = reply.url() if "releases/tag/" in redirect_url.toString(): - self.latestVersion = redirect_url.fileName() + self.set_latest_version(redirect_url.fileName()) self._update_waiter.quit() + + def run_help(self) -> None: + self.update_current_if_needed() + generator_path = self.get_generator(self.currentVersion) + process = MapGeneratorProcess(generator_path, ".", ["--help"]) + process.run() + self.process_stdout = process.stdout diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py index 73ad44ae4..b7b64c878 100644 --- a/src/mapGenerator/mapgenProcess.py +++ b/src/mapGenerator/mapgenProcess.py @@ -43,17 +43,19 @@ def __init__(self, gen_path: str, out_path: str, args: list[str]) -> None: self.map_generator_process.setWorkingDirectory(out_path) self.map_generator_process.readyReadStandardOutput.connect(self.on_log_ready) self.map_generator_process.readyReadStandardError.connect(self.on_error_ready) + self.stdout = "" self._error_msgs_received = 0 self.map_generator_process.finished.connect(self.on_exit) self.map_name = None - self.map_names: list[str] = [] + self.map_names: set[str] = set() self.java_path = fafpath.get_java_path() self.args = ["-jar", gen_path] self.args.extend(args) def run(self) -> None: + self.stdout = "" logger.info("Starting map generator with %s", " ".join((self.java_path, *self.args))) generatorLogger.info(">>> --------------------- MapGenerator Launch") @@ -81,14 +83,15 @@ def mapnames(self) -> list[str]: def on_log_ready(self) -> None: standard_output = self.map_generator_process.readAllStandardOutput() - data = standard_output.data().strip().decode().split(os.linesep) - for line in data: + data = standard_output.data().decode() + self.stdout += data + for line in data.split(os.linesep): if line == "": continue - if re.match(mapgenUtils.generatedMapPattern, line): + if m := re.search(mapgenUtils.generatedMapPattern, line): if self.map_name is None: - self.map_name = line.strip() - self.map_names.append(line.strip()) + self.map_name = m[0].strip().lower() + self.map_names.add(m[0].strip().lower()) generatorLogger.info(line.strip()) # Kinda fake progress bar. Better than nothing :) # some 'lines' have multiple lines in them, display only the first diff --git a/src/qt/widgets/checkablecombobox.py b/src/qt/widgets/checkablecombobox.py new file mode 100644 index 000000000..5abce64da --- /dev/null +++ b/src/qt/widgets/checkablecombobox.py @@ -0,0 +1,156 @@ +from typing import Any + +from PyQt6.QtCore import QEvent +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QSize +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QTimerEvent +from PyQt6.QtGui import QFontMetrics +from PyQt6.QtGui import QResizeEvent +from PyQt6.QtGui import QStandardItem +from PyQt6.QtGui import QWheelEvent +from PyQt6.QtWidgets import QComboBox +from PyQt6.QtWidgets import QStyledItemDelegate +from PyQt6.QtWidgets import QStyleOptionViewItem +from PyQt6.QtWidgets import QWidget + + +# https://gis.stackexchange.com/questions/350148/qcombobox-multiple-selection-pyqt5 +class CheckableComboBox(QComboBox): + + # Subclass Delegate to increase item height + class Delegate(QStyledItemDelegate): + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: + size = super().sizeHint(option, index) + size.setHeight(20) + return size + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + # Make the combo editable to set a custom text, but readonly + self.setEditable(True) + self.lineEdit().setReadOnly(True) + + # Use custom delegate + self.setItemDelegate(CheckableComboBox.Delegate()) + + # Update the text when an item is toggled + self.model().dataChanged.connect(self.updateText) + + # Hide and show popup when clicking the line edit + self.lineEdit().installEventFilter(self) + self.closeOnLineEditClick = False + + # Prevent popup from closing when clicking on an item + self.view().viewport().installEventFilter(self) + + self.no_choice_text = "" + + def setNoChoiceText(self, text: str) -> None: + self.no_choice_text = text + + def resizeEvent(self, event: QResizeEvent | None) -> None: # type: ignore[override] + super().resizeEvent(event) + # Recompute text to elide as needed + self.updateText() + + def wheelEvent(self, e: QWheelEvent | None) -> None: + # TODO + return + + def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # type: ignore[override] # noqa: E501 + if obj is None or not self.isEnabled(): + return False + + if event is None: + return super().eventFilter(obj, event) + + if obj is self.lineEdit(): + if event.type() == QEvent.Type.MouseButtonRelease: + if self.closeOnLineEditClick: + self.hidePopup() + else: + self.showPopup() + return True + return False + + if obj is self.view().viewport(): + if event.type() == QEvent.Type.MouseButtonRelease: + index = self.view().indexAt(event.pos()) + item = self.model().item(index.row()) + + if item.checkState() == Qt.CheckState.Checked: + item.setCheckState(Qt.CheckState.Unchecked) + else: + item.setCheckState(Qt.CheckState.Checked) + return True + return False + + def showPopup(self) -> None: + super().showPopup() + # When the popup is displayed, a click on the lineedit should close it + self.closeOnLineEditClick = True + + def hidePopup(self) -> None: + super().hidePopup() + # Used to prevent immediate reopening when clicking on the lineEdit + self.startTimer(100) + # Refresh the display text when closing + self.updateText() + + def timerEvent(self, event: QTimerEvent | None) -> None: # type: ignore[override] + if event is None: + return + # After timeout, kill timer, and reenable click on line edit + self.killTimer(event.timerId()) + self.closeOnLineEditClick = False + + def updateText(self) -> None: + texts = self.currentData() + text = self.delimiter().join(texts) if texts else self.no_choice_text + + # Compute elided text (with "...") + metrics = QFontMetrics(self.lineEdit().font()) + elidedText = metrics.elidedText(text, Qt.TextElideMode.ElideRight, self.lineEdit().width()) + self.lineEdit().setText(elidedText) + + def addItem(self, text: str, data: Any | None = None) -> None: + item = QStandardItem() + item.setText(text) + if data is None: + item.setData(text) + else: + item.setData(data) + item.setData(Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole) + self.model().appendRow(item) + + def addItems(self, texts: list[str], datalist: list[Any] | None = None) -> None: + if datalist is None: + return + for i, text in enumerate(texts): + try: + data = datalist[i] + except (TypeError, IndexError): + data = None + self.addItem(text, data) + + def currentData(self) -> list[Any]: + # Return the list of selected items data + res = [] + for i in range(self.model().rowCount()): + if self.model().item(i).checkState() == Qt.CheckState.Checked: + res.append(self.model().item(i).data()) + return res + + def setCurrentText(self, text: str | None) -> None: + if text is None: + return + choices = text.split(self.delimiter()) + for i in range(self.model().rowCount()): + if self.model().item(i).text() in choices: + self.model().item(i).setCheckState(Qt.CheckState.Checked) + + def delimiter(self) -> str: + return ", " diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 400435bc4..32e83708b 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -16,6 +16,7 @@ from PyQt6.QtGui import QAction from PyQt6.QtGui import QFont from PyQt6.QtGui import QFontMetrics +from PyQt6.QtWidgets import QApplication from src import util from src.api.models.Game import Game @@ -416,12 +417,19 @@ def generate_scoreboard(self) -> Scoreboard: def pressed(self) -> None: menu = QtWidgets.QMenu(self.parent_widget) + action_watched = QAction(f"Mark as {'unwatched' if self.watched() else 'watched'}", menu) - action_watched.triggered.connect(self.change_watched_status) + actionLink = QAction("Copy Link", menu) actionDownload = QAction("Download replay", menu) + + action_watched.triggered.connect(self.change_watched_status) + actionLink.triggered.connect(lambda: QApplication.clipboard().setText(self.url)) actionDownload.triggered.connect(self.downloadReplay) + menu.addAction(action_watched) + menu.addAction(actionLink) menu.addAction(actionDownload) + menu.popup(QtGui.QCursor.pos()) def downloadReplay(self): diff --git a/src/replays/zigparser/decompressor.zig b/src/replays/zigparser/decompressor.zig index 302e569dc..edd77a577 100644 --- a/src/replays/zigparser/decompressor.zig +++ b/src/replays/zigparser/decompressor.zig @@ -33,8 +33,8 @@ pub fn decompress(compressed: []u8, allocator: Allocator) !structs.Preprocessed return .{ .metadata = metadata, .data = data }; } -pub fn decompress_file(path: []const u8, allocator: Allocator) !structs.Preprocessed { - const replay = try std.fs.cwd().readFileAlloc(allocator, path, 0x20000000); +pub fn decompress_file(io: std.Io, path: []const u8, allocator: Allocator) !structs.Preprocessed { + const replay = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(0x20000000)); defer allocator.free(replay); return try decompress(replay, allocator); } diff --git a/src/replays/zigparser/zigfafreplay.zig b/src/replays/zigparser/zigfafreplay.zig index d65bab201..ea1c84fa4 100644 --- a/src/replays/zigparser/zigfafreplay.zig +++ b/src/replays/zigparser/zigfafreplay.zig @@ -6,7 +6,11 @@ const decompressor = @import("decompressor.zig"); const parser = @import("parser.zig"); const structs = @import("structs.zig"); +var threaded: std.Io.Threaded = .init_single_threaded; +const io = threaded.io(); + fn parse_replaydata(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c]py.PyObject { + @setRuntimeSafety(true); var buf: py.Py_buffer = undefined; if (py.PyArg_ParseTuple(args, "y*", &buf) == 0) { return null; @@ -33,6 +37,7 @@ fn parse_replaydata(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c] } fn parse_compressed(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c]py.PyObject { + @setRuntimeSafety(true); var buf: py.Py_buffer = undefined; if (py.PyArg_ParseTuple(args, "y*", &buf) == 0) { return null; @@ -63,6 +68,7 @@ fn parse_compressed(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c] } fn parse_file(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c]py.PyObject { + @setRuntimeSafety(true); var buf: py.Py_buffer = undefined; if (py.PyArg_ParseTuple(args, "s*", &buf) == 0) { return null; @@ -74,7 +80,7 @@ fn parse_file(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c]py.PyO const size: usize = @intCast(buf.len); const path: []u8 = @as([*]u8, @ptrCast(buf.buf))[0..size]; - const preprocessed = decompressor.decompress_file(path, allocator) catch return py.Py_BuildValue(""); + const preprocessed = decompressor.decompress_file(io, path, allocator) catch return py.Py_BuildValue(""); defer preprocessed.deinit(allocator); var replay_parser = parser.parse(preprocessed.data, allocator) catch |err| { @@ -93,6 +99,7 @@ fn parse_file(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c]py.PyO } fn chart_rolling_window(_: [*c]py.PyObject, args: [*c]py.PyObject) callconv(.c) [*c]py.PyObject { + @setRuntimeSafety(true); var chart_data: *py.PyObject = undefined; var ticks: c_long = undefined; if (py.PyArg_ParseTuple(args, "Ol", &chart_data, &ticks) == 0) { diff --git a/src/ui/information_dialog.py b/src/ui/information_dialog.py new file mode 100644 index 000000000..1ee9a4cf8 --- /dev/null +++ b/src/ui/information_dialog.py @@ -0,0 +1,39 @@ +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QWidget + +from src.util import THEME + +FormClass, BaseClass = THEME.loadUiType("dialogs/information.ui") + + +class MessageDialog(FormClass, BaseClass): + def __init__(self, parent: QWidget | None = None) -> None: + BaseClass.__init__(self, parent) + self.setupUi(self) + self.set_icon() + + def setDetailedText(self, text: str) -> None: + self.editDetails.setPlainText(text) + + def setText(self, text: str) -> None: + self.labelText.setText(text) + + def set_icon(self) -> None: + if p := self.parent(): + style = p.style() + else: + style = QApplication.style() + if not style: + return + pixmap = style.standardPixmap(QStyle.StandardPixmap.SP_MessageBoxInformation) + self.labelIcon.setPixmap(pixmap) + + +def message_dialog(parent: QWidget | None, title: str, text: str, details: str) -> None: + dialog = MessageDialog(parent) + dialog.setWindowTitle(title) + dialog.setText(text) + dialog.setDetailedText(details) + dialog.exec() + dialog.deleteLater()