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